@gitkraken/core-gitlens 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"integrationAuthenticationProvider.js","names":[],"sources":["../../../../../plus/integrations/src/authentication/integrationAuthenticationProvider.ts"],"sourcesContent":["import { trace } from '@gitlens/utils/decorators/log.js';\nimport type { Disposable } from '@gitlens/utils/disposable.js';\nimport type { Event } from '@gitlens/utils/event.js';\nimport { Emitter } from '@gitlens/utils/event.js';\nimport type { IntegrationIds } from '../constants.js';\nimport type { Sources } from '../telemetry.js';\nimport { areDomainsOnSameHost } from '../utils/domain.utils.js';\nimport { isNonExpiringZeroTokenIntegrationId, isSelfManagedHostIntegrationId } from '../utils/integration.utils.js';\nimport type { ConfiguredIntegrationService } from './configuredIntegrationService.js';\nimport type { IntegrationAuthenticationService } from './integrationAuthenticationService.js';\nimport type { ProviderAuthenticationSession } from './models.js';\nimport { isSupportedCloudIntegrationId } from './models.js';\n\nconst maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers)\n\nexport interface IntegrationAuthenticationProviderDescriptor {\n\tid: IntegrationIds;\n\tscopes: string[];\n}\n\nexport interface IntegrationAuthenticationSessionDescriptor {\n\tdomain: string;\n\tscopes: string[];\n\t/** When set, reads/deletes only the matching storage variant. */\n\tcloud?: boolean;\n\t/**\n\t * Targets a specific connection when a provider has multiple accounts connected. When omitted,\n\t * operations resolve to the provider's primary connection (see\n\t * {@link ConfiguredIntegrationService.resolveConnectionId}).\n\t */\n\tconnectionId?: string;\n\t[key: string]: unknown;\n}\n\n/**\n * Options for {@link IntegrationAuthenticationProvider.getSession}. The two variants keep `sync` exclusive\n * with the interactive flags: a sync resolves whatever the cloud already has, so it must never open a connect\n * window.\n *\n * `refreshRejectedToken` marks the stored token as known-refused by the provider (an `AuthenticationError` on\n * a previous read), which forces a cloud `/refresh` even when the token's expiry still claims it is valid —\n * the case a purely time-based refresh cannot see. The refresh token itself never reaches the client: the GK\n * cloud performs the exchange server-side.\n *\n * `refetch` (sync only) fetches a fresh token instead of serving the stored one, WITHOUT deleting the stored one\n * first: it is replaced only once the fetch returns a session. A forced re-sync used to delete it up front, so a\n * fetch that failed transiently (a GK API 429 or 5xx) left the connection with no token at all, and every read\n * until the next good sync reported it as gone (gitkraken/kepler#3546).\n */\nexport type GetSessionOptions =\n\t| {\n\t\t\tcreateIfNeeded?: boolean;\n\t\t\tforceNewSession?: boolean;\n\t\t\tsync?: never;\n\t\t\trefetch?: never;\n\t\t\trefreshRejectedToken?: boolean;\n\t\t\tsource?: Sources;\n\t }\n\t| {\n\t\t\tcreateIfNeeded?: never;\n\t\t\tforceNewSession?: never;\n\t\t\tsync: boolean;\n\t\t\trefetch?: boolean;\n\t\t\trefreshRejectedToken?: boolean;\n\t\t\tsource?: Sources;\n\t };\n\n/**\n * What {@link IntegrationAuthenticationProviderBase.getSession} passes down once it has decided the stored\n * session can't be served as-is. `refreshIfExpired` is its own conclusion, not a caller's option.\n */\ntype GetNewSessionOptions = GetSessionOptions & { refreshIfExpired?: boolean };\n\nexport interface IntegrationAuthenticationProvider extends Disposable {\n\t/**\n\t * Clears the stored secret for one connection only (never the whole provider). Unlike\n\t * {@link deleteAllSessions}, this by default leaves the descriptor in `integrations:configured` — its\n\t * primary caller is a forced re-sync, which needs `getConfigured()` to keep reporting the connection\n\t * while a fresh session is fetched to replace the deleted secret. Implementers should not treat this as\n\t * a full disconnect. Pass `preserveConfigured: false` to also drop the descriptor (e.g. when the\n\t * re-sync's replacement fetch failed and the connection should no longer be reported as connected).\n\t */\n\tdeleteSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: { preserveConfigured?: boolean },\n\t): Promise<void>;\n\tdeleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void>;\n\t/**\n\t * Resolves the session for `descriptor`, from storage when it is still usable and from the cloud\n\t * otherwise. See {@link GetSessionOptions}.\n\t */\n\tgetSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined>;\n\tget onDidChange(): Event<void>;\n}\n\nabstract class IntegrationAuthenticationProviderBase<\n\tID extends IntegrationIds = IntegrationIds,\n> implements IntegrationAuthenticationProvider {\n\tprotected readonly disposables: Disposable[] = [];\n\n\tconstructor(\n\t\tprotected readonly authenticationService: IntegrationAuthenticationService,\n\t\tprotected readonly configuredIntegrationService: ConfiguredIntegrationService,\n\t) {}\n\n\tdispose(): void {\n\t\tthis.disposables.forEach(d => d.dispose());\n\t}\n\n\tprivate readonly _onDidChange = new Emitter<void>();\n\tget onDidChange(): Event<void> {\n\t\treturn this._onDidChange.event;\n\t}\n\n\tprotected abstract get authProviderId(): ID;\n\n\t@trace()\n\tasync deleteSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: { preserveConfigured?: boolean },\n\t): Promise<void> {\n\t\tconst domain = isSelfManagedHostIntegrationId(this.authProviderId) ? descriptor?.domain : undefined;\n\t\tconst configured = this.configuredIntegrationService.getConfigured(this.authProviderId, {\n\t\t\tdomain: domain,\n\t\t});\n\n\t\t// Scope the descriptor to cloud so resolveConnectionId targets the cloud variant's id: this is a\n\t\t// cloud-only delete, and a mixed local+cloud connection whose local descriptor is primary would\n\t\t// otherwise resolve the local id and leave the cloud secret intact.\n\t\tawait this.configuredIntegrationService.deleteStoredSessions(\n\t\t\tthis.authProviderId,\n\t\t\t{ ...descriptor, cloud: true },\n\t\t\ttrue,\n\t\t\t{ preserveConfigured: options?.preserveConfigured ?? true },\n\t\t);\n\n\t\tif (configured?.length) {\n\t\t\tthis.fireChange();\n\t\t}\n\t}\n\n\t@trace()\n\tasync deleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void> {\n\t\t// Self-managed providers group every host under one provider id, so scope the clear to this\n\t\t// descriptor's host when given; for cloud providers the domain stays undefined here, clearing every account.\n\t\tconst domain = isSelfManagedHostIntegrationId(this.authProviderId) ? descriptor?.domain : undefined;\n\t\tconst configured = this.configuredIntegrationService.getConfigured(this.authProviderId, {\n\t\t\tdomain: domain,\n\t\t});\n\n\t\tawait this.configuredIntegrationService.deleteAllStoredSessions(this.authProviderId, undefined, domain);\n\n\t\tif (configured?.length) {\n\t\t\tthis.fireChange();\n\t\t}\n\t}\n\n\t@trace()\n\tasync getSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined> {\n\t\tlet session;\n\t\tlet previousToken;\n\t\tlet refetched: ProviderAuthenticationSession | undefined;\n\t\tif (options?.forceNewSession) {\n\t\t\t// Cloud-only delete (see deleteSession): scope to cloud so the cloud variant's id is cleared even\n\t\t\t// when a mixed local+cloud connection's local descriptor is primary.\n\t\t\tawait this.configuredIntegrationService.deleteStoredSessions(\n\t\t\t\tthis.authProviderId,\n\t\t\t\t{ ...descriptor, cloud: true },\n\t\t\t\ttrue,\n\t\t\t);\n\t\t} else {\n\t\t\tconst stored = await this.configuredIntegrationService.getStoredSession(\n\t\t\t\tthis.authProviderId,\n\t\t\t\toptions?.sync ? { ...descriptor, cloud: true } : descriptor,\n\t\t\t);\n\t\t\tpreviousToken = stored?.accessToken;\n\t\t\tif (options?.sync && options.refetch) {\n\t\t\t\trefetched = stored;\n\t\t\t} else {\n\t\t\t\tsession = stored;\n\t\t\t}\n\t\t}\n\n\t\tconst isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now();\n\t\t// A rejected token has to re-resolve even when the stored session looks perfectly healthy: that is\n\t\t// the whole point — the provider refused it while its expiry still says valid. Without this the\n\t\t// stored session short-circuits here and the refresh below is never reached.\n\t\tconst refreshRejectedToken = options?.refreshRejectedToken === true && session != null;\n\t\tif (session == null || isExpiredSession || refreshRejectedToken) {\n\t\t\tsession = await this.getNewSession(descriptor, {\n\t\t\t\t...options,\n\t\t\t\trefreshIfExpired: isExpiredSession,\n\t\t\t\trefreshRejectedToken: refreshRejectedToken,\n\t\t\t});\n\n\t\t\tif (session != null) {\n\t\t\t\tawait this.configuredIntegrationService.storeSession(this.authProviderId, session);\n\t\t\t\t// The replacement belongs to a different connection (the cloud's primary moved to another\n\t\t\t\t// account), so it was stored beside the one it replaces rather than over it. Drop the old token\n\t\t\t\t// as the up-front delete used to, or an unscoped read would keep resolving it until the next sync.\n\t\t\t\tif (refetched?.id != null && refetched.id !== session.id) {\n\t\t\t\t\tawait this.configuredIntegrationService.deleteStoredSessions(\n\t\t\t\t\t\tthis.authProviderId,\n\t\t\t\t\t\t{ ...descriptor, connectionId: refetched.id, cloud: true },\n\t\t\t\t\t\ttrue,\n\t\t\t\t\t\t{ preserveConfigured: true },\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (previousToken !== session?.accessToken) {\n\t\t\tthis.fireChange();\n\t\t}\n\n\t\treturn session;\n\t}\n\n\tprotected abstract getNewSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetNewSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined>;\n\n\tprotected fireChange(): void {\n\t\tqueueMicrotask(() => this._onDidChange.fire());\n\t}\n}\n\nexport class CloudIntegrationAuthenticationProvider<\n\tID extends IntegrationIds = IntegrationIds,\n> extends IntegrationAuthenticationProviderBase<ID> {\n\tconstructor(\n\t\tauthenticationService: IntegrationAuthenticationService,\n\t\tconfiguredIntegrationService: ConfiguredIntegrationService,\n\t\tprivate readonly _authProviderId: ID,\n\t) {\n\t\tsuper(authenticationService, configuredIntegrationService);\n\t}\n\n\tprotected get authProviderId(): ID {\n\t\treturn this._authProviderId;\n\t}\n\n\tprotected override async getNewSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetNewSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined> {\n\t\tif (options?.forceNewSession) {\n\t\t\tif ((await this.disconnectCloudSession()) === 'failure') {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\tvoid this.connectCloudSession(false, options?.source);\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session.\n\t\t// Ultimately we want to timestamp calls to syncCloudIntegrations and use that to determine whether we should\n\t\t// make the call or not.\n\t\t// `refreshRejectedToken` joins the list for the same reason `refreshIfExpired` is on it: both mean the\n\t\t// session in hand is known-unusable, so skipping the cloud round trip would just re-serve it.\n\t\tlet session =\n\t\t\toptions?.refreshIfExpired ||\n\t\t\toptions?.refreshRejectedToken ||\n\t\t\toptions?.createIfNeeded ||\n\t\t\toptions?.forceNewSession ||\n\t\t\toptions?.sync\n\t\t\t\t? await this.getCloudSession(descriptor, { refreshRejectedToken: options?.refreshRejectedToken })\n\t\t\t\t: undefined;\n\n\t\tconst shouldCreateSession = options?.createIfNeeded && session == null;\n\t\tif (shouldCreateSession) {\n\t\t\tconst connected = await this.connectCloudSession(true, options?.source);\n\t\t\tif (!connected) return undefined;\n\n\t\t\t// This should get us the session we just created with connectCloudSession, because a syncCloudIntegrations run from\n\t\t\t// integration service should have resulted in it being created and stored by this provider\n\t\t\tsession = await this.getSession(descriptor, { source: options?.source });\n\t\t}\n\t\treturn session;\n\t}\n\n\tprivate connectCloudSession(skipIfConnected: boolean, source: Sources | undefined): Promise<boolean> {\n\t\t// Capture in a const so the type guard narrows it to `SupportedCloudIntegrationIds` (a getter\n\t\t// access wouldn't narrow), letting us call the service directly without a cast.\n\t\tconst id = this.authProviderId;\n\t\tif (isSupportedCloudIntegrationId(id)) {\n\t\t\treturn this.authenticationService.connectCloudIntegrations(\n\t\t\t\t{ integrationIds: [id], skipIfConnected: skipIfConnected, skipPreSync: true },\n\t\t\t\t{\n\t\t\t\t\tsource: source ?? 'integrations',\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\taction: 'connect',\n\t\t\t\t\t\tintegration: id,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\n\t\treturn Promise.resolve(false);\n\t}\n\n\tprivate async getCloudSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: { refreshRejectedToken?: boolean },\n\t): Promise<ProviderAuthenticationSession | undefined> {\n\t\tconst loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;\n\t\tif (!loggedIn) return undefined;\n\n\t\tconst cloudIntegrations = this.authenticationService.cloudIntegrations;\n\t\t// An unscoped descriptor (no explicit connectionId) would fetch the provider-global primary via\n\t\t// `v1/provider-tokens/<provider>`. For a self-managed provider spanning multiple hosts that primary\n\t\t// can belong to a different host, so a forced sync of host A would hydrate host B's token here (before\n\t\t// reconcile corrects storage). Scope to this host's own configured connection when we have one; fall\n\t\t// through to the provider-scoped path only when nothing is configured yet (legacy/first sync).\n\t\tconst connectionId =\n\t\t\tdescriptor.connectionId ??\n\t\t\t(isSelfManagedHostIntegrationId(this.authProviderId)\n\t\t\t\t? this.configuredIntegrationService.getConfiguredConnectionId(\n\t\t\t\t\t\tthis.authProviderId,\n\t\t\t\t\t\tdescriptor.domain,\n\t\t\t\t\t\ttrue,\n\t\t\t\t\t)\n\t\t\t\t: undefined);\n\t\t// Refuses a session issued for a DIFFERENT host than the one this connection is keyed by, which would\n\t\t// otherwise let host B's token serve host A.\n\t\t//\n\t\t// A session that names no host is accepted ONLY when it was fetched by a connection id WE have stored\n\t\t// for this host. That descriptor is what ties the token to the host, so an absent domain then means the\n\t\t// backend did not say rather than that the token belongs elsewhere, and refusing it would strand the\n\t\t// refresh.\n\t\t//\n\t\t// A non-null id is not enough on its own: `descriptor.connectionId` can come from the caller, and with\n\t\t// no id at all the fetch goes to the provider-GLOBAL primary endpoint whose token can belong to any\n\t\t// configured host. In both of those cases a domainless response must be refused, or a token would be\n\t\t// bound to — and sent to — a host it was never issued for.\n\t\tconst scopedToThisHost = this.configuredIntegrationService.isConnectionConfiguredForHost(\n\t\t\tthis.authProviderId,\n\t\t\tconnectionId,\n\t\t\tdescriptor.domain,\n\t\t);\n\t\tconst isForAnotherHost = (session: { domain: string } | undefined) =>\n\t\t\tsession != null &&\n\t\t\tisSelfManagedHostIntegrationId(this.authProviderId) &&\n\t\t\t(!scopedToThisHost || Boolean(session.domain?.trim())) &&\n\t\t\t!areDomainsOnSameHost(session.domain, descriptor.domain);\n\n\t\tlet session = await cloudIntegrations.getConnectionSession(this.authProviderId, undefined, connectionId);\n\t\tif (isForAnotherHost(session)) return undefined;\n\n\t\t// GitHub, the cloud self-managed hosts, and Trello return `expiresIn: 0` for a token that never\n\t\t// expires; left as 0 the session would be built with `expiresAt = now` and rejected as expired on the\n\t\t// next resolution. Map it to the maximum expiry so it isn't refreshed frequently.\n\t\tif (session?.expiresIn === 0 && isNonExpiringZeroTokenIntegrationId(this.authProviderId)) {\n\t\t\tsession.expiresIn = maxSmallIntegerV8; // maximum expiration length\n\t\t}\n\n\t\t// `expiresIn < 60` is a PREDICTION that the token is about to lapse, and it is the only trigger the\n\t\t// refresh used to have. It cannot see a token the provider has already refused while the backend\n\t\t// still believes it is valid — a revoked grant, retired scopes, an app removed from the org — where\n\t\t// `expiresIn` stays high and the doomed token is re-sent on every read. `refreshRejectedToken` is\n\t\t// the observed counterpart: the caller saw the provider reject this exact token, so refresh it\n\t\t// regardless of what its expiry claims.\n\t\tif (session != null && (session.expiresIn < 60 || options?.refreshRejectedToken)) {\n\t\t\tsession = await cloudIntegrations.getConnectionSession(\n\t\t\t\tthis.authProviderId,\n\t\t\t\tsession.accessToken,\n\t\t\t\tconnectionId,\n\t\t\t);\n\t\t\tif (isForAnotherHost(session)) return undefined;\n\t\t}\n\n\t\tif (!session) return undefined;\n\n\t\tlet sessionProtocol;\n\t\t// Only derive a protocol from a domain carrying an explicit scheme; a bare `host:port` (e.g. a\n\t\t// self-managed `ghe.example.com:8443`) parses the host as the protocol, corrupting the value.\n\t\tif (/^[a-z][a-z\\d+\\-.]*:\\/\\//i.test(session.domain)) {\n\t\t\ttry {\n\t\t\t\tsessionProtocol = new URL(session.domain).protocol;\n\t\t\t} catch {\n\t\t\t\tsessionProtocol = undefined;\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\t// Prefer the backend's per-connection token id (multi-account); fall back to the resolved\n\t\t\t// primary/legacy connection id so existing single-connection storage keys are preserved.\n\t\t\tid: session.id ?? this.configuredIntegrationService.resolveConnectionId(this.authProviderId, descriptor),\n\t\t\taccessToken: session.accessToken,\n\t\t\tscopes: descriptor.scopes,\n\t\t\taccount: {\n\t\t\t\tid: '',\n\t\t\t\tlabel: '',\n\t\t\t},\n\t\t\tcloud: true,\n\t\t\ttype: session.type,\n\t\t\texpiresAt: new Date(session.expiresIn * 1000 + Date.now()),\n\t\t\t// Note: do not use the session's domain, because the format is different than in our model\n\t\t\tdomain: descriptor.domain,\n\t\t\t// That format difference is exactly what `baseUrl` is for: the descriptor's domain is the\n\t\t\t// normalized host this connection is keyed by, so a context path only survives on the wire value.\n\t\t\t// When the backend reports no domain at all, KEEP the address already configured rather than\n\t\t\t// writing `undefined` — `writeSecret` mirrors this onto the descriptor, so falling back to the\n\t\t\t// bare host here would drop a context path the user never changed on the next refresh.\n\t\t\tbaseUrl:\n\t\t\t\tsession.domain ||\n\t\t\t\tthis.configuredIntegrationService.getConfiguredBaseUrl(\n\t\t\t\t\tthis.authProviderId,\n\t\t\t\t\tconnectionId,\n\t\t\t\t\tdescriptor.domain,\n\t\t\t\t),\n\t\t\tprotocol: sessionProtocol ?? undefined,\n\t\t\t// Carried for providers whose client needs an app key alongside the token (e.g. Trello).\n\t\t\tappKey: session.appKey,\n\t\t};\n\t}\n\n\tprivate async disconnectCloudSession(): Promise<'skip' | 'success' | 'failure'> {\n\t\tconst loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;\n\t\tif (!loggedIn) return 'skip';\n\n\t\tconst cloudIntegrations = this.authenticationService.cloudIntegrations;\n\t\treturn (await cloudIntegrations.disconnect(this.authProviderId)) ? 'success' : 'failure';\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"integrationAuthenticationProvider.js","names":[],"sources":["../../../../../plus/integrations/src/authentication/integrationAuthenticationProvider.ts"],"sourcesContent":["import { trace } from '@gitlens/utils/decorators/log.js';\nimport type { Disposable } from '@gitlens/utils/disposable.js';\nimport type { Event } from '@gitlens/utils/event.js';\nimport { Emitter } from '@gitlens/utils/event.js';\nimport type { IntegrationIds } from '../constants.js';\nimport type { Sources } from '../telemetry.js';\nimport { areDomainsOnSameHost } from '../utils/domain.utils.js';\nimport { isNonExpiringZeroTokenIntegrationId, isSelfManagedHostIntegrationId } from '../utils/integration.utils.js';\nimport type { ConfiguredIntegrationService } from './configuredIntegrationService.js';\nimport type { IntegrationAuthenticationService } from './integrationAuthenticationService.js';\nimport type { ProviderAuthenticationSession } from './models.js';\nimport { isSupportedCloudIntegrationId } from './models.js';\n\nconst maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers)\n\nexport interface IntegrationAuthenticationProviderDescriptor {\n\tid: IntegrationIds;\n\tscopes: string[];\n}\n\nexport interface IntegrationAuthenticationSessionDescriptor {\n\tdomain: string;\n\tscopes: string[];\n\t/** When set, reads/deletes only the matching storage variant. */\n\tcloud?: boolean;\n\t/**\n\t * Targets a specific connection when a provider has multiple accounts connected. When omitted,\n\t * operations resolve to the provider's primary connection (see\n\t * {@link ConfiguredIntegrationService.resolveConnectionId}).\n\t */\n\tconnectionId?: string;\n\t[key: string]: unknown;\n}\n\n/**\n * Options for {@link IntegrationAuthenticationProvider.getSession}. The two variants keep `sync` exclusive\n * with the interactive flags: a sync resolves whatever the cloud already has, so it must never open a connect\n * window.\n *\n * `refreshRejectedToken` marks the stored token as known-refused by the provider (an `AuthenticationError` on\n * a previous read), which forces a cloud `/refresh` even when the token's expiry still claims it is valid —\n * the case a purely time-based refresh cannot see. The refresh token itself never reaches the client: the GK\n * cloud performs the exchange server-side.\n *\n * `refetch` (sync only) fetches a fresh token instead of serving the stored one, WITHOUT deleting the stored one\n * first: it is replaced only once the fetch returns a session. A forced re-sync used to delete it up front, so a\n * fetch that failed transiently (a GK API 429 or 5xx) left the connection with no token at all, and every read\n * until the next good sync reported it as gone (gitkraken/kepler#3546).\n */\nexport type GetSessionOptions =\n\t| {\n\t\t\tcreateIfNeeded?: boolean;\n\t\t\tforceNewSession?: boolean;\n\t\t\tsync?: never;\n\t\t\trefetch?: never;\n\t\t\trefreshRejectedToken?: boolean;\n\t\t\tsource?: Sources;\n\t }\n\t| {\n\t\t\tcreateIfNeeded?: never;\n\t\t\tforceNewSession?: never;\n\t\t\tsync: boolean;\n\t\t\trefetch?: boolean;\n\t\t\trefreshRejectedToken?: boolean;\n\t\t\tsource?: Sources;\n\t };\n\n/**\n * What {@link IntegrationAuthenticationProviderBase.getSession} passes down once it has decided the stored\n * session can't be served as-is. `refreshIfExpired` is its own conclusion, not a caller's option.\n */\ntype GetNewSessionOptions = GetSessionOptions & { refreshIfExpired?: boolean };\n\nexport interface IntegrationAuthenticationProvider extends Disposable {\n\t/**\n\t * Clears the stored secret for one connection only (never the whole provider). Unlike\n\t * {@link deleteAllSessions}, this by default leaves the descriptor in `integrations:configured` — its\n\t * primary caller is a forced re-sync, which needs `getConfigured()` to keep reporting the connection\n\t * while a fresh session is fetched to replace the deleted secret. Implementers should not treat this as\n\t * a full disconnect. Pass `preserveConfigured: false` to also drop the descriptor (e.g. when the\n\t * re-sync's replacement fetch failed and the connection should no longer be reported as connected).\n\t */\n\tdeleteSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: { preserveConfigured?: boolean },\n\t): Promise<void>;\n\tdeleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void>;\n\t/**\n\t * Resolves the session for `descriptor`, from storage when it is still usable and from the cloud\n\t * otherwise. See {@link GetSessionOptions}.\n\t */\n\tgetSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined>;\n\tget onDidChange(): Event<void>;\n}\n\nabstract class IntegrationAuthenticationProviderBase<\n\tID extends IntegrationIds = IntegrationIds,\n> implements IntegrationAuthenticationProvider {\n\tprotected readonly disposables: Disposable[] = [];\n\n\tconstructor(\n\t\tprotected readonly authenticationService: IntegrationAuthenticationService,\n\t\tprotected readonly configuredIntegrationService: ConfiguredIntegrationService,\n\t) {}\n\n\tdispose(): void {\n\t\tthis.disposables.forEach(d => d.dispose());\n\t}\n\n\tprivate readonly _onDidChange = new Emitter<void>();\n\tget onDidChange(): Event<void> {\n\t\treturn this._onDidChange.event;\n\t}\n\n\tprotected abstract get authProviderId(): ID;\n\n\t@trace()\n\tasync deleteSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: { preserveConfigured?: boolean },\n\t): Promise<void> {\n\t\tconst domain = isSelfManagedHostIntegrationId(this.authProviderId) ? descriptor?.domain : undefined;\n\t\tconst configured = this.configuredIntegrationService.getConfigured(this.authProviderId, {\n\t\t\tdomain: domain,\n\t\t});\n\n\t\t// Scope the descriptor to cloud so resolveConnectionId targets the cloud variant's id: this is a\n\t\t// cloud-only delete, and a mixed local+cloud connection whose local descriptor is primary would\n\t\t// otherwise resolve the local id and leave the cloud secret intact.\n\t\tawait this.configuredIntegrationService.deleteStoredSessions(\n\t\t\tthis.authProviderId,\n\t\t\t{ ...descriptor, cloud: true },\n\t\t\ttrue,\n\t\t\t{ preserveConfigured: options?.preserveConfigured ?? true },\n\t\t);\n\n\t\tif (configured?.length) {\n\t\t\tthis.fireChange();\n\t\t}\n\t}\n\n\t@trace()\n\tasync deleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void> {\n\t\t// Self-managed providers group every host under one provider id, so scope the clear to this\n\t\t// descriptor's host when given; for cloud providers the domain stays undefined here, clearing every account.\n\t\tconst domain = isSelfManagedHostIntegrationId(this.authProviderId) ? descriptor?.domain : undefined;\n\t\tconst configured = this.configuredIntegrationService.getConfigured(this.authProviderId, {\n\t\t\tdomain: domain,\n\t\t});\n\n\t\tawait this.configuredIntegrationService.deleteAllStoredSessions(this.authProviderId, undefined, domain);\n\n\t\tif (configured?.length) {\n\t\t\tthis.fireChange();\n\t\t}\n\t}\n\n\t@trace()\n\tasync getSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined> {\n\t\tlet session;\n\t\tlet previousToken;\n\t\tlet refetched: ProviderAuthenticationSession | undefined;\n\t\tlet signOutMark: number;\n\t\tif (options?.forceNewSession) {\n\t\t\t// Cloud-only delete (see deleteSession): scope to cloud so the cloud variant's id is cleared even\n\t\t\t// when a mixed local+cloud connection's local descriptor is primary. A sign-out, so a token fetched before\n\t\t\t// it is not stored back; the mark is taken after it, so this session's own fetch is not refused.\n\t\t\tawait this.configuredIntegrationService.deleteStoredSessions(\n\t\t\t\tthis.authProviderId,\n\t\t\t\t{ ...descriptor, cloud: true },\n\t\t\t\ttrue,\n\t\t\t\t{ signOut: true },\n\t\t\t);\n\t\t\tsignOutMark = this.configuredIntegrationService.getSignOutMark();\n\t\t} else {\n\t\t\t// Taken before the stored-token read (a sign-out landing during the read is ordered after it by the storage\n\t\t\t// lock, so it counts either way).\n\t\t\tsignOutMark = this.configuredIntegrationService.getSignOutMark();\n\t\t\tconst stored = await this.configuredIntegrationService.getStoredSession(\n\t\t\t\tthis.authProviderId,\n\t\t\t\toptions?.sync ? { ...descriptor, cloud: true } : descriptor,\n\t\t\t);\n\t\t\tpreviousToken = stored?.accessToken;\n\t\t\tif (options?.sync && options.refetch) {\n\t\t\t\trefetched = stored;\n\t\t\t} else {\n\t\t\t\tsession = stored;\n\t\t\t}\n\t\t}\n\n\t\tconst isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now();\n\t\t// A rejected token has to re-resolve even when the stored session looks perfectly healthy: that is\n\t\t// the whole point — the provider refused it while its expiry still says valid. Without this the\n\t\t// stored session short-circuits here and the refresh below is never reached.\n\t\tconst refreshRejectedToken = options?.refreshRejectedToken === true && session != null;\n\t\tif (session == null || isExpiredSession || refreshRejectedToken) {\n\t\t\tsession = await this.getNewSession(descriptor, {\n\t\t\t\t...options,\n\t\t\t\trefreshIfExpired: isExpiredSession,\n\t\t\t\trefreshRejectedToken: refreshRejectedToken,\n\t\t\t});\n\t\t\t// Stored only when no sign-out landed since the mark (checked and written under the storage lock): a token\n\t\t\t// read or fetched across a disconnect, a reauthentication or the connection's removal is not reconnected.\n\t\t\tif (\n\t\t\t\tsession != null &&\n\t\t\t\t!(await this.configuredIntegrationService.storeSession(this.authProviderId, session, {\n\t\t\t\t\tsignOutMark: signOutMark,\n\t\t\t\t}))\n\t\t\t) {\n\t\t\t\tsession = undefined;\n\t\t\t}\n\n\t\t\tif (session != null) {\n\t\t\t\t// The replacement belongs to a different connection (the cloud's primary moved to another\n\t\t\t\t// account), so it was stored beside the one it replaces rather than over it. Drop the old token\n\t\t\t\t// as the up-front delete used to, or an unscoped read would keep resolving it until the next sync.\n\t\t\t\tif (refetched?.id != null && refetched.id !== session.id) {\n\t\t\t\t\tawait this.configuredIntegrationService.deleteStoredSessions(\n\t\t\t\t\t\tthis.authProviderId,\n\t\t\t\t\t\t{ ...descriptor, connectionId: refetched.id, cloud: true },\n\t\t\t\t\t\ttrue,\n\t\t\t\t\t\t{ preserveConfigured: true },\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (previousToken !== session?.accessToken) {\n\t\t\tthis.fireChange();\n\t\t}\n\n\t\treturn session;\n\t}\n\n\tprotected abstract getNewSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetNewSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined>;\n\n\tprotected fireChange(): void {\n\t\tqueueMicrotask(() => this._onDidChange.fire());\n\t}\n}\n\nexport class CloudIntegrationAuthenticationProvider<\n\tID extends IntegrationIds = IntegrationIds,\n> extends IntegrationAuthenticationProviderBase<ID> {\n\tconstructor(\n\t\tauthenticationService: IntegrationAuthenticationService,\n\t\tconfiguredIntegrationService: ConfiguredIntegrationService,\n\t\tprivate readonly _authProviderId: ID,\n\t) {\n\t\tsuper(authenticationService, configuredIntegrationService);\n\t}\n\n\tprotected get authProviderId(): ID {\n\t\treturn this._authProviderId;\n\t}\n\n\tprotected override async getNewSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: GetNewSessionOptions,\n\t): Promise<ProviderAuthenticationSession | undefined> {\n\t\tif (options?.forceNewSession) {\n\t\t\tif ((await this.disconnectCloudSession()) === 'failure') {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\tvoid this.connectCloudSession(false, options?.source);\n\t\t\treturn undefined;\n\t\t}\n\n\t\t// TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session.\n\t\t// Ultimately we want to timestamp calls to syncCloudIntegrations and use that to determine whether we should\n\t\t// make the call or not.\n\t\t// `refreshRejectedToken` joins the list for the same reason `refreshIfExpired` is on it: both mean the\n\t\t// session in hand is known-unusable, so skipping the cloud round trip would just re-serve it.\n\t\tlet session =\n\t\t\toptions?.refreshIfExpired ||\n\t\t\toptions?.refreshRejectedToken ||\n\t\t\toptions?.createIfNeeded ||\n\t\t\toptions?.forceNewSession ||\n\t\t\toptions?.sync\n\t\t\t\t? await this.getCloudSession(descriptor, { refreshRejectedToken: options?.refreshRejectedToken })\n\t\t\t\t: undefined;\n\n\t\tconst shouldCreateSession = options?.createIfNeeded && session == null;\n\t\tif (shouldCreateSession) {\n\t\t\tconst connected = await this.connectCloudSession(true, options?.source);\n\t\t\tif (!connected) return undefined;\n\n\t\t\t// This should get us the session we just created with connectCloudSession, because a syncCloudIntegrations run from\n\t\t\t// integration service should have resulted in it being created and stored by this provider\n\t\t\tsession = await this.getSession(descriptor, { source: options?.source });\n\t\t}\n\t\treturn session;\n\t}\n\n\tprivate connectCloudSession(skipIfConnected: boolean, source: Sources | undefined): Promise<boolean> {\n\t\t// Capture in a const so the type guard narrows it to `SupportedCloudIntegrationIds` (a getter\n\t\t// access wouldn't narrow), letting us call the service directly without a cast.\n\t\tconst id = this.authProviderId;\n\t\tif (isSupportedCloudIntegrationId(id)) {\n\t\t\treturn this.authenticationService.connectCloudIntegrations(\n\t\t\t\t{ integrationIds: [id], skipIfConnected: skipIfConnected, skipPreSync: true },\n\t\t\t\t{\n\t\t\t\t\tsource: source ?? 'integrations',\n\t\t\t\t\tdetail: {\n\t\t\t\t\t\taction: 'connect',\n\t\t\t\t\t\tintegration: id,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\n\t\treturn Promise.resolve(false);\n\t}\n\n\tprivate async getCloudSession(\n\t\tdescriptor: IntegrationAuthenticationSessionDescriptor,\n\t\toptions?: { refreshRejectedToken?: boolean },\n\t): Promise<ProviderAuthenticationSession | undefined> {\n\t\tconst loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;\n\t\tif (!loggedIn) return undefined;\n\n\t\tconst cloudIntegrations = this.authenticationService.cloudIntegrations;\n\t\t// An unscoped descriptor (no explicit connectionId) would fetch the provider-global primary via\n\t\t// `v1/provider-tokens/<provider>`. For a self-managed provider spanning multiple hosts that primary\n\t\t// can belong to a different host, so a forced sync of host A would hydrate host B's token here (before\n\t\t// reconcile corrects storage). Scope to this host's own configured connection when we have one; fall\n\t\t// through to the provider-scoped path only when nothing is configured yet (legacy/first sync).\n\t\tconst connectionId =\n\t\t\tdescriptor.connectionId ??\n\t\t\t(isSelfManagedHostIntegrationId(this.authProviderId)\n\t\t\t\t? this.configuredIntegrationService.getConfiguredConnectionId(\n\t\t\t\t\t\tthis.authProviderId,\n\t\t\t\t\t\tdescriptor.domain,\n\t\t\t\t\t\ttrue,\n\t\t\t\t\t)\n\t\t\t\t: undefined);\n\t\t// Refuses a session issued for a DIFFERENT host than the one this connection is keyed by, which would\n\t\t// otherwise let host B's token serve host A.\n\t\t//\n\t\t// A session that names no host is accepted ONLY when it was fetched by a connection id WE have stored\n\t\t// for this host. That descriptor is what ties the token to the host, so an absent domain then means the\n\t\t// backend did not say rather than that the token belongs elsewhere, and refusing it would strand the\n\t\t// refresh.\n\t\t//\n\t\t// A non-null id is not enough on its own: `descriptor.connectionId` can come from the caller, and with\n\t\t// no id at all the fetch goes to the provider-GLOBAL primary endpoint whose token can belong to any\n\t\t// configured host. In both of those cases a domainless response must be refused, or a token would be\n\t\t// bound to — and sent to — a host it was never issued for.\n\t\tconst scopedToThisHost = this.configuredIntegrationService.isConnectionConfiguredForHost(\n\t\t\tthis.authProviderId,\n\t\t\tconnectionId,\n\t\t\tdescriptor.domain,\n\t\t);\n\t\tconst isForAnotherHost = (session: { domain: string } | undefined) =>\n\t\t\tsession != null &&\n\t\t\tisSelfManagedHostIntegrationId(this.authProviderId) &&\n\t\t\t(!scopedToThisHost || Boolean(session.domain?.trim())) &&\n\t\t\t!areDomainsOnSameHost(session.domain, descriptor.domain);\n\n\t\tlet session = await cloudIntegrations.getConnectionSession(this.authProviderId, undefined, connectionId);\n\t\tif (isForAnotherHost(session)) return undefined;\n\n\t\t// GitHub, the cloud self-managed hosts, and Trello return `expiresIn: 0` for a token that never\n\t\t// expires; left as 0 the session would be built with `expiresAt = now` and rejected as expired on the\n\t\t// next resolution. Map it to the maximum expiry so it isn't refreshed frequently.\n\t\tif (session?.expiresIn === 0 && isNonExpiringZeroTokenIntegrationId(this.authProviderId)) {\n\t\t\tsession.expiresIn = maxSmallIntegerV8; // maximum expiration length\n\t\t}\n\n\t\t// `expiresIn < 60` is a PREDICTION that the token is about to lapse, and it is the only trigger the\n\t\t// refresh used to have. It cannot see a token the provider has already refused while the backend\n\t\t// still believes it is valid — a revoked grant, retired scopes, an app removed from the org — where\n\t\t// `expiresIn` stays high and the doomed token is re-sent on every read. `refreshRejectedToken` is\n\t\t// the observed counterpart: the caller saw the provider reject this exact token, so refresh it\n\t\t// regardless of what its expiry claims.\n\t\tif (session != null && (session.expiresIn < 60 || options?.refreshRejectedToken)) {\n\t\t\tsession = await cloudIntegrations.getConnectionSession(\n\t\t\t\tthis.authProviderId,\n\t\t\t\tsession.accessToken,\n\t\t\t\tconnectionId,\n\t\t\t);\n\t\t\tif (isForAnotherHost(session)) return undefined;\n\t\t}\n\n\t\tif (!session) return undefined;\n\n\t\tlet sessionProtocol;\n\t\t// Only derive a protocol from a domain carrying an explicit scheme; a bare `host:port` (e.g. a\n\t\t// self-managed `ghe.example.com:8443`) parses the host as the protocol, corrupting the value.\n\t\tif (/^[a-z][a-z\\d+\\-.]*:\\/\\//i.test(session.domain)) {\n\t\t\ttry {\n\t\t\t\tsessionProtocol = new URL(session.domain).protocol;\n\t\t\t} catch {\n\t\t\t\tsessionProtocol = undefined;\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\t// Prefer the backend's per-connection token id (multi-account); fall back to the resolved\n\t\t\t// primary/legacy connection id so existing single-connection storage keys are preserved.\n\t\t\tid: session.id ?? this.configuredIntegrationService.resolveConnectionId(this.authProviderId, descriptor),\n\t\t\taccessToken: session.accessToken,\n\t\t\tscopes: descriptor.scopes,\n\t\t\taccount: {\n\t\t\t\tid: '',\n\t\t\t\tlabel: '',\n\t\t\t},\n\t\t\tcloud: true,\n\t\t\ttype: session.type,\n\t\t\texpiresAt: new Date(session.expiresIn * 1000 + Date.now()),\n\t\t\t// Note: do not use the session's domain, because the format is different than in our model\n\t\t\tdomain: descriptor.domain,\n\t\t\t// That format difference is exactly what `baseUrl` is for: the descriptor's domain is the\n\t\t\t// normalized host this connection is keyed by, so a context path only survives on the wire value.\n\t\t\t// When the backend reports no domain at all, KEEP the address already configured rather than\n\t\t\t// writing `undefined` — `writeSecret` mirrors this onto the descriptor, so falling back to the\n\t\t\t// bare host here would drop a context path the user never changed on the next refresh.\n\t\t\tbaseUrl:\n\t\t\t\tsession.domain ||\n\t\t\t\tthis.configuredIntegrationService.getConfiguredBaseUrl(\n\t\t\t\t\tthis.authProviderId,\n\t\t\t\t\tconnectionId,\n\t\t\t\t\tdescriptor.domain,\n\t\t\t\t),\n\t\t\tprotocol: sessionProtocol ?? undefined,\n\t\t\t// Carried for providers whose client needs an app key alongside the token (e.g. Trello).\n\t\t\tappKey: session.appKey,\n\t\t};\n\t}\n\n\tprivate async disconnectCloudSession(): Promise<'skip' | 'success' | 'failure'> {\n\t\tconst loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;\n\t\tif (!loggedIn) return 'skip';\n\n\t\tconst cloudIntegrations = this.authenticationService.cloudIntegrations;\n\t\treturn (await cloudIntegrations.disconnect(this.authProviderId)) ? 'success' : 'failure';\n\t}\n}\n"],"mappings":"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"}
@@ -934,6 +934,7 @@ var IntegrationService = class {
934
934
  const syncedPrimaryIdsByDomain = /* @__PURE__ */ new Map();
935
935
  const existingById = new Map(this.configuredIntegrationService.getConfigured(id, { cloud: true }).map((c) => [c.id, c]));
936
936
  const repointedDomains = /* @__PURE__ */ new Set();
937
+ const signOutMark = this.configuredIntegrationService.getSignOutMark();
937
938
  const preparedConnections = await Promise.all(identified.map(async (connection) => {
938
939
  const host = hostFromDomain(connection.domain);
939
940
  if (isSelfManagedHostIntegrationId(id) && !host) {
@@ -976,7 +977,7 @@ var IntegrationService = class {
976
977
  }));
977
978
  for (const prepared of preparedConnections) {
978
979
  if (prepared == null) continue;
979
- if (prepared.kind === "fetched") await this.configuredIntegrationService.storeSession(id, prepared.providerSession);
980
+ if (prepared.kind === "fetched" && !await this.configuredIntegrationService.storeSession(id, prepared.providerSession, { signOutMark })) continue;
980
981
  syncedIds.add(prepared.connection.id);
981
982
  if (prepared.connection.primary) {
982
983
  const domain = isSelfManagedHostIntegrationId(id) ? prepared.host : void 0;