diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index b683b8c..e4b5300 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -104,6 +104,50 @@ const result = await host.ports.mcpConnectors?.beginDcrAuthorization({ `status(name)` returns only name, connection state, OAuth state and tool count. It never returns an endpoint, vault binding, token, or secret value. +### Use a product-brokered short-lived token + +When an organization owns the OAuth relationship, bind a runtime token source +from the product plugin and declare its non-secret id on the connector. The MCP +plugin asks the resolver only while it is about to dial; it writes neither the +access token it receives nor a refresh token or client secret to `mcp.json`. + +```ts +import { definePlugin } from '@hoshi/harness' + +export default [ + definePlugin({ + name: 'example-platform', + description: 'Makes organization-brokered connectors available.', + uses: ['mcpConnectors'], + setup(host) { + const connectors = host.ports.mcpConnectors + if (!connectors) return + + connectors.bindTokenSource('platform.example', async ({ name, scopes }) => { + const token = await mintShortLivedTokenFromYourPlatform({ name, scopes }) + return token ? { state: 'available', accessToken: token } : { state: 'unavailable' } + }) + + void connectors.install({ + name: 'example', + transport: 'http', + url: 'https://mcp.example.com/', + tokenSource: { id: 'platform.example', scopes: ['documents.read'] }, + }) + }, + }), +] +``` + +Bind the source during every plugin setup. Source bindings are process-local by +design, so a restart begins unbound; a stored connector whose source was not +registered is visibly `unreachable` with `needs-auth` rather than being dialled +without a credential. Resolver outcomes are sanitized: an expired token reports +`expired`; unavailable, revoked, or failed broker requests report `needs-auth`. +Do not return provider errors or token metadata. A connector uses either +`tokenSource` or `vaultHeaders`, never both; DCR remains the machine-owned OAuth +path above. + ## Describe what the machine can do `capability` is the public, stable identity of the unit your plugin adds. Its diff --git a/src/index.ts b/src/index.ts index 8ec72da..3def2b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,10 @@ export type { McpConnectorPort, McpConnectorSnapshot, McpConnectorStatus, + McpConnectorTokenSource, + McpConnectorTokenSourceReference, + McpConnectorTokenSourceRequest, + McpConnectorTokenSourceResult, McpConnectorTransport, McpConnectorVaultHeader, McpDcrAuthorizationRequest, diff --git a/src/mcp-connectors.ts b/src/mcp-connectors.ts index 734dd33..acb70c0 100644 --- a/src/mcp-connectors.ts +++ b/src/mcp-connectors.ts @@ -16,12 +16,49 @@ export interface McpConnectorVaultHeader { template: string } +/** A runtime-only identity source supplied by another plugin. The persisted + * connector definition names this source, but never carries a token, a refresh + * token, or a client secret. */ +export interface McpConnectorTokenSourceReference { + /** Stable, product-defined name of a source bound during plugin setup. */ + id: string + /** Permissions the product asks its broker to mint for this connector. */ + scopes?: string[] +} + +/** What a product-owned broker can safely tell the MCP plugin. A token is + * consumed immediately to make one connection and is never written to storage. + * The non-ready cases intentionally contain no upstream error detail: a + * machine UI can distinguish an expired authorization from one needing action + * without exposing a provider response, secret, or account information. */ +export type McpConnectorTokenSourceResult = + | { state: 'available'; accessToken: string } + | { state: 'unavailable' | 'expired' | 'revoked' } + +export interface McpConnectorTokenSourceRequest { + /** Connector asking for the token; useful when one broker serves several. */ + name: string + /** The declaration's scopes, copied rather than read from durable storage by + * the product plugin. */ + scopes: string[] +} + +/** A product plugin binds this during every boot. It is deliberately runtime + * state: a restarted machine must re-establish its Platform connection before + * a persisted connector can use the source again. */ +export type McpConnectorTokenSource = ( + input: McpConnectorTokenSourceRequest, +) => Promise + export interface McpConnectorDeclaration { name: string url: string transport: McpConnectorTransport /** Header name -> vault binding. Literal credentials are intentionally absent. */ vaultHeaders?: Record + /** A non-secret broker source. Mutually exclusive with vault headers: one + * connector has one credential owner. */ + tokenSource?: McpConnectorTokenSourceReference } export type McpConnectorAuthStatus = 'authenticated' | 'needs-auth' | 'expired' | null @@ -45,6 +82,11 @@ export interface McpDcrAuthorizationRequest { /** The MCP plugin's narrow service for external product plugins. */ export interface McpConnectorPort { install(input: McpConnectorDeclaration): Promise + /** Bind a product-owned, short-lived bearer-token resolver for this process. + * Call from plugin setup on every boot. The returned function releases only + * this binding, so a plugin can cleanly shut down without affecting another + * source. */ + bindTokenSource(id: string, resolve: McpConnectorTokenSource): () => void beginDcrAuthorization(input: McpDcrAuthorizationRequest): Promise<{ url: string }> status(name: string): Promise } diff --git a/src/plugins/mcp/connector-token-source.test.ts b/src/plugins/mcp/connector-token-source.test.ts new file mode 100644 index 0000000..c0475a0 --- /dev/null +++ b/src/plugins/mcp/connector-token-source.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const mocks = vi.hoisted(() => ({ + close: vi.fn(), + connect: vi.fn(), +})) + +vi.mock('@openharness/core', () => ({ + closeMCPClients: mocks.close, + connectMCPServers: mocks.connect, +})) + +import { configureStateRoot, resetStateRoot } from '../../kernel/store.js' +import { setSecret } from '../../kernel/secrets.js' +import { installRemoteServer, listServers, mcpTools } from './servers.js' +import { bindTokenSource, clearTokenSources } from './token-sources.js' + +describe('a provider-brokered connector', () => { + let state = '' + + beforeEach(async () => { + state = await mkdtemp(path.join(tmpdir(), 'hoshi-mcp-token-source-')) + configureStateRoot(state) + clearTokenSources() + mocks.connect.mockReset() + mocks.close.mockReset() + mocks.connect.mockResolvedValue({ clients: [], tools: {} }) + }) + + afterEach(async () => { + clearTokenSources() + resetStateRoot() + await rm(state, { recursive: true, force: true }) + }) + + it('dials with a brokered bearer token while persisting only the source id and scopes', async () => { + await installRemoteServer({ + name: 'google-drive', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] }, + }) + bindTokenSource('platform.google', async () => ({ state: 'available', accessToken: 'ephemeral-access-token' })) + + await mcpTools() + + expect(mocks.connect).toHaveBeenCalledWith({ + 'google-drive': { + type: 'http', + url: 'https://mcp.example.test/', + headers: { Authorization: 'Bearer ephemeral-access-token' }, + }, + }) + const persisted = await readFile(path.join(state, 'mcp.json'), 'utf8') + expect(persisted).toContain('platform.google') + expect(persisted).toContain('drive.readonly') + expect(persisted).not.toContain('ephemeral-access-token') + expect(persisted).not.toMatch(/refresh|client.secret/i) + }) + + it('keeps the existing vault-header path when no runtime source is declared', async () => { + await installRemoteServer({ + name: 'github', + transport: 'http', + url: 'https://mcp.example.test/', + vaultHeaders: { Authorization: { key: 'GITHUB_TOKEN', template: 'Bearer {{secret}}' } }, + }) + await setSecret('GITHUB_TOKEN', 'vault-token') + + await mcpTools() + + expect(mocks.connect).toHaveBeenCalledWith({ + github: { + type: 'http', + url: 'https://mcp.example.test/', + headers: { Authorization: 'Bearer vault-token' }, + }, + }) + }) + + it('does not dial without a source and exposes a safe missing-source status', async () => { + await installRemoteServer({ + name: 'missing-source', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.missing' }, + }) + + await expect(listServers()).resolves.toMatchObject([ + { + name: 'missing-source', + status: 'unreachable', + auth: 'needs-auth', + error: 'The required connector token source is not available.', + }, + ]) + expect(mocks.connect).not.toHaveBeenCalled() + }) + + it('reports an expired brokered authorization without forwarding the broker response', async () => { + await installRemoteServer({ + name: 'expired-source', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.expired' }, + }) + bindTokenSource('platform.expired', async () => ({ state: 'expired' })) + + await expect(listServers()).resolves.toMatchObject([ + { + name: 'expired-source', + status: 'unreachable', + auth: 'expired', + error: 'The connector authorization has expired.', + }, + ]) + expect(mocks.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/plugins/mcp/index.test.ts b/src/plugins/mcp/index.test.ts index c440da1..dfba3c3 100644 --- a/src/plugins/mcp/index.test.ts +++ b/src/plugins/mcp/index.test.ts @@ -25,6 +25,7 @@ describe('the public MCP connector port', () => { expect(provided?.mcpConnectors).toEqual({ install: expect.any(Function), + bindTokenSource: expect.any(Function), beginDcrAuthorization: expect.any(Function), status: expect.any(Function), }) diff --git a/src/plugins/mcp/index.ts b/src/plugins/mcp/index.ts index 38af6e0..1f88497 100644 --- a/src/plugins/mcp/index.ts +++ b/src/plugins/mcp/index.ts @@ -10,8 +10,9 @@ import oauthCallback from './mcp.oauth.callback.get.js' import startOauth from './mcp.name.oauth.post.js' import disconnectOauth from './mcp.name.oauth.delete.js' import { refreshExpiringTokens } from './oauth-connect.js' -import { connectorStatus, installRemoteServer } from './servers.js' +import { connectorStatus, installRemoteServer, invalidateMcpConnections } from './servers.js' import { beginDcrAuthorization } from './mcp.name.oauth.post.js' +import { bindTokenSource, clearTokenSources } from './token-sources.js' /** * ── MCP connectors ─────────────────────────────────────────────────────────── @@ -43,6 +44,14 @@ export default definePlugin({ ...current, mcpConnectors: { install: installRemoteServer, + bindTokenSource: (id, resolve) => { + const release = bindTokenSource(id, resolve) + invalidateMcpConnections() + return () => { + release() + invalidateMcpConnections() + } + }, beginDcrAuthorization, status: connectorStatus, }, @@ -86,5 +95,11 @@ export default definePlugin({ /** Renew what is close to expiring. On the machine's own timer: the * no-polling rule is about the client↔machine wire. */ host.jobs.every(5 * 60_000, refreshExpiringTokens) + return { + shutdown: () => { + clearTokenSources() + invalidateMcpConnections() + }, + } }, }) diff --git a/src/plugins/mcp/servers.test.ts b/src/plugins/mcp/servers.test.ts index 526e462..cd3a5ad 100644 --- a/src/plugins/mcp/servers.test.ts +++ b/src/plugins/mcp/servers.test.ts @@ -142,4 +142,39 @@ describe('the public declared-connector boundary', () => { }), ).toThrow(InvalidServerError) }) + + it('persists only a named runtime token source and its scopes, never a bearer token', () => { + expect( + parseRemoteConnector({ + name: 'google-drive', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] }, + }), + ).toEqual({ + type: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'platform.google', scopes: ['drive.readonly'] }, + }) + }) + + it('refuses malformed sources and a declaration that tries to combine credential owners', () => { + expect(() => + parseRemoteConnector({ + name: 'bad-source', + transport: 'http', + url: 'https://mcp.example.test/', + tokenSource: { id: 'not valid' }, + }), + ).toThrow(InvalidServerError) + expect(() => + parseRemoteConnector({ + name: 'two-auth-modes', + transport: 'http', + url: 'https://mcp.example.test/', + vaultHeaders: { Authorization: { key: 'TOKEN', template: 'Bearer {{secret}}' } }, + tokenSource: { id: 'platform.google' }, + }), + ).toThrow(InvalidServerError) + }) }) diff --git a/src/plugins/mcp/servers.ts b/src/plugins/mcp/servers.ts index cfbcca6..c43b278 100644 --- a/src/plugins/mcp/servers.ts +++ b/src/plugins/mcp/servers.ts @@ -5,8 +5,14 @@ import { authorizationHeader } from './oauth-connect.js' import { authorizedConnectors, authStatus, forgetAuth, type AuthStatus } from './oauth-store.js' import { publishMachineEvent } from '../../kernel/events.js' import { isValidSecretKey, readSecretValue } from '../../kernel/secrets.js' -import type { McpConnectorDeclaration, McpConnectorSnapshot, McpConnectorVaultHeader } from '../../mcp-connectors.js' +import type { + McpConnectorDeclaration, + McpConnectorSnapshot, + McpConnectorTokenSourceReference, + McpConnectorVaultHeader, +} from '../../mcp-connectors.js' import { forgetFlowsFor } from './oauth-flow.js' +import { resolveTokenSource, TokenSourceUnavailableError } from './token-sources.js' /** * ── MCP connectors ─────────────────────────────────────────────────────────── @@ -41,6 +47,9 @@ export type StoredServer = MCPServerConfig & { enabled?: boolean /** Credential references only. Resolved immediately before connecting. */ vaultHeaders?: Record + /** A runtime-only product broker reference. This is identity metadata, never + * a credential: the resolver supplies a short-lived token at dial time. */ + tokenSource?: McpConnectorTokenSourceReference } export interface McpServer { @@ -106,7 +115,10 @@ async function writeDefinitions(servers: Record): Promise< interface Live { clients: Awaited>['clients'] tools: ToolSet - statuses: Map + statuses: Map< + string, + { status: ServerStatus; auth: AuthStatus | null; toolCount: number; tools: string[]; error: string | null } + > } let live: Promise | null = null @@ -123,14 +135,28 @@ function invalidate(): void { void previous?.then(({ clients }) => closeMCPClients(clients)).catch(() => undefined) } +/** Runtime credentials are intentionally not part of a stored definition. When + * their provider comes up, goes away, or is rebound, discard an old failed dial + * so the next turn observes the current runtime source rather than a cache. */ +export function invalidateMcpConnections(): void { + invalidate() +} + /** Merge the machine's bearer token into a remote connector's headers. Returns * the config untouched for stdio, and for anything with no authorization. */ async function withAuthorization(name: string, config: MCPServerConfig): Promise { - if (config.type === 'stdio') return config const stored = config as StoredServer + if (stored.type === 'stdio') return config const vaultHeaders = await resolveVaultHeaders(stored.vaultHeaders) const header = await authorizationHeader(name) - return { ...config, headers: { ...config.headers, ...vaultHeaders, ...header } } + const token = stored.tokenSource ? await resolveTokenSource(stored.tokenSource, name) : null + const headers = { ...stored.headers, ...vaultHeaders, ...header, ...(token ? { Authorization: `Bearer ${token}` } : {}) } + if (stored.type === 'http') return { type: 'http', url: stored.url, headers } + return { + type: 'sse', + url: stored.url, + headers, + } } /** Resolve only while connecting. Stored declarations carry vault key NAMES and @@ -163,7 +189,7 @@ async function connectAll(): Promise { * **/ if (enabled === false) { - statuses.set(name, { status: 'disabled', toolCount: 0, tools: [], error: null }) + statuses.set(name, { status: 'disabled', auth: null, toolCount: 0, tools: [], error: null }) continue } try { @@ -181,6 +207,7 @@ async function connectAll(): Promise { tools = { ...tools, ...connection.tools } statuses.set(name, { status: 'connected', + auth: stored.tokenSource ? 'authenticated' : null, toolCount: Object.keys(connection.tools).length, tools: Object.keys(connection.tools), error: null, @@ -195,6 +222,7 @@ async function connectAll(): Promise { **/ statuses.set(name, { status: 'unreachable', + auth: error instanceof TokenSourceUnavailableError ? error.auth : null, toolCount: 0, tools: [], error: error instanceof Error ? error.message : String(error), @@ -230,7 +258,7 @@ export async function listServers(): Promise { name, config, status: state?.status ?? 'unreachable', - auth: authStatus(auth.get(name) ?? null), + auth: state?.auth ?? authStatus(auth.get(name) ?? null), toolCount: state?.toolCount ?? 0, tools: state?.tools ?? [], error: state?.error ?? null, @@ -294,6 +322,21 @@ function vaultHeaderBindings(value: unknown): Record + if (typeof id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id)) { + throw new InvalidServerError('tokenSource needs a valid source id.') + } + if (scopes !== undefined && (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== 'string' || !scope.trim()))) { + throw new InvalidServerError('tokenSource scopes must be non-empty strings.') + } + return { id, ...(scopes ? { scopes: [...new Set(scopes)] as string[] } : {}) } +} + export function parseRemoteConnector(input: McpConnectorDeclaration): StoredServer { const config = parseConfig({ type: input.transport, url: input.url }) if (config.type === 'stdio') throw new InvalidServerError('A declared connector must use an HTTP or SSE transport.') @@ -302,7 +345,11 @@ export function parseRemoteConnector(input: McpConnectorDeclaration): StoredServ throw new InvalidServerError('A declared connector needs an http or https endpoint.') } const vaultHeaders = vaultHeaderBindings(input.vaultHeaders) - return { ...config, ...(vaultHeaders ? { vaultHeaders } : {}) } + const tokenSource = tokenSourceReference(input.tokenSource) + if (vaultHeaders && tokenSource) { + throw new InvalidServerError('A declared connector may use vault headers or a token source, not both.') + } + return { ...config, ...(vaultHeaders ? { vaultHeaders } : {}), ...(tokenSource ? { tokenSource } : {}) } } export function parseConfig(raw: unknown): MCPServerConfig { @@ -368,9 +415,13 @@ export async function installRemoteServer(input: McpConnectorDeclaration): Promi const previous = servers[input.name] const changedEndpoint = !previous || previous.type === 'stdio' || previous.type !== definition.type || previous.url !== definition.url + const changedAuthentication = + !previous || + JSON.stringify((previous as StoredServer).vaultHeaders ?? null) !== JSON.stringify(definition.vaultHeaders ?? null) || + JSON.stringify((previous as StoredServer).tokenSource ?? null) !== JSON.stringify(definition.tokenSource ?? null) servers[input.name] = definition await writeDefinitions(servers) - if (changedEndpoint) { + if (changedEndpoint || changedAuthentication) { forgetFlowsFor(input.name) await forgetAuth(input.name) publishMachineEvent('mcp.changed', { name: input.name, auth: null }) diff --git a/src/plugins/mcp/token-sources.test.ts b/src/plugins/mcp/token-sources.test.ts new file mode 100644 index 0000000..c93f878 --- /dev/null +++ b/src/plugins/mcp/token-sources.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + bindTokenSource, + clearTokenSources, + InvalidTokenSourceError, + resolveTokenSource, + TokenSourceUnavailableError, +} from './token-sources.js' + +describe('runtime connector token sources', () => { + it('passes a connector and copied scopes to a bound resolver, but returns only its ephemeral token to the dialler', async () => { + const calls: unknown[] = [] + const release = bindTokenSource('platform.google', async (input) => { + calls.push(input) + return { state: 'available', accessToken: 'short-lived-token' } + }) + + await expect(resolveTokenSource({ id: 'platform.google', scopes: ['drive.readonly'] }, 'google-drive')).resolves.toBe( + 'short-lived-token', + ) + expect(calls).toEqual([{ name: 'google-drive', scopes: ['drive.readonly'] }]) + release() + }) + + it('turns absent, expired, revoked, and throwing resolvers into safe machine states', async () => { + await expect(resolveTokenSource({ id: 'missing' }, 'connector')).rejects.toMatchObject({ + auth: 'needs-auth', + message: 'The required connector token source is not available.', + }) + + for (const [id, result, auth] of [ + ['expired', { state: 'expired' }, 'expired'], + ['revoked', { state: 'revoked' }, 'needs-auth'], + ['unavailable', { state: 'unavailable' }, 'needs-auth'], + ] as const) { + const release = bindTokenSource(id, async () => result) + await expect(resolveTokenSource({ id }, 'connector')).rejects.toBeInstanceOf(TokenSourceUnavailableError) + await expect(resolveTokenSource({ id }, 'connector')).rejects.toMatchObject({ auth }) + release() + } + + const release = bindTokenSource('throws', async () => { + throw new Error('upstream response must not cross this boundary') + }) + await expect(resolveTokenSource({ id: 'throws' }, 'connector')).rejects.toMatchObject({ + auth: 'needs-auth', + message: 'The connector authorization is not available.', + }) + release() + }) + + it('does not allow one plugin to replace another source, and clears registrations at shutdown', async () => { + bindTokenSource('shared', async () => ({ state: 'available', accessToken: 'first' })) + expect(() => bindTokenSource('shared', async () => ({ state: 'available', accessToken: 'second' }))).toThrow( + InvalidTokenSourceError, + ) + clearTokenSources() + await expect(resolveTokenSource({ id: 'shared' }, 'connector')).rejects.toMatchObject({ auth: 'needs-auth' }) + }) +}) diff --git a/src/plugins/mcp/token-sources.ts b/src/plugins/mcp/token-sources.ts new file mode 100644 index 0000000..30ab0ca --- /dev/null +++ b/src/plugins/mcp/token-sources.ts @@ -0,0 +1,74 @@ +import type { + McpConnectorTokenSource, + McpConnectorTokenSourceReference, + McpConnectorTokenSourceResult, +} from '../../mcp-connectors.js' +import type { AuthStatus } from './oauth-store.js' + +/** + * Product brokers are process-local on purpose. Their Platform credentials and + * any token they mint belong to the product plugin, not a connector definition + * on disk. A restart therefore begins with an empty map and requires the + * product plugin to bind its source again during setup. + */ +const sources = new Map() +const SOURCE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/ + +export class InvalidTokenSourceError extends Error {} + +/** Bind a source for the current harness process. A second plugin may not + * silently replace the first source with the same name: doing so would turn a + * declaration into an implicit credential-routing decision. */ +export function bindTokenSource(id: string, resolve: McpConnectorTokenSource): () => void { + if (!SOURCE_ID.test(id)) { + throw new InvalidTokenSourceError('A connector token source id must use letters, digits, dots, dashes, or underscores.') + } + if (typeof resolve !== 'function') throw new InvalidTokenSourceError('A connector token source needs a resolver function.') + const existing = sources.get(id) + if (existing && existing !== resolve) throw new InvalidTokenSourceError('A connector token source is already bound.') + sources.set(id, resolve) + return () => { + if (sources.get(id) === resolve) sources.delete(id) + } +} + +/** Called by the MCP plugin shutdown hook. This makes the restart contract + * executable in same-process library tests as well as a real daemon restart. */ +export function clearTokenSources(): void { + sources.clear() +} + +/** A deliberately ordinary error surface. Neither a resolver exception nor an + * upstream OAuth response reaches a machine client or an agent tool list. */ +export class TokenSourceUnavailableError extends Error { + constructor(readonly auth: AuthStatus, message: string) { + super(message) + } +} + +export async function resolveTokenSource( + reference: McpConnectorTokenSourceReference, + name: string, +): Promise { + const resolve = sources.get(reference.id) + if (!resolve) { + throw new TokenSourceUnavailableError('needs-auth', 'The required connector token source is not available.') + } + + let result: McpConnectorTokenSourceResult + try { + result = await resolve({ name, scopes: [...(reference.scopes ?? [])] }) + } catch { + throw new TokenSourceUnavailableError('needs-auth', 'The connector authorization is not available.') + } + + if (result?.state === 'available' && typeof result.accessToken === 'string' && result.accessToken) { + return result.accessToken + } + if (result?.state === 'expired') { + throw new TokenSourceUnavailableError('expired', 'The connector authorization has expired.') + } + /** Revocation and transient broker failure have the same safe next action: + * reconnect the product account. Do not expose which one occurred. */ + throw new TokenSourceUnavailableError('needs-auth', 'The connector authorization is not available.') +}