From a39e00eb8445182117f8ce8f9aa8b3a1b234976e Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 21 May 2026 17:43:11 +0000 Subject: [PATCH 01/13] feat(integrations): scaffold types and helpers for federated auth (M1) Lay the foundation for BigQuery Google OAuth integration: DI symbols, shared type contracts, command/localization keys, and vendored @deepnote/blocks helpers required to generate federated SQL cell code without persisting access tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.nls.json | 1 + src/messageTypes.ts | 22 ++ .../federatedAuthInvariants.unit.test.ts | 34 +++ .../federatedAuth/vendoredBlocksHelpers.ts | 96 +++++++ .../vendoredBlocksHelpers.unit.test.ts | 259 ++++++++++++++++++ src/notebooks/deepnote/integrations/types.ts | 59 ++++ src/platform/common/constants.ts | 1 + .../notebooks/deepnote/integrationTypes.ts | 15 + .../webview-side/integrations/types.ts | 27 ++ 9 files changed, 514 insertions(+) create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts diff --git a/package.nls.json b/package.nls.json index 35ee95ae66..79fdb1ed8e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -253,6 +253,7 @@ "deepnote.commands.enableSnapshots.title": "Enable Snapshots", "deepnote.commands.disableSnapshots.title": "Disable Snapshots", "deepnote.commands.manageIntegrations.title": "Manage Integrations", + "deepnote.commands.authenticateIntegration.title": "Authenticate Integration", "deepnote.commands.newProject.title": "New Project", "deepnote.commands.importNotebook.title": "Import Notebook", "deepnote.commands.importJupyterNotebook.title": "Import Jupyter Notebook", diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 5ef6c37248..1ae9482fa3 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -228,6 +228,28 @@ export type LocalizedMessages = { integrationsBigQueryCredentialsLabel: string; integrationsBigQueryCredentialsPlaceholder: string; integrationsBigQueryCredentialsRequired: string; + // BigQuery federated-auth form strings + integrationsBigQueryAuthMethodLabel: string; + integrationsBigQueryAuthMethodServiceAccount: string; + integrationsBigQueryAuthMethodGoogleOauth: string; + integrationsBigQueryProjectLabel: string; + integrationsBigQueryProjectPlaceholder: string; + integrationsBigQueryClientIdLabel: string; + integrationsBigQueryClientIdPlaceholder: string; + integrationsBigQueryClientSecretLabel: string; + integrationsBigQueryClientSecretPlaceholder: string; + integrationsBigQueryGoogleOauthHelp: string; + // Federated-auth integration management strings + integrationsAuthenticate: string; + integrationsReauthenticate: string; + integrationsTokenStatusAuthenticated: string; + integrationsTokenStatusDisconnected: string; + integrationsAuthenticating: string; + integrationsAuthenticationSucceeded: string; + integrationsAuthenticationFailed: string; + integrationsBigQueryNotAuthenticated: string; + integrationsFederatedAuthNotSupportedInWeb: string; + integrationsFederatedAuthNotSupportedInRemote: string; // Snowflake form strings integrationsSnowflakeNameLabel: string; integrationsSnowflakeNamePlaceholder: string; diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts new file mode 100644 index 0000000000..0668f23c3f --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts @@ -0,0 +1,34 @@ +import { assert } from 'chai'; + +import { FederatedAuthTokenEntry } from '../types'; + +suite('Federated auth invariants', () => { + test('FederatedAuthTokenEntry does not carry accessToken or expiresAt', () => { + // Plan non-negotiable: access tokens must be fetched fresh on every cell + // execution and must never be stored at rest. The persisted entry shape + // is therefore restricted to { integrationId, refreshToken, metadataFingerprint }. + // + // This test is both a compile-time tripwire (the type assertion below is + // evaluated by tsc) and a runtime check on a sample entry literal. + // + // Catches: any future addition of `accessToken` or `expiresAt` (or any + // similarly time-bounded access-credential field) to the persisted entry + // shape, which the plan explicitly forbids. + + // Compile-time check: tsc fails this file if a forbidden key is ever added. + type Forbidden = 'accessToken' | 'expiresAt'; + type AssertEntryOmitsForbidden = Forbidden extends keyof FederatedAuthTokenEntry ? never : true; + const _entryShapeCheck: AssertEntryOmitsForbidden = true; + void _entryShapeCheck; + + // Runtime check: a sample entry literal has exactly the three allowed keys. + const sample: FederatedAuthTokenEntry = { + integrationId: 'integration-1', + refreshToken: 'refresh-token-value', + metadataFingerprint: 'sha256-of-client-meta' + }; + const keys = Object.keys(sample).sort(); + + assert.deepStrictEqual(keys, ['integrationId', 'metadataFingerprint', 'refreshToken']); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts new file mode 100644 index 0000000000..3177e7ce58 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts @@ -0,0 +1,96 @@ +// VENDORED from @deepnote/blocks bundled internals. None of these symbols are part of the +// package's public exports (verified against dist/index.d.ts). Track removal in +// /home/ubuntu/.claude/plans/look-at-the-pr-curious-toast.md Step 10 β€” once @deepnote/blocks +// exports them, delete this file and import directly. +// TODO(deepnote-followups): remove when @deepnote/blocks exports these helpers. + +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { dedent } from 'ts-dedent'; + +/** + * SQL block subtype of `DeepnoteBlock`. Vendored because `@deepnote/blocks` + * does not currently export `SqlBlock` from its public API. + * + * TODO(deepnote-followups): replace with `import { SqlBlock } from '@deepnote/blocks'` + * once exported upstream. + */ +export type SqlBlock = Extract; + +/** + * Valid values for the `sql_cache_mode` argument of + * `_dntk.execute_sql_with_connection_json`. + * + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export type SqlCacheMode = 'cache_disabled' | 'always_write' | 'read_or_write'; + +/** + * Valid values for the `return_variable_type` argument of + * `_dntk.execute_sql_with_connection_json`. + * + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export type SqlCellVariableType = 'dataframe' | 'query_preview'; + +/** + * Mirror of `@deepnote/blocks`'s bundled-internal `escapePythonString`. Single-quotes + * a string and escapes backslashes, single quotes, and newlines. + * + * Byte-identical to the upstream implementation at + * `node_modules/@deepnote/blocks/dist/index.js`: + * `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'` + * + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export function escapePythonString(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`; +} + +/** + * Mirror of `@deepnote/blocks`'s bundled-internal `sanitizePythonVariableName`. Turns a + * user-supplied identifier into a valid Python variable name: + * - replaces runs of whitespace with `_` + * - strips characters that are not `[0-9a-zA-Z_]` + * - strips a leading run of non-letter/underscore characters + * - returns `'input_1'` when the result is empty (matches upstream default) + * + * Differs from upstream by accepting `undefined` and returning `undefined` for it, + * so call sites can pass `block.metadata.deepnote_variable_name` directly. + * + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export function sanitizePythonVariableName(name: string | undefined): string | undefined { + if (name === undefined) { + return undefined; + } + + let sanitizedVariableName = name + .replace(/\s+/g, '_') + .replace(/[^0-9a-zA-Z_]/g, '') + .replace(/^[^a-zA-Z_]+/g, ''); + + if (sanitizedVariableName === '') { + sanitizedVariableName = 'input_1'; + } + + return sanitizedVariableName; +} + +/** + * Mirror of `@deepnote/blocks`'s bundled-internal `createDataFrameConfig`. Produces a + * two-line Python snippet that configures the dataframe formatter for the given + * SQL block's table state. + * + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export function createDataFrameConfig(block: SqlBlock): string { + const tableState = block.metadata?.deepnote_table_state ?? {}; + const tableStateAsJson = JSON.stringify(tableState); + + return dedent` + if '_dntk' in globals(): + _dntk.dataframe_utils.configure_dataframe_formatter(${escapePythonString(tableStateAsJson)}) + else: + _deepnote_current_table_attrs = ${escapePythonString(tableStateAsJson)} + `; +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts new file mode 100644 index 0000000000..1b7ffd60fb --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts @@ -0,0 +1,259 @@ +import { createPythonCode } from '@deepnote/blocks'; +import { assert } from 'chai'; + +import { + createDataFrameConfig, + escapePythonString, + sanitizePythonVariableName, + SqlBlock +} from './vendoredBlocksHelpers'; + +suite('vendoredBlocksHelpers', () => { + suite('escapePythonString', () => { + test('wraps an empty string in single quotes', () => { + assert.strictEqual(escapePythonString(''), "''"); + }); + + test('wraps a plain ASCII string in single quotes', () => { + assert.strictEqual(escapePythonString('hello'), "'hello'"); + }); + + test('escapes single quotes inside the string', () => { + assert.strictEqual(escapePythonString("it's"), "'it\\'s'"); + assert.strictEqual(escapePythonString("'''"), "'\\'\\'\\''"); + }); + + test('leaves double quotes alone', () => { + assert.strictEqual(escapePythonString('he said "hi"'), `'he said "hi"'`); + }); + + test('escapes backslashes', () => { + assert.strictEqual(escapePythonString('a\\b'), "'a\\\\b'"); + }); + + test('escapes backslashes before quotes (order matters)', () => { + // Original: \ should become \\ + // Original: ' should become \' + // The order in the upstream impl is backslash, then quote, then newline. + // So `\'` (one backslash + one quote) becomes `\\\\\\'` (\\ + \'). + assert.strictEqual(escapePythonString("\\'"), "'\\\\\\''"); + }); + + test('escapes newlines', () => { + assert.strictEqual(escapePythonString('line1\nline2'), "'line1\\nline2'"); + }); + + test('does not escape tabs', () => { + // Upstream only escapes \\, ', and \n. Tabs pass through verbatim. + assert.strictEqual(escapePythonString('a\tb'), "'a\tb'"); + }); + + test('passes unicode through verbatim', () => { + assert.strictEqual(escapePythonString('hΓ©llo δΈ–η•Œ πŸš€'), "'hΓ©llo δΈ–η•Œ πŸš€'"); + }); + + test('handles a mixed input', () => { + const input = `a\\b'c\nd`; + // \\ β†’ \\\\ + // ' β†’ \' + // \n β†’ \\n + assert.strictEqual(escapePythonString(input), "'a\\\\b\\'c\\nd'"); + }); + + test('output, when interpreted as a Python single-quoted literal, round-trips back to the original SQL query', () => { + // Plan invariant (Step 1a): the generator wraps the user's SQL query via + // escapePythonString and embeds the result inside the Python source emitted + // to the kernel. The escaping must be reversible by Python's string literal + // parser. We simulate Python's parser by reversing the same escape mapping + // (single quote, backslash, and \n) β€” anything that doesn't round-trip here + // would also fail in CPython. + // + // Catches: a future change that adds an extra escape (e.g. for `\t` or `\r`) + // without updating the inverse mapping, breaking SQL queries with those + // characters at runtime. + function parsePythonSingleQuoted(escaped: string): string { + assert.isTrue(escaped.startsWith("'") && escaped.endsWith("'"), 'must be wrapped in single quotes'); + const body = escaped.slice(1, -1); + let result = ''; + for (let i = 0; i < body.length; i++) { + if (body[i] === '\\' && i + 1 < body.length) { + const next = body[i + 1]; + if (next === '\\') { + result += '\\'; + } else if (next === "'") { + result += "'"; + } else if (next === 'n') { + result += '\n'; + } else { + // Unrecognized escape β€” leave both chars in place. Python would + // typically keep the backslash too. + result += '\\' + next; + } + i++; + } else { + result += body[i]; + } + } + return result; + } + + const queries = [ + "SELECT 'a''b' AS x", + 'SELECT * FROM t WHERE path = "C:\\Users\\me"', + 'SELECT\n *\nFROM\n table', + "SELECT 'cafΓ©' AS greeting, 'δΈ–η•Œ' AS world", + "SELECT 'a\\b' AS literal_backslash", + '' + ]; + for (const query of queries) { + const escaped = escapePythonString(query); + assert.strictEqual( + parsePythonSingleQuoted(escaped), + query, + `round-trip failed for: ${JSON.stringify(query)}` + ); + } + }); + }); + + suite('sanitizePythonVariableName', () => { + test('returns undefined for undefined input', () => { + assert.strictEqual(sanitizePythonVariableName(undefined), undefined); + }); + + test('falls back to "input_1" for an empty string', () => { + assert.strictEqual(sanitizePythonVariableName(''), 'input_1'); + }); + + test('strips a leading digit', () => { + assert.strictEqual(sanitizePythonVariableName('1abc'), 'abc'); + }); + + test('strips leading digits but keeps a following underscore', () => { + // Upstream strips `[^a-zA-Z_]+` from the start, so `123_foo` becomes `_foo` + // (the underscore is a valid leading character and is preserved). + assert.strictEqual(sanitizePythonVariableName('123_foo'), '_foo'); + }); + + test('converts whitespace to underscores', () => { + assert.strictEqual(sanitizePythonVariableName('my var'), 'my_var'); + assert.strictEqual(sanitizePythonVariableName('foo bar'), 'foo_bar'); + }); + + test('strips hyphens and dots', () => { + assert.strictEqual(sanitizePythonVariableName('my-var.name'), 'myvarname'); + }); + + test('passes a valid identifier through unchanged', () => { + assert.strictEqual(sanitizePythonVariableName('valid_name_1'), 'valid_name_1'); + }); + + test('preserves a leading underscore', () => { + assert.strictEqual(sanitizePythonVariableName('_hidden'), '_hidden'); + }); + + test('falls back to "input_1" when only invalid chars are present', () => { + // Upstream behavior: '-' is stripped, then nothing remains, so fallback applies. + assert.strictEqual(sanitizePythonVariableName('---'), 'input_1'); + }); + + test('does not rewrite Python reserved words (upstream does not either)', () => { + // sanitize only handles syntactic validity; reserved-word handling is out of scope. + assert.strictEqual(sanitizePythonVariableName('class'), 'class'); + assert.strictEqual(sanitizePythonVariableName('lambda'), 'lambda'); + }); + + test('collapses an all-whitespace string to a single underscore', () => { + // Catches: a future upstream change to the \s+ -> _ step (e.g. \W+) breaking parity + // without us noticing. Upstream returns '_' for whitespace-only input because the + // \s+ replace collapses it to '_', which is a valid leading char that survives. + assert.strictEqual(sanitizePythonVariableName(' '), '_'); + }); + }); + + suite('createDataFrameConfig', () => { + function makeSqlBlock(tableState?: Record): SqlBlock { + return { + type: 'sql', + id: 'block-id', + blockGroup: 'group-id', + sortingKey: 'a', + content: 'SELECT 1', + metadata: tableState === undefined ? {} : { deepnote_table_state: tableState } + } as unknown as SqlBlock; + } + + test('produces a two-branch Python snippet with the JSON-encoded table state inlined', () => { + const block = makeSqlBlock({ pageSize: 25 }); + const result = createDataFrameConfig(block); + + const expected = + "if '_dntk' in globals():\n" + + ' _dntk.dataframe_utils.configure_dataframe_formatter(\'{"pageSize":25}\')\n' + + 'else:\n' + + ' _deepnote_current_table_attrs = \'{"pageSize":25}\''; + + assert.strictEqual(result, expected); + }); + + test('uses an empty JSON object when metadata.deepnote_table_state is missing', () => { + const block = makeSqlBlock(); + const result = createDataFrameConfig(block); + + const expected = + "if '_dntk' in globals():\n" + + " _dntk.dataframe_utils.configure_dataframe_formatter('{}')\n" + + 'else:\n' + + " _deepnote_current_table_attrs = '{}'"; + + assert.strictEqual(result, expected); + }); + + test('JSON-stringifies a non-trivial table state and round-trips through escapePythonString', () => { + const tableState = { + pageSize: 50, + sortBy: [{ column: 'name', direction: 'asc' as const }], + hiddenColumns: ['id'] + }; + const block = makeSqlBlock(tableState); + const result = createDataFrameConfig(block); + + const expectedJson = JSON.stringify(tableState); + assert.include(result, escapePythonString(expectedJson)); + // Both branches must reference the same escaped JSON. + const occurrences = result.split(escapePythonString(expectedJson)).length - 1; + assert.strictEqual(occurrences, 2); + }); + + test('matches the data-frame-config prefix produced by upstream @deepnote/blocks.createPythonCode', () => { + // Plan invariant (Step 1a): the federated cell-code generator emits the + // createDataFrameConfig output as the *first line[s]* of the cell output, + // identical to upstream createPythonCodeForSqlBlock. This test pins + // upstream parity by comparing our vendored helper's output against the + // dataframe-config prefix that upstream's createPythonCodeForSqlBlock + // produces for the same block. + // + // Upstream emits: + // + // + // + // i.e. the data-frame-config block, a blank line, then the SQL call. + // We extract the prefix up to the first blank line and assert byte parity. + // + // Catches: any upstream change to the dataframe-config template (indentation, + // wording, JSON serialization order) that drifts us out of parity. + const tableState = { + pageSize: 50, + sortBy: [{ column: 'name', direction: 'asc' as const }], + hiddenColumns: ['id'] + }; + const block = makeSqlBlock(tableState); + + const ours = createDataFrameConfig(block); + const upstreamFull = createPythonCode(block); + const upstreamPrefix = upstreamFull.split('\n\n')[0]; + + assert.strictEqual(ours, upstreamPrefix); + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/types.ts b/src/notebooks/deepnote/integrations/types.ts index d38a3cd2ed..d53c8402c6 100644 --- a/src/notebooks/deepnote/integrations/types.ts +++ b/src/notebooks/deepnote/integrations/types.ts @@ -1,3 +1,6 @@ +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { Event } from 'vscode'; + import { IntegrationWithStatus } from '../../../platform/notebooks/deepnote/integrationTypes'; // Re-export IIntegrationStorage from platform layer @@ -38,3 +41,59 @@ export interface IIntegrationManager { */ activate(): void; } + +/** + * A persisted federated-auth token entry for a single integration. + * + * The fingerprint is computed from `${clientId}|${clientSecret}|${project}` + * so we can detect stale tokens after the user edits their OAuth client + * metadata and invalidate them without consulting Google. + * + * Access tokens are never persisted β€” only the long-lived refresh token. + */ +export interface FederatedAuthTokenEntry { + integrationId: string; + refreshToken: string; + metadataFingerprint: string; +} + +export const IFederatedAuthTokenStorage = Symbol('IFederatedAuthTokenStorage'); +export interface IFederatedAuthTokenStorage { + /** + * Fires when a token is saved or deleted; the payload is the integration id. + */ + readonly onDidChangeTokens: Event; + delete(integrationId: string): Promise; + get(integrationId: string): Promise; + has(integrationId: string): Promise; + save(entry: FederatedAuthTokenEntry): Promise; +} + +export const IFederatedAuthSqlBlockCodeGenerator = Symbol('IFederatedAuthSqlBlockCodeGenerator'); +export interface IFederatedAuthSqlBlockCodeGenerator { + /** + * For a federated BigQuery SQL block, returns: + * - prelude: Python to run via a silent pre-execute (variable + * definition with the fresh access token). Never runs through + * the normal cell-history path. + * - cellCode: Python to run as the cell's main execute. References + * the variable defined by prelude. Safe to put in In[] history. + * + * Returns undefined for any block that is not a federated BigQuery + * SQL cell, so callers fall back to @deepnote/blocks.createPythonCode. + */ + generate(block: DeepnoteBlock): Promise<{ prelude: string; cellCode: string } | undefined>; +} + +/** + * Thrown by `IFederatedAuthSqlBlockCodeGenerator.generate` (and related + * call sites) when the integration is federated but has no usable refresh + * token β€” either because the user has not authenticated yet or because the + * stored token was invalidated (fingerprint mismatch, `invalid_grant`). + */ +export class NotAuthenticatedError extends Error { + constructor(public readonly integrationName: string) { + super(`Integration "${integrationName}" is not authenticated.`); + this.name = 'NotAuthenticatedError'; + } +} diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index c85719faec..2d650927e3 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -225,6 +225,7 @@ export namespace Commands { export const RevealInDeepnoteExplorer = 'deepnote.revealInExplorer'; export const EnableSnapshots = 'deepnote.enableSnapshots'; export const DisableSnapshots = 'deepnote.disableSnapshots'; + export const AuthenticateIntegration = 'deepnote.authenticateIntegration'; export const ManageIntegrations = 'deepnote.manageIntegrations'; export const AddSqlBlock = 'deepnote.addSqlBlock'; export const AddBigNumberChartBlock = 'deepnote.addBigNumberChartBlock'; diff --git a/src/platform/notebooks/deepnote/integrationTypes.ts b/src/platform/notebooks/deepnote/integrationTypes.ts index 3176e39027..56f8682d56 100644 --- a/src/platform/notebooks/deepnote/integrationTypes.ts +++ b/src/platform/notebooks/deepnote/integrationTypes.ts @@ -152,6 +152,16 @@ export enum IntegrationStatus { Error = 'error' } +/** + * Federated-auth token status for an integration. + * + * - `authenticated`: a refresh token is stored for this integration. + * - `disconnected`: the integration uses federated auth but has no stored token. + * - `unsupported`: this integration does not use federated auth, or federated + * auth is unavailable in the current runtime (web / remote). + */ +export type FederatedAuthTokenStatus = 'authenticated' | 'disconnected' | 'unsupported'; + /** * Integration with its current status */ @@ -167,4 +177,9 @@ export interface IntegrationWithStatus { * Type from the project's integrations list (used for prefilling when config is null) */ integrationType?: ConfigurableDatabaseIntegrationType; + /** + * Federated-auth token status. Only meaningful for integrations that support + * federated auth (currently BigQuery with `authMethod === 'google-oauth'`). + */ + tokenStatus?: FederatedAuthTokenStatus; } diff --git a/src/webviews/webview-side/integrations/types.ts b/src/webviews/webview-side/integrations/types.ts index 145c04109f..8c1cb4d3e1 100644 --- a/src/webviews/webview-side/integrations/types.ts +++ b/src/webviews/webview-side/integrations/types.ts @@ -6,12 +6,23 @@ export type ConfigurableDatabaseIntegrationConfig = Exclude; } +// Inbound (extension -> webview). Consumed by `MessageEvent` in the webview. export type WebviewMessage = UpdateMessage | ShowFormMessage | StatusMessage | LocInitMessage; + +export interface AuthenticateMessage { + type: 'authenticate'; + integrationId: string; +} + +// Outbound (webview -> extension). Dispatched in `integrationWebview.ts:handleMessage`. +// Keep this discriminated union exhaustive β€” every webview-side `postMessage` should +// produce a value of this type, and the extension-side handler should switch on `type`. +export type WebviewOutboundMessage = + | { type: 'configure'; integrationId: string } + | { type: 'save'; integrationId: string; config: ConfigurableDatabaseIntegrationConfig } + | { type: 'reset'; integrationId: string } + | { type: 'delete'; integrationId: string } + | AuthenticateMessage; From 15210688f8cccd04a90efd9da80f61f1bd556192 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 21 May 2026 18:40:45 +0000 Subject: [PATCH 02/13] feat(integrations): add OAuth token lifecycle for federated auth (M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot the federated-auth runtime: encrypted refresh-token storage with metadata-fingerprint stale detection, a Google OAuth strategy that owns its own verify callback, a session-less PKCE store, and a loopback flow that mints a refresh token via the user's browser. No access token is persisted β€” refresh is per-execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 1195 +++++++++++++++-- package.json | 7 + .../federatedAuthTokenStorage.node.ts | 326 +++++ ...ederatedAuthTokenStorage.node.unit.test.ts | 595 ++++++++ .../federatedAuth/googleOAuthProvider.node.ts | 216 +++ .../googleOAuthProvider.node.unit.test.ts | 282 ++++ .../federatedAuth/oauthLoopbackFlow.node.ts | 302 +++++ .../oauthLoopbackFlow.node.unit.test.ts | 472 +++++++ 8 files changed, 3315 insertions(+), 80 deletions(-) create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts diff --git a/package-lock.json b/package-lock.json index cb2372257b..57df1cdd8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "cross-fetch": "^3.1.5", "d3-format": "^3.1.0", "encoding": "^0.1.13", + "express": "^5.2.1", "fast-deep-equal": "^2.0.1", "format-util": "^1.0.5", "fs-extra": "^4.0.3", @@ -58,6 +59,8 @@ "node-fetch": "^2.6.7", "node-gyp-build": "^4.6.0", "node-stream-zip": "^1.6.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "path-browserify": "^1.0.1", "pdfkit": "^0.13.0", "pidtree": "^0.6.0", @@ -88,6 +91,7 @@ "tailwind-merge": "^3.3.1", "tcp-port-used": "^1.0.1", "tmp": "^0.2.4", + "ts-dedent": "^2.2.0", "url-parse": "^1.5.10", "uuid": "^13.0.2", "vega": "^6.2.0", @@ -119,6 +123,7 @@ "@types/dedent": "^0.7.0", "@types/del": "^4.0.0", "@types/event-stream": "^3.3.33", + "@types/express": "^5.0.6", "@types/format-util": "^1.0.2", "@types/fs-extra": "^5.0.1", "@types/get-port": "^3.2.0", @@ -132,6 +137,8 @@ "@types/nock": "^10.0.3", "@types/node": "^22.15.1", "@types/node-fetch": "^2.6.12", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", "@types/pdfkit": "^0.11.0", "@types/promisify-node": "^0.4.0", "@types/react": "^16.4.14", @@ -7162,6 +7169,17 @@ "@types/underscore": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz", @@ -7186,6 +7204,16 @@ "@types/chai": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -7485,6 +7513,31 @@ "@types/node": "*" } }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/format-util": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/format-util/-/format-util-1.0.2.tgz", @@ -7540,6 +7593,13 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7716,6 +7776,50 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/pdfkit": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.11.2.tgz", @@ -7753,6 +7857,20 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "16.14.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.24.tgz", @@ -7822,6 +7940,27 @@ "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==", "dev": true }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/sinon": { "version": "10.0.15", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.15.tgz", @@ -10027,6 +10166,15 @@ } ] }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", @@ -10182,6 +10330,46 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -10556,6 +10744,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c8": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", @@ -10818,7 +11015,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -11682,7 +11878,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true, "engines": { "node": ">=18" }, @@ -11695,7 +11890,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11723,6 +11917,24 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -13296,7 +13508,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13642,7 +13853,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -13703,7 +13913,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -14128,7 +14337,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -14974,6 +15182,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -15169,6 +15386,105 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -15396,6 +15712,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -15670,6 +16007,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -17082,20 +17428,23 @@ "optional": true }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -17360,6 +17709,15 @@ "node": ">=8" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -17818,6 +18176,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -21985,12 +22349,23 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-source-map": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", @@ -23720,6 +24095,12 @@ "node": ">=6" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -23886,7 +24267,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -24270,7 +24650,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -24285,6 +24664,64 @@ "node": ">=0.10.0" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -24399,6 +24836,11 @@ "node": "*" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -25230,6 +25672,19 @@ "node": ">= 8" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -25318,7 +25773,6 @@ "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -25403,6 +25857,46 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -26339,6 +26833,32 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -26790,6 +27310,72 @@ "node": ">= 10.13.0" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", @@ -26805,6 +27391,25 @@ "node": ">=20.0.0" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -26851,7 +27456,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/sha.js": { @@ -26928,7 +27532,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -26947,7 +27550,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -26963,7 +27565,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -26981,7 +27582,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -27587,10 +28187,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -28672,7 +29271,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -29077,7 +29675,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -29092,7 +29689,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -29102,7 +29698,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -29241,6 +29836,12 @@ "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==", "license": "MIT" }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -29391,6 +29992,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -29504,6 +30114,15 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", @@ -29592,7 +30211,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -36304,6 +36922,16 @@ "@types/underscore": "*" } }, + "@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/chai": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz", @@ -36328,6 +36956,15 @@ "@types/chai": "*" } }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -36594,6 +37231,29 @@ "@types/node": "*" } }, + "@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "@types/format-util": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/format-util/-/format-util-1.0.2.tgz", @@ -36647,6 +37307,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -36804,6 +37470,46 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" }, + "@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "@types/pdfkit": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.11.2.tgz", @@ -36839,6 +37545,18 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" }, + "@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, "@types/react": { "version": "16.14.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.24.tgz", @@ -36908,6 +37626,25 @@ "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==", "dev": true }, + "@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "@types/sinon": { "version": "10.0.15", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.15.tgz", @@ -38480,6 +39217,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "baseline-browser-mapping": { "version": "2.8.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", @@ -38590,6 +39332,32 @@ "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true }, + "body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "requires": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -38880,6 +39648,11 @@ "run-applescript": "^5.0.0" } }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "c8": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", @@ -39061,7 +39834,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -39679,14 +40451,12 @@ "content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==" }, "content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, "continuation-local-storage": { "version": "3.2.1", @@ -39712,6 +40482,16 @@ } } }, + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, "cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -40839,8 +41619,7 @@ "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, "dequal": { "version": "2.0.3", @@ -41113,8 +41892,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "electron-to-chromium": { "version": "1.5.233", @@ -41165,8 +41943,7 @@ "encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "encoding": { "version": "0.1.13", @@ -41504,8 +42281,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "1.0.5", @@ -42109,6 +42885,11 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, "event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -42250,6 +43031,75 @@ } } }, + "express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "requires": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "dependencies": { + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + } + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + } + } + }, "ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -42418,6 +43268,19 @@ "to-regex-range": "^5.0.1" } }, + "finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, "find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -42617,6 +43480,11 @@ "fetch-blob": "^3.1.2" } }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, "fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -43619,16 +44487,15 @@ "optional": true }, "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" } }, "http-proxy-agent": { @@ -43805,6 +44672,11 @@ "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==" }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -44107,6 +44979,11 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -46970,8 +47847,12 @@ "media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, "merge-source-map": { "version": "1.0.4", @@ -48236,6 +49117,11 @@ } } }, + "oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -48354,7 +49240,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "requires": { "ee-first": "1.1.1" } @@ -48608,14 +49493,48 @@ "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "pascalcase": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==" }, + "passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + } + }, + "passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==" + }, "path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -48699,6 +49618,11 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -49278,6 +50202,15 @@ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -49358,7 +50291,6 @@ "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, "requires": { "side-channel": "^1.1.0" } @@ -49417,6 +50349,32 @@ "safe-buffer": "^5.1.0" } }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "requires": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -50106,6 +51064,25 @@ "points-on-path": "^0.2.1" } }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "dependencies": { + "path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + } + } + }, "rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -50427,6 +51404,49 @@ "sver": "^1.8.3" } }, + "send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "requires": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "dependencies": { + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", @@ -50438,6 +51458,17 @@ "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true }, + "serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -50477,8 +51508,7 @@ "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "sha.js": { "version": "2.4.12", @@ -50535,7 +51565,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "requires": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -50548,7 +51577,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "requires": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -50558,7 +51586,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "requires": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -50570,7 +51597,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "requires": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -51005,10 +52031,9 @@ } }, "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" }, "stdin-discarder": { "version": "0.2.2", @@ -51822,8 +52847,7 @@ "toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, "topojson-client": { "version": "3.1.0", @@ -52118,7 +53142,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, "requires": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -52128,14 +53151,12 @@ "mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" }, "mime-types": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, "requires": { "mime-db": "^1.54.0" } @@ -52240,6 +53261,11 @@ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz", "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==" }, + "uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -52373,6 +53399,11 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -52454,6 +53485,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, "uuid": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", @@ -52528,8 +53564,7 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vega": { "version": "6.2.0", diff --git a/package.json b/package.json index 6f84ce4fc7..beaf643127 100644 --- a/package.json +++ b/package.json @@ -2704,6 +2704,7 @@ "cross-fetch": "^3.1.5", "d3-format": "^3.1.0", "encoding": "^0.1.13", + "express": "^5.2.1", "fast-deep-equal": "^2.0.1", "format-util": "^1.0.5", "fs-extra": "^4.0.3", @@ -2720,6 +2721,8 @@ "node-fetch": "^2.6.7", "node-gyp-build": "^4.6.0", "node-stream-zip": "^1.6.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "path-browserify": "^1.0.1", "pdfkit": "^0.13.0", "pidtree": "^0.6.0", @@ -2750,6 +2753,7 @@ "tailwind-merge": "^3.3.1", "tcp-port-used": "^1.0.1", "tmp": "^0.2.4", + "ts-dedent": "^2.2.0", "url-parse": "^1.5.10", "uuid": "^13.0.2", "vega": "^6.2.0", @@ -2781,6 +2785,7 @@ "@types/dedent": "^0.7.0", "@types/del": "^4.0.0", "@types/event-stream": "^3.3.33", + "@types/express": "^5.0.6", "@types/format-util": "^1.0.2", "@types/fs-extra": "^5.0.1", "@types/get-port": "^3.2.0", @@ -2794,6 +2799,8 @@ "@types/nock": "^10.0.3", "@types/node": "^22.15.1", "@types/node-fetch": "^2.6.12", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", "@types/pdfkit": "^0.11.0", "@types/promisify-node": "^0.4.0", "@types/react": "^16.4.14", diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts new file mode 100644 index 0000000000..d8e44ef38d --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts @@ -0,0 +1,326 @@ +import { createHash } from 'crypto'; +import { inject, injectable } from 'inversify'; +import { EventEmitter } from 'vscode'; +import { z } from 'zod'; + +import { IEncryptedStorage } from '../../../../platform/common/application/types'; +import { IAsyncDisposableRegistry } from '../../../../platform/common/types'; +import { logger } from '../../../../platform/logging'; +import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage } from '../types'; + +const FEDERATED_AUTH_TOKEN_SERVICE_NAME = 'deepnote-federated-auth-tokens'; +const INDEX_KEY = 'index'; +const TOKEN_REFRESH_TIMEOUT_MS = 15_000; // 15s + +/** + * Schema for the body returned by Google's OAuth token endpoint when + * exchanging a refresh token for a fresh access token. + * + * The shape mirrors the production response handling at + * /workspace/deepnote-internal/libs/shared-node/src/integration-federated-auth/integration-federated-auth.ts:372-433. + * + * - `access_token`: required on 2xx responses. + * - `refresh_token`: returned only when Google rotates the refresh token. + * - `expires_in`: seconds until the access token expires (unused β€” we never cache). + * - `error`: returned on non-2xx responses (e.g. 'invalid_grant', 'invalid_client'). + */ +const tokenEndpointResponseSchema = z.object({ + access_token: z.string().optional(), + refresh_token: z.string().optional(), + expires_in: z.number().optional(), + error: z.string().optional(), + error_description: z.string().optional() +}); + +/** + * Thrown when the OAuth provider rejects the refresh request with + * `error: 'invalid_grant'`. Indicates the stored refresh token is no + * longer usable (revoked, expired, or invalidated by the user). + * + * Callers typically respond by deleting the stored token entry and + * surfacing a "Not authenticated" state to the user. + */ +export class InvalidGrantError extends Error { + constructor(message = 'Refresh token rejected by OAuth provider.') { + super(message); + this.name = 'InvalidGrantError'; + } +} + +/** + * Thrown when the OAuth provider rejects the refresh request with + * `error: 'invalid_client'` or `error: 'unauthorized_client'`. Indicates + * the OAuth client metadata stored on the integration is misconfigured + * (e.g. wrong clientId/clientSecret). + */ +export class InvalidClientError extends Error { + constructor(message = 'OAuth client credentials rejected by provider.') { + super(message); + this.name = 'InvalidClientError'; + } +} + +/** + * Computes a SHA-256 fingerprint of the OAuth-client metadata on a federated + * integration. The fingerprint is `${clientId}|${clientSecret}|${project}` + * hashed with SHA-256. + * + * Used to detect when the user edits OAuth client metadata after a token + * has been saved β€” the stored entry's `metadataFingerprint` no longer + * matches the integration, so the token must be invalidated before use. + */ +export function computeMetadataFingerprint(metadata: { + clientId: string; + clientSecret: string; + project: string; +}): string { + return createHash('sha256') + .update(`${metadata.clientId}|${metadata.clientSecret}|${metadata.project}`) + .digest('hex'); +} + +/** + * POSTs a `grant_type=refresh_token` request to the OAuth token endpoint + * and returns the resulting fresh access token. Optionally returns a + * rotated refresh token if the provider issues one β€” callers are + * responsible for persisting it via `IFederatedAuthTokenStorage.save`. + * + * The plan's non-negotiable: access tokens are never cached, neither at + * rest nor in memory beyond the single execution preparation that needs + * them. This function MUST be called before every SQL cell execution. + * + * `timeoutMs` overrides {@link TOKEN_REFRESH_TIMEOUT_MS}. It exists as a + * test seam so the timeout-on-slow-body scenario can be exercised without + * sleeping for 15 seconds; production callers should leave it undefined. + * + * Reference implementation: + * /workspace/deepnote-internal/libs/shared-node/src/integration-federated-auth/integration-federated-auth.ts:350-434 + */ +export async function fetchFreshAccessToken( + entry: FederatedAuthTokenEntry, + oauthConfig: { tokenUrl: string; clientId: string; clientSecret: string }, + timeoutMs: number = TOKEN_REFRESH_TIMEOUT_MS +): Promise<{ accessToken: string; newRefreshToken?: string }> { + const basicAuth = Buffer.from(`${oauthConfig.clientId}:${oauthConfig.clientSecret}`).toString('base64'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + // The same AbortController governs both the initial fetch and the + // body-read (`response.json()`) so a slow body stream is also bounded + // by the timeout β€” Google sometimes sends headers fast and the body + // slow. The single `finally` ensures the timer is cleared regardless + // of which step fails. + let response: Response | undefined; + let rawBody: unknown; + try { + response = await fetch(oauthConfig.tokenUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(entry.refreshToken)}`, + signal: controller.signal + }); + rawBody = await response.json(); + } catch (error) { + if (error instanceof SyntaxError && response !== undefined) { + throw new Error( + `Token refresh response was not valid JSON (HTTP ${response.status} ${response.statusText}).`, + { cause: error } + ); + } + throw error; + } finally { + clearTimeout(timeout); + } + + const parsed = tokenEndpointResponseSchema.safeParse(rawBody); + if (!parsed.success) { + throw new Error(`Token refresh returned invalid response body: ${parsed.error.message}`); + } + + const data = parsed.data; + + if (!response.ok) { + if (data.error === 'invalid_grant') { + throw new InvalidGrantError(); + } + if (data.error === 'invalid_client' || data.error === 'unauthorized_client') { + throw new InvalidClientError(); + } + throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`); + } + + if (!data.access_token) { + throw new Error('Token refresh succeeded but response did not include an access_token.'); + } + + return { + accessToken: data.access_token, + newRefreshToken: data.refresh_token ?? undefined + }; +} + +/** + * Encrypted-storage backed implementation of {@link IFederatedAuthTokenStorage}. + * + * Mirrors the cache+index pattern in {@link IntegrationStorage}: + * - Each entry is stored under its `integrationId` key. + * - A separate `'index'` key holds a JSON array of known IDs so the + * cache can be hydrated lazily on first access. + * - An in-memory cache keeps reads cheap once hydrated. + * + * Plan non-negotiable: only the long-lived `refreshToken` (plus the + * integration id and metadata fingerprint) is persisted. Access tokens + * are never written here β€” they're fetched on demand for each cell + * execution via {@link fetchFreshAccessToken}. + */ +@injectable() +export class FederatedAuthTokenStorage implements IFederatedAuthTokenStorage { + private readonly cache: Map = new Map(); + + private cacheLoaded = false; + + private readonly _onDidChangeTokens = new EventEmitter(); + + public readonly onDidChangeTokens = this._onDidChangeTokens.event; + + constructor( + @inject(IEncryptedStorage) private readonly encryptedStorage: IEncryptedStorage, + @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry + ) { + // Register for disposal when the extension deactivates. + asyncRegistry.push(this); + } + + public async delete(integrationId: string): Promise { + await this.ensureCacheLoaded(); + + if (!this.cache.has(integrationId)) { + return; + } + + await this.encryptedStorage.store(FEDERATED_AUTH_TOKEN_SERVICE_NAME, integrationId, undefined); + this.cache.delete(integrationId); + await this.updateIndex(); + + this._onDidChangeTokens.fire(integrationId); + } + + public dispose(): void { + this._onDidChangeTokens.dispose(); + } + + public async get(integrationId: string): Promise { + await this.ensureCacheLoaded(); + return this.cache.get(integrationId); + } + + public async has(integrationId: string): Promise { + await this.ensureCacheLoaded(); + return this.cache.has(integrationId); + } + + public async save(entry: FederatedAuthTokenEntry): Promise { + await this.ensureCacheLoaded(); + + await this.encryptedStorage.store( + FEDERATED_AUTH_TOKEN_SERVICE_NAME, + entry.integrationId, + JSON.stringify(entry) + ); + this.cache.set(entry.integrationId, entry); + await this.updateIndex(); + + this._onDidChangeTokens.fire(entry.integrationId); + } + + /** + * Hydrate the in-memory cache from encrypted storage. Reads the + * `'index'` secret first to discover which integration IDs have + * entries persisted; then loads each entry by id. + * + * Tolerates corrupted entries (logs + skips) and a missing/corrupted + * index (treats storage as empty). + */ + private async ensureCacheLoaded(): Promise { + if (this.cacheLoaded) { + return; + } + + const indexJson = await this.encryptedStorage.retrieve(FEDERATED_AUTH_TOKEN_SERVICE_NAME, INDEX_KEY); + if (!indexJson) { + this.cacheLoaded = true; + return; + } + + let integrationIds: string[]; + try { + const parsed: unknown = JSON.parse(indexJson); + if (!Array.isArray(parsed)) { + throw new Error('Index is not an array.'); + } + integrationIds = parsed.filter((id): id is string => typeof id === 'string'); + } catch (error) { + logger.error('FederatedAuthTokenStorage: Failed to parse index, treating storage as empty.', error); + this.cacheLoaded = true; + return; + } + + // Mirrors the cleanup pattern in IntegrationStorage:165-241 β€” collect + // ids whose entries are unreadable or malformed, then purge them from + // encrypted storage and rewrite the index after the loop. Without this + // step, an orphaned refresh-token secret could linger in SecretStorage + // forever (and in the index until the next save/delete rewrites it). + const malformedIds: string[] = []; + for (const id of integrationIds) { + try { + const entryJson = await this.encryptedStorage.retrieve(FEDERATED_AUTH_TOKEN_SERVICE_NAME, id); + if (!entryJson) { + continue; + } + const parsed = JSON.parse(entryJson) as Partial; + if ( + typeof parsed.integrationId === 'string' && + typeof parsed.refreshToken === 'string' && + typeof parsed.metadataFingerprint === 'string' + ) { + this.cache.set(id, { + integrationId: parsed.integrationId, + refreshToken: parsed.refreshToken, + metadataFingerprint: parsed.metadataFingerprint + }); + } else { + logger.warn(`FederatedAuthTokenStorage: Skipping malformed token entry for ${id}.`); + malformedIds.push(id); + } + } catch (error) { + logger.error(`FederatedAuthTokenStorage: Failed to load token entry for ${id}.`, error); + malformedIds.push(id); + } + } + + if (malformedIds.length > 0) { + logger.info( + `FederatedAuthTokenStorage: Removing ${malformedIds.length} malformed token entry/entries from storage.` + ); + for (const id of malformedIds) { + try { + await this.encryptedStorage.store(FEDERATED_AUTH_TOKEN_SERVICE_NAME, id, undefined); + } catch (error) { + logger.error(`FederatedAuthTokenStorage: Failed to delete malformed token entry for ${id}.`, error); + } + } + await this.updateIndex(); + } + + this.cacheLoaded = true; + } + + private async updateIndex(): Promise { + const integrationIds = Array.from(this.cache.keys()); + await this.encryptedStorage.store(FEDERATED_AUTH_TOKEN_SERVICE_NAME, INDEX_KEY, JSON.stringify(integrationIds)); + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts new file mode 100644 index 0000000000..bce2f11496 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts @@ -0,0 +1,595 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; + +import { IEncryptedStorage } from '../../../../platform/common/application/types'; +import { IAsyncDisposableRegistry } from '../../../../platform/common/types'; +import { FederatedAuthTokenEntry } from '../types'; +import { + computeMetadataFingerprint, + FederatedAuthTokenStorage, + fetchFreshAccessToken, + InvalidClientError, + InvalidGrantError +} from './federatedAuthTokenStorage.node'; + +suite('federatedAuthTokenStorage', () => { + suite('computeMetadataFingerprint', () => { + test('is deterministic for the same inputs', () => { + const meta = { clientId: 'c-1', clientSecret: 's-1', project: 'p-1' }; + assert.strictEqual(computeMetadataFingerprint(meta), computeMetadataFingerprint(meta)); + }); + + test('produces a 64-char hex SHA-256 digest', () => { + const fp = computeMetadataFingerprint({ clientId: 'a', clientSecret: 'b', project: 'c' }); + assert.match(fp, /^[a-f0-9]{64}$/); + }); + + test('differs when clientId changes', () => { + const a = computeMetadataFingerprint({ clientId: 'c-1', clientSecret: 's', project: 'p' }); + const b = computeMetadataFingerprint({ clientId: 'c-2', clientSecret: 's', project: 'p' }); + assert.notStrictEqual(a, b); + }); + + test('differs when clientSecret changes', () => { + const a = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's-1', project: 'p' }); + const b = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's-2', project: 'p' }); + assert.notStrictEqual(a, b); + }); + + test('differs when project changes', () => { + const a = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's', project: 'p-1' }); + const b = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's', project: 'p-2' }); + assert.notStrictEqual(a, b); + }); + + test('treats the three fields as distinct (no field-boundary confusion)', () => { + // Catches a bug where someone concatenates without a separator and `a|bc` + // collides with `ab|c`. Empirically each field must be independent. + const a = computeMetadataFingerprint({ clientId: 'a', clientSecret: 'b', project: 'c' }); + const b = computeMetadataFingerprint({ clientId: 'a|b', clientSecret: '', project: 'c' }); + assert.notStrictEqual(a, b); + }); + }); + + suite('FederatedAuthTokenStorage', () => { + let storage: FederatedAuthTokenStorage; + let encryptedStorage: IEncryptedStorage; + let asyncRegistry: IAsyncDisposableRegistry; + let storageData: Map; + + setup(() => { + storageData = new Map(); + encryptedStorage = mock(); + asyncRegistry = mock(); + + when(encryptedStorage.store(anything(), anything(), anything())).thenCall( + async (_serviceName: string, key: string, value: string | undefined) => { + if (value === undefined) { + storageData.delete(key); + } else { + storageData.set(key, value); + } + } + ); + when(encryptedStorage.retrieve(anything(), anything())).thenCall( + async (_serviceName: string, key: string) => { + return storageData.get(key); + } + ); + + storage = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + }); + + teardown(() => { + storage.dispose(); + }); + + const sampleEntry = (id = 'integration-1'): FederatedAuthTokenEntry => ({ + integrationId: id, + refreshToken: `refresh-token-for-${id}`, + metadataFingerprint: `fp-${id}` + }); + + test('returns undefined for unknown integration id', async () => { + const result = await storage.get('does-not-exist'); + assert.strictEqual(result, undefined); + }); + + test('has returns false for unknown integration id', async () => { + assert.strictEqual(await storage.has('does-not-exist'), false); + }); + + test('save then get round-trips the entry', async () => { + const entry = sampleEntry(); + await storage.save(entry); + + const result = await storage.get(entry.integrationId); + assert.deepStrictEqual(result, entry); + }); + + test('save persists exactly the three-field entry shape', async () => { + const entry = sampleEntry(); + await storage.save(entry); + const stored = storageData.get(entry.integrationId); + assert.ok(stored, 'entry should be stored'); + const parsed = JSON.parse(stored!); + assert.deepStrictEqual(Object.keys(parsed).sort(), [ + 'integrationId', + 'metadataFingerprint', + 'refreshToken' + ]); + }); + + test('has returns true after save', async () => { + const entry = sampleEntry(); + await storage.save(entry); + assert.strictEqual(await storage.has(entry.integrationId), true); + }); + + test('delete removes the entry', async () => { + const entry = sampleEntry(); + await storage.save(entry); + await storage.delete(entry.integrationId); + assert.strictEqual(await storage.get(entry.integrationId), undefined); + assert.strictEqual(await storage.has(entry.integrationId), false); + }); + + test('delete on a missing integration does not fire the change event', async () => { + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + await storage.delete('does-not-exist'); + + assert.deepStrictEqual(events, []); + }); + + test('save fires onDidChangeTokens with the correct integration id', async () => { + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + await storage.save(sampleEntry('integration-1')); + await storage.save(sampleEntry('integration-2')); + + assert.deepStrictEqual(events, ['integration-1', 'integration-2']); + }); + + test('delete fires onDidChangeTokens with the deleted integration id', async () => { + const entry = sampleEntry(); + await storage.save(entry); + + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + await storage.delete(entry.integrationId); + + assert.deepStrictEqual(events, [entry.integrationId]); + }); + + test('save updates the index secret with the integration id', async () => { + await storage.save(sampleEntry('integration-1')); + await storage.save(sampleEntry('integration-2')); + + const indexJson = storageData.get('index'); + assert.ok(indexJson); + assert.deepStrictEqual((JSON.parse(indexJson!) as string[]).sort(), ['integration-1', 'integration-2']); + }); + + test('delete updates the index secret', async () => { + await storage.save(sampleEntry('integration-1')); + await storage.save(sampleEntry('integration-2')); + await storage.delete('integration-1'); + + const indexJson = storageData.get('index'); + assert.ok(indexJson); + assert.deepStrictEqual(JSON.parse(indexJson!), ['integration-2']); + }); + + test('a fresh instance backed by the same storage rehydrates the cache', async () => { + const entry = sampleEntry(); + await storage.save(entry); + + // New instance, same underlying storageData. + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + const result = await reloaded.get(entry.integrationId); + assert.deepStrictEqual(result, entry); + } finally { + reloaded.dispose(); + } + }); + + test('a fresh instance after multiple saves loads all entries', async () => { + await storage.save(sampleEntry('a')); + await storage.save(sampleEntry('b')); + await storage.save(sampleEntry('c')); + + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + assert.strictEqual(await reloaded.has('a'), true); + assert.strictEqual(await reloaded.has('b'), true); + assert.strictEqual(await reloaded.has('c'), true); + } finally { + reloaded.dispose(); + } + }); + + test('handles missing index gracefully', async () => { + // Nothing in storageData. + const result = await storage.get('integration-1'); + assert.strictEqual(result, undefined); + }); + + test('handles corrupted index gracefully', async () => { + storageData.set('index', 'not-json'); + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + assert.strictEqual(await reloaded.has('whatever'), false); + } finally { + reloaded.dispose(); + } + }); + + test('skips malformed entries during reload', async () => { + storageData.set('index', JSON.stringify(['malformed-1', 'good-1'])); + storageData.set('malformed-1', JSON.stringify({ integrationId: 'malformed-1' })); + storageData.set( + 'good-1', + JSON.stringify({ + integrationId: 'good-1', + refreshToken: 't', + metadataFingerprint: 'fp' + } satisfies FederatedAuthTokenEntry) + ); + + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + assert.strictEqual(await reloaded.has('malformed-1'), false); + assert.strictEqual(await reloaded.has('good-1'), true); + } finally { + reloaded.dispose(); + } + }); + + test('removes malformed entries from encrypted storage during reload', async () => { + // Catches: orphaned refresh-token secrets persist forever when an + // entry's JSON shape is wrong on disk. + storageData.set('index', JSON.stringify(['malformed-1', 'good-1'])); + storageData.set('malformed-1', JSON.stringify({ integrationId: 'malformed-1' })); + storageData.set( + 'good-1', + JSON.stringify({ + integrationId: 'good-1', + refreshToken: 't', + metadataFingerprint: 'fp' + } satisfies FederatedAuthTokenEntry) + ); + + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + // Trigger cache load. + await reloaded.has('good-1'); + + assert.strictEqual(storageData.has('malformed-1'), false, 'malformed entry should be purged'); + assert.strictEqual(storageData.has('good-1'), true, 'good entry should remain'); + } finally { + reloaded.dispose(); + } + }); + + test('removes malformed entries from the persisted index during reload', async () => { + // Catches: the index keeps referencing the malformed id even after + // the entry itself is gone, leading to repeated load attempts. + storageData.set('index', JSON.stringify(['malformed-1', 'good-1'])); + storageData.set('malformed-1', JSON.stringify({ integrationId: 'malformed-1' })); + storageData.set( + 'good-1', + JSON.stringify({ + integrationId: 'good-1', + refreshToken: 't', + metadataFingerprint: 'fp' + } satisfies FederatedAuthTokenEntry) + ); + + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + // Trigger cache load. + await reloaded.has('good-1'); + + const indexJson = storageData.get('index'); + assert.ok(indexJson, 'index should still be present'); + assert.deepStrictEqual(JSON.parse(indexJson!), ['good-1']); + } finally { + reloaded.dispose(); + } + }); + }); + + suite('fetchFreshAccessToken', () => { + let originalFetch: typeof globalThis.fetch | undefined; + + const sampleEntry: FederatedAuthTokenEntry = { + integrationId: 'integration-1', + refreshToken: 'refresh-token-value', + metadataFingerprint: 'fp' + }; + const sampleConfig = { + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'client-id', + clientSecret: 'client-secret' + }; + + setup(() => { + originalFetch = globalThis.fetch; + }); + + teardown(() => { + if (originalFetch === undefined) { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } else { + globalThis.fetch = originalFetch; + } + sinon.restore(); + }); + + function makeResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); + } + + test('sends Basic auth header and form-encoded refresh_token body', async () => { + const fetchStub = sinon.stub().resolves(makeResponse(200, { access_token: 'fresh-access' })); + globalThis.fetch = fetchStub as unknown as typeof fetch; + + await fetchFreshAccessToken(sampleEntry, sampleConfig); + + sinon.assert.calledOnce(fetchStub); + const [url, init] = fetchStub.firstCall.args as [string, RequestInit]; + assert.strictEqual(url, sampleConfig.tokenUrl); + assert.strictEqual(init.method, 'POST'); + + const expectedBasic = Buffer.from(`${sampleConfig.clientId}:${sampleConfig.clientSecret}`).toString( + 'base64' + ); + const headers = init.headers as Record; + assert.strictEqual(headers.Authorization, `Basic ${expectedBasic}`); + assert.strictEqual(headers['Content-Type'], 'application/x-www-form-urlencoded'); + + assert.strictEqual(init.body, `grant_type=refresh_token&refresh_token=${sampleEntry.refreshToken}`); + }); + + test('returns the access token and the rotated refresh token on success', async () => { + globalThis.fetch = sinon + .stub() + .resolves( + makeResponse(200, { access_token: 'fresh-access', refresh_token: 'rotated-refresh' }) + ) as unknown as typeof fetch; + + const result = await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.deepStrictEqual(result, { + accessToken: 'fresh-access', + newRefreshToken: 'rotated-refresh' + }); + }); + + test('returns the access token with newRefreshToken=undefined when not rotated', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(200, { access_token: 'fresh-access' })) as unknown as typeof fetch; + + const result = await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.strictEqual(result.accessToken, 'fresh-access'); + assert.strictEqual(result.newRefreshToken, undefined); + }); + + test('URL-encodes the refresh token in the body', async () => { + const fetchStub = sinon.stub().resolves(makeResponse(200, { access_token: 'a' })); + globalThis.fetch = fetchStub as unknown as typeof fetch; + + const entryWithSpecial: FederatedAuthTokenEntry = { + integrationId: 'i', + refreshToken: 'a=b&c+d e', + metadataFingerprint: 'fp' + }; + + await fetchFreshAccessToken(entryWithSpecial, sampleConfig); + + const [, init] = fetchStub.firstCall.args as [string, RequestInit]; + assert.strictEqual( + init.body, + `grant_type=refresh_token&refresh_token=${encodeURIComponent(entryWithSpecial.refreshToken)}` + ); + }); + + test('throws InvalidGrantError on HTTP 400 with error=invalid_grant', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(400, { error: 'invalid_grant' })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, InvalidGrantError); + } + }); + + test('throws InvalidClientError on HTTP 401 with error=invalid_client', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(401, { error: 'invalid_client' })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, InvalidClientError); + } + }); + + test('throws InvalidClientError on error=unauthorized_client', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(401, { error: 'unauthorized_client' })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, InvalidClientError); + } + }); + + test('throws a generic Error on HTTP 500', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(500, { error: 'internal_server_error' })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + assert.notInstanceOf(err, InvalidGrantError); + assert.notInstanceOf(err, InvalidClientError); + } + }); + + test('throws on a fetch AbortError (timeout)', async () => { + const abortError = new Error('The user aborted a request.'); + abortError.name = 'AbortError'; + globalThis.fetch = sinon.stub().rejects(abortError) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + assert.strictEqual((err as Error).name, 'AbortError'); + } + }); + + test('throws when the response body is not valid JSON', async () => { + const malformedResponse = new Response('not-json', { + status: 200, + headers: { 'content-type': 'application/json' } + }); + globalThis.fetch = sinon.stub().resolves(malformedResponse) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + } + }); + + test('throws when 2xx response does not include access_token', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(200, { refresh_token: 'r' })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + } + }); + + test('throws when access_token in a 2xx response is not a string', async () => { + // Zod schema drift / proxy-injected garbage: locks the schema + // contract on the access_token field. + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(200, { access_token: 12345 })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + assert.include((err as Error).message, 'invalid response body'); + } + }); + + test('throws when refresh_token in a 2xx response is not a string', async () => { + globalThis.fetch = sinon + .stub() + .resolves(makeResponse(200, { access_token: 'a', refresh_token: 42 })) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + assert.include((err as Error).message, 'invalid response body'); + } + }); + + test('throws with SyntaxError cause when body is invalid JSON, preserving original error', async () => { + const malformedResponse = new Response('not-json', { + status: 200, + headers: { 'content-type': 'application/json' } + }); + globalThis.fetch = sinon.stub().resolves(malformedResponse) as unknown as typeof fetch; + + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + assert.include((err as Error).message, 'not valid JSON'); + assert.include((err as Error).message, 'HTTP 200'); + assert.instanceOf((err as Error).cause, SyntaxError); + } + }); + + test('rejects when response.json() takes longer than the timeout', async () => { + // Headers arrive instantly, but `response.json()` never settles + // unless the AbortController inside fetchFreshAccessToken fires + // and rejects the body read. If the timeout only covered the + // initial fetch (the pre-fix behaviour), this test would hang + // until mocha's own 2s timeout β€” instead we want a quick reject + // when the body read is aborted. + const makeSlowResponse = (signal: AbortSignal | undefined): Response => { + const slowJson = (): Promise => + new Promise((_resolve, reject) => { + if (signal === undefined) { + return; + } + signal.addEventListener('abort', () => { + const abortError = new Error('The body read was aborted.'); + abortError.name = 'AbortError'; + reject(abortError); + }); + }); + return { + ok: true, + status: 200, + statusText: 'OK', + json: slowJson + } as unknown as Response; + }; + + globalThis.fetch = ((_url: string, init?: RequestInit) => { + // Headers arrive immediately; body read stalls until abort. + return Promise.resolve(makeSlowResponse(init?.signal ?? undefined)); + }) as unknown as typeof fetch; + + const start = Date.now(); + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig, 50); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, Error); + assert.strictEqual((err as Error).name, 'AbortError'); + // Sanity: should have rejected close to the timeout, not after a + // many-second delay. + assert.isBelow(Date.now() - start, 1500); + } + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts new file mode 100644 index 0000000000..2c977a70be --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts @@ -0,0 +1,216 @@ +import * as crypto from 'crypto'; +import { Profile as GoogleProfile, Strategy as GoogleStrategy, VerifyCallback } from 'passport-google-oauth20'; + +export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** + * OAuth scopes for BigQuery federated authentication. Mirrors production at + * /workspace/deepnote-internal/apps/webapp/server/modules/federated-integration-auth/handlers.ts:77. + * + * Notably absent: `openid`. Refresh tokens are issued by combining + * `access_type=offline` + `prompt=consent` on the authorize request β€” no + * `openid` scope needed. Adding `openid` triggers ID-token issuance which + * we don't need. + */ +export const GOOGLE_BIGQUERY_SCOPES = ['email', 'profile', 'https://www.googleapis.com/auth/bigquery'] as const; + +/** + * Shape of the per-flow record kept inside an {@link InMemoryPkceStore}. The + * `meta` field is opaque to us β€” passport-oauth2 supplies an object with the + * `authorizationURL` / `tokenURL` / `clientID` / `callbackURL` keys at call + * time but we round-trip it without inspection. + */ +interface PkceRecord { + codeVerifier: string; + meta: unknown; +} + +/** + * Subset of `passport-oauth2`'s state-store interface that we actually use. + * + * passport-oauth2 inspects the function's arity (`store.length` / + * `verify.length`) to pick between PKCE and non-PKCE call patterns + * (see `node_modules/passport-oauth2/lib/strategy.js:218-298`). We declare + * the 5-arg / 4-arg overloads explicitly so we land in the PKCE branch: + * + * - `store(req, verifier, state, meta, cb)` β€” arity 5, PKCE+state path. + * - `verify(req, providedState, meta, cb)` β€” arity 4, PKCE+state path. + * + * The verify callback shape is `(err, ok, state)` per passport-oauth2's + * `loaded(err, ok, state)` function at strategy.js:160. When PKCE is on, + * the `ok` slot must hold the `codeVerifier` string (truthy + typeof + * 'string' triggers the `params.code_verifier = ok` branch at + * strategy.js:171-173). The `state` slot is the (optional) opaque state + * value passed to the user code via the success info object. + */ +export interface InMemoryPkceStore { + store( + req: unknown, + verifier: string, + state: unknown, + meta: unknown, + cb: (err: Error | null, state?: string) => void + ): void; + verify( + req: unknown, + providedState: string, + meta: unknown, + cb: (err: Error | null, ok: string | false, info?: unknown) => void + ): void; +} + +/** + * Builds a per-flow in-memory PKCE/state store compatible with + * `passport-oauth2`'s state-store contract. + * + * Why a custom store: `passport-oauth2`'s built-in `PKCESessionStore` + * (auto-selected when `pkce: true, state: true` and no `store` option is + * provided β€” see `passport-oauth2/lib/strategy.js:105-114`) reads/writes + * `req.session` and errors when it's undefined. The loopback OAuth flow + * (Step 5 of the plan) has no `express-session` and adding one is + * overkill, so we substitute a simple `Map` keyed + * by a cryptographically-random state value generated here. + * + * Each call to {@link buildBigQueryGoogleOAuthStrategy} should be paired + * with its own store so concurrent flows don't trample each other. + */ +export function createInMemoryPkceStore(): InMemoryPkceStore { + const records = new Map(); + return { + store(_req, verifier, _state, meta, cb) { + const state = crypto.randomBytes(24).toString('base64url'); + records.set(state, { codeVerifier: verifier, meta }); + cb(null, state); + }, + verify(_req, providedState, _meta, cb) { + const record = records.get(providedState); + if (record === undefined) { + cb(null, false, { message: 'Invalid authorization request state.' }); + return; + } + records.delete(providedState); + // passport-oauth2 PKCE: the `ok` slot must hold the codeVerifier + // string (truthy + typeof 'string' triggers the + // `params.code_verifier = ok` branch at strategy.js:171-173). + cb(null, record.codeVerifier); + } + }; +} + +/** + * Result of {@link buildBigQueryGoogleOAuthStrategy}. + * + * - `strategy`: the configured passport strategy. Caller passes this to + * `passport.use(name, strategy)` and mounts the standard + * `passport.authenticate(name, ...)` middleware on the loopback server. + * - `completion`: resolves with the captured refresh token once the + * verify callback fires, or rejects on a missing/empty refresh token. + * The verify closure is set up inside this builder so the call site + * cannot forget to wire it. + */ +export interface BigQueryGoogleOAuthStrategy { + completion: Promise<{ refreshToken: string }>; + strategy: GoogleStrategy; +} + +/** + * Parameters for {@link buildBigQueryGoogleOAuthStrategy}. + * + * `authorizationURL` / `tokenURL` are documented overrides on + * `passport-google-oauth20`'s strategy options (see + * `node_modules/passport-google-oauth20/lib/strategy.js:49-50`). When + * unset, the strategy defaults to Google's bundled URLs. We expose the + * overrides primarily as test seams β€” Step 6's plan calls these out + * explicitly for the runOAuthFlow integration test. + */ +export interface BuildBigQueryGoogleOAuthStrategyParams { + authorizationURL?: string; + clientId: string; + clientSecret: string; + store: InMemoryPkceStore; + tokenURL?: string; +} + +/** + * Builds the Google OAuth 2.0 strategy + verify callback pair used by + * the loopback flow in Step 5. The verify is constructed internally so + * the call site cannot forget to supply one (production review finding + * #6 in the plan). + * + * The verify resolves the returned `completion` promise on a non-empty + * refresh token; on an empty refresh token (Google sometimes omits one + * when the same OAuth client has already been authorized for the same + * user without an intervening revoke), it rejects with the documented + * "Revoke the app at myaccount.google.com/permissions and try again." + * message so the user knows the fix. + */ +export function buildBigQueryGoogleOAuthStrategy( + params: BuildBigQueryGoogleOAuthStrategyParams +): BigQueryGoogleOAuthStrategy { + let resolveCompletion!: (value: { refreshToken: string }) => void; + let rejectCompletion!: (reason: Error) => void; + const completion = new Promise<{ refreshToken: string }>((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + + const strategyOptions = { + clientID: params.clientId, + clientSecret: params.clientSecret, + // Overwritten by runOAuthFlow once the loopback server has bound a port. + // We start with a placeholder so the strategy options pass validation. + callbackURL: 'http://127.0.0.1:0/auth/callback', + scope: [...GOOGLE_BIGQUERY_SCOPES], + pkce: true, + state: true, + // Skip the user-profile fetch. passport-oauth2 calls + // strategy.userProfile(accessToken, done) after the token exchange, + // which in passport-google-oauth20 hits + // https://www.googleapis.com/oauth2/v3/userinfo. We don't need the + // profile β€” we only care about capturing the refresh token β€” and + // hitting the real userinfo endpoint with a stub access token would + // fail with "Invalid Credentials". + skipUserProfile: true, + // @types/passport-oauth2's `StateStore` interface only declares the + // non-PKCE 2-/3-arg `store` and 3-arg `verify` overloads (see + // node_modules/@types/passport-oauth2/index.d.ts:37-43). Our + // PKCE-flavored store uses the 5-arg `store` and 4-arg `verify` + // shapes that passport-oauth2 selects at runtime via + // `Function.length` inspection (see + // node_modules/passport-oauth2/lib/strategy.js:218-298). There is no + // strict-typed path until DefinitelyTyped adds the PKCE overloads; + // the cast preserves runtime correctness and is intentionally + // narrowed to this single field so the rest of the options remain + // strictly typed. + store: params.store as never, + passReqToCallback: false as const, + ...(params.authorizationURL ? { authorizationURL: params.authorizationURL } : {}), + ...(params.tokenURL ? { tokenURL: params.tokenURL } : {}) + }; + + const verify = ( + _accessToken: string, + refreshToken: string, + _profile: GoogleProfile, + done: VerifyCallback + ): void => { + if (!refreshToken) { + const err = new Error( + 'No refresh token returned. Revoke the app at myaccount.google.com/permissions and try again.' + ); + rejectCompletion(err); + done(err); + return; + } + resolveCompletion({ refreshToken }); + // Pass a truthy `user` so passport considers the authentication + // successful and renders the configured /auth/callback response. + done(null, { refreshToken } as unknown as Express.User); + }; + + const strategy = new GoogleStrategy(strategyOptions, verify); + + return { strategy, completion }; +} + +export { GoogleStrategy }; diff --git a/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts new file mode 100644 index 0000000000..ebe496e4a9 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts @@ -0,0 +1,282 @@ +import { assert } from 'chai'; + +import { + buildBigQueryGoogleOAuthStrategy, + createInMemoryPkceStore, + GOOGLE_BIGQUERY_SCOPES, + GOOGLE_TOKEN_URL +} from './googleOAuthProvider.node'; + +suite('googleOAuthProvider', () => { + suite('GOOGLE_BIGQUERY_SCOPES', () => { + test('exposes email, profile, and the bigquery scope (no openid)', () => { + assert.deepStrictEqual( + [...GOOGLE_BIGQUERY_SCOPES], + ['email', 'profile', 'https://www.googleapis.com/auth/bigquery'] + ); + }); + + test('does not include openid', () => { + // Plan invariant (Step 5): production at + // /workspace/deepnote-internal/apps/webapp/server/modules/federated-integration-auth/handlers.ts:77 + // omits 'openid'. Refresh tokens come from access_type=offline + + // prompt=consent, not from the OpenID Connect flow. + assert.notInclude([...GOOGLE_BIGQUERY_SCOPES], 'openid'); + }); + }); + + suite('GOOGLE_TOKEN_URL', () => { + test('points at the production Google token endpoint', () => { + assert.strictEqual(GOOGLE_TOKEN_URL, 'https://oauth2.googleapis.com/token'); + }); + }); + + suite('createInMemoryPkceStore', () => { + test('store + verify round-trips the code verifier', () => { + const store = createInMemoryPkceStore(); + const verifier = 'random-verifier-12345'; + + let issuedState: string | undefined; + store.store(undefined, verifier, undefined, undefined, (err, state) => { + assert.isNull(err); + assert.isString(state); + issuedState = state; + }); + + assert.isDefined(issuedState); + + let verifyResult: { ok: string | false; info: unknown } | undefined; + store.verify(undefined, issuedState!, undefined, (err, ok, info) => { + assert.isNull(err); + verifyResult = { ok, info }; + }); + + assert.isDefined(verifyResult); + // For PKCE, the `ok` slot must hold the codeVerifier string so + // passport-oauth2 forwards it as `code_verifier` on the token + // request (see strategy.js:171-173). + assert.strictEqual(verifyResult!.ok, verifier); + }); + + test('store generates a non-empty, URL-safe state', () => { + const store = createInMemoryPkceStore(); + let issuedState: string | undefined; + store.store(undefined, 'v', undefined, undefined, (_err, state) => { + issuedState = state; + }); + assert.isString(issuedState); + assert.isAbove(issuedState!.length, 0); + // base64url alphabet: A-Z, a-z, 0-9, -, _ (no padding). + assert.match(issuedState!, /^[A-Za-z0-9_-]+$/); + }); + + test('store generates distinct states across calls', () => { + const store = createInMemoryPkceStore(); + const states: string[] = []; + for (let i = 0; i < 5; i++) { + store.store(undefined, `v-${i}`, undefined, undefined, (_err, state) => { + states.push(state!); + }); + } + assert.strictEqual(new Set(states).size, 5); + }); + + test('verify with unknown state returns (null, false, info)', () => { + const store = createInMemoryPkceStore(); + let result: { ok: string | false; info: unknown } | undefined; + store.verify(undefined, 'never-issued', undefined, (err, ok, info) => { + assert.isNull(err); + result = { ok, info }; + }); + assert.isDefined(result); + assert.strictEqual(result!.ok, false); + assert.isDefined(result!.info); + }); + + test('verify deletes the entry (single-use)', () => { + const store = createInMemoryPkceStore(); + let issuedState!: string; + store.store(undefined, 'verifier', undefined, undefined, (_err, state) => { + issuedState = state!; + }); + + // First verify: succeeds. + let firstResult: string | false | undefined; + store.verify(undefined, issuedState, undefined, (_err, ok) => { + firstResult = ok; + }); + assert.strictEqual(firstResult, 'verifier'); + + // Second verify with the same state: must fail (entry was deleted). + let secondResult: string | false | undefined; + store.verify(undefined, issuedState, undefined, (_err, ok) => { + secondResult = ok; + }); + assert.strictEqual(secondResult, false); + }); + + test('store and verify both accept undefined req (no req.session needed)', () => { + // Plan invariant (Step 5): the custom store must work without + // express-session. We pass undefined for req and assert no throw. + const store = createInMemoryPkceStore(); + let issuedState!: string; + assert.doesNotThrow(() => { + store.store(undefined, 'v', undefined, undefined, (_err, state) => { + issuedState = state!; + }); + }); + assert.doesNotThrow(() => { + store.verify(undefined, issuedState, undefined, () => { + // no-op + }); + }); + }); + + test('isolated stores do not share state', () => { + const a = createInMemoryPkceStore(); + const b = createInMemoryPkceStore(); + let stateA!: string; + a.store(undefined, 'va', undefined, undefined, (_err, state) => { + stateA = state!; + }); + // Verify stateA against the *other* store: must fail. + let result: string | false | undefined; + b.verify(undefined, stateA, undefined, (_err, ok) => { + result = ok; + }); + assert.strictEqual(result, false); + }); + }); + + suite('buildBigQueryGoogleOAuthStrategy', () => { + test('returns a strategy and a completion promise', () => { + const store = createInMemoryPkceStore(); + const result = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store + }); + + assert.isObject(result.strategy); + assert.instanceOf(result.completion, Promise); + }); + + test('strategy.name is "google" (the passport-google-oauth20 default)', () => { + const store = createInMemoryPkceStore(); + const result = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store + }); + + assert.strictEqual(result.strategy.name, 'google'); + }); + + test('uses the GOOGLE_BIGQUERY_SCOPES on the strategy', () => { + const store = createInMemoryPkceStore(); + const result = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store + }); + + // `_scope` is a protected field on OAuth2Strategy; passport-oauth2 + // sets it from options.scope. We probe it to assert wiring. + const scope = (result.strategy as unknown as { _scope: string[] })._scope; + assert.deepStrictEqual(scope, [...GOOGLE_BIGQUERY_SCOPES]); + }); + + test('verify resolves the completion promise on a non-empty refresh token', async () => { + const store = createInMemoryPkceStore(); + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store + }); + + // `_verify` is the verify callback stored by passport-oauth2 (see + // passport-oauth2/lib/strategy.js around line 70). + const verify = (strategy as unknown as { _verify: Function })._verify; + + verify( + 'access-token', + 'refresh-token-value', + { id: 'user-1', provider: 'google' }, + (_err: unknown, user: unknown) => { + assert.deepStrictEqual(user, { refreshToken: 'refresh-token-value' }); + } + ); + + const result = await completion; + assert.deepStrictEqual(result, { refreshToken: 'refresh-token-value' }); + }); + + test('verify rejects the completion promise on an empty refresh token', async () => { + const store = createInMemoryPkceStore(); + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store + }); + + const verify = (strategy as unknown as { _verify: Function })._verify; + verify('access-token', '', { id: 'u', provider: 'google' }, () => { + // done() is called with the error β€” we ignore here. + }); + + try { + await completion; + assert.fail('expected rejection'); + } catch (err) { + assert.instanceOf(err, Error); + assert.include((err as Error).message, 'No refresh token returned'); + assert.include((err as Error).message, 'myaccount.google.com/permissions'); + } + }); + + test('authorizationURL override lands on the strategy', () => { + const store = createInMemoryPkceStore(); + const { strategy } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store, + authorizationURL: 'http://stub/oauth/authorize' + }); + + // passport-oauth2 stores the URL on the embedded `_oauth2` helper. + const oauth2 = (strategy as unknown as { _oauth2: { _authorizeUrl: string } })._oauth2; + assert.strictEqual(oauth2._authorizeUrl, 'http://stub/oauth/authorize'); + }); + + test('tokenURL override lands on the strategy', () => { + const store = createInMemoryPkceStore(); + const { strategy } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store, + tokenURL: 'http://stub/oauth/token' + }); + + const oauth2 = (strategy as unknown as { _oauth2: { _accessTokenUrl: string } })._oauth2; + assert.strictEqual(oauth2._accessTokenUrl, 'http://stub/oauth/token'); + }); + + test('without overrides, the strategy uses Google production URLs', () => { + const store = createInMemoryPkceStore(); + const { strategy } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store + }); + + const oauth2 = ( + strategy as unknown as { + _oauth2: { _authorizeUrl: string; _accessTokenUrl: string }; + } + )._oauth2; + // passport-google-oauth20/lib/strategy.js:49-50. + assert.strictEqual(oauth2._authorizeUrl, 'https://accounts.google.com/o/oauth2/v2/auth'); + assert.strictEqual(oauth2._accessTokenUrl, 'https://www.googleapis.com/oauth2/v4/token'); + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts new file mode 100644 index 0000000000..e06434a85a --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts @@ -0,0 +1,302 @@ +import * as crypto from 'crypto'; +import express, { type Express, type Request, type Response } from 'express'; +import * as http from 'http'; +import passport from 'passport'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { type AddressInfo } from 'net'; +import { CancellationError, CancellationToken } from 'vscode'; + +import { logger } from '../../../../platform/logging'; + +/** + * Default deadline for the entire OAuth flow, measured from the moment the + * loopback server starts listening. The user has 5 minutes to complete the + * browser consent flow; after that the loopback server is torn down and the + * promise rejects with a timeout error. + * + * Long enough to accommodate Google account-switcher interactions on a + * mobile device; short enough that a forgotten flow doesn't tie up a port + * indefinitely. + */ +export const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Inputs for {@link runOAuthFlow}. + * + * The strategy + completion pair must come from + * {@link buildBigQueryGoogleOAuthStrategy} (or an equivalent builder that + * captures the refresh token via its internal verify closure). We do not + * accept a separate verify here β€” production review found that splitting + * the verify across the call site and the builder made it too easy to + * forget wiring it (#6 in the plan). + * + * `onListening` is invoked once with the user-facing start URL after the + * loopback server has bound a random port. The caller is expected to + * launch the user's browser at that URL (typically via + * `vscode.env.openExternal(env.asExternalUri(...))`). + * + * `timeoutMs` overrides {@link OAUTH_FLOW_TIMEOUT_MS}. It exists primarily + * as a test seam β€” production callers should leave it undefined. + */ +export interface RunOAuthFlowParams { + completion: Promise<{ refreshToken: string }>; + integrationId: string; + onListening: (startUrl: string) => Promise; + strategy: GoogleStrategy; + timeoutMs?: number; + token: CancellationToken; +} + +/** + * Runs the loopback OAuth flow. Returns when the user completes consent + * (resolves with the captured refresh token) or rejects on cancellation, + * timeout, or an OAuth error. + * + * Implementation outline (matches plan Step 5): + * - Creates an `express` app, mounts /auth/start + /auth/callback. + * - Boots an `http.createServer` on `0.0.0.0`-style ephemeral port via + * `listen(0, '127.0.0.1')`. Once listening, computes the callback URL + * using the bound port and patches it onto the strategy. + * - Registers the strategy under a per-flow random name so concurrent + * flows can coexist. + * - Invokes `params.onListening(startUrl)` so the caller can launch the + * user's browser. + * - Awaits whichever resolves first: the verify-driven `completion`, + * cancellation, or the timeout. + * - Cleans up unconditionally: closes the server, removes the strategy + * from passport, and clears the timeout. + */ +export async function runOAuthFlow(params: RunOAuthFlowParams): Promise<{ refreshToken: string }> { + const strategyName = `deepnote-google-oauth-${crypto.randomBytes(8).toString('hex')}`; + const timeoutMs = params.timeoutMs ?? OAUTH_FLOW_TIMEOUT_MS; + + const app: Express = express(); + const server = http.createServer(app); + + let cancellationSubscription: { dispose(): void } | undefined; + let timeoutHandle: NodeJS.Timeout | undefined; + + try { + passport.use(strategyName, params.strategy); + + // Bind to 127.0.0.1 only β€” Google's "Desktop app" OAuth clients accept + // loopback redirects only on the loopback interface. + server.listen(0, '127.0.0.1'); + + const listening = new Promise((resolve, reject) => { + // Forward-declared as `let` so each handler can reference the other + // for `removeListener`. We could nest them but the lint rule for + // use-before-define would catch either ordering. + let onError: (err: Error) => void = () => undefined; + let onListening: () => void = () => undefined; + onError = (err: Error) => { + server.removeListener('listening', onListening); + reject(err); + }; + onListening = () => { + server.removeListener('error', onError); + const address = server.address() as AddressInfo | null; + if (!address || typeof address === 'string') { + reject(new Error('Loopback server did not bind a port.')); + return; + } + resolve(address.port); + }; + server.once('error', onError); + server.once('listening', onListening); + }); + + const port = await listening; + const callbackURL = `http://127.0.0.1:${port}/auth/callback`; + const startUrl = `http://127.0.0.1:${port}/auth/start`; + + // Override the placeholder `callbackURL` baked into the strategy. The + // strategy uses the configured `callbackURL` when generating the + // authorize redirect AND when exchanging the code at the token + // endpoint (redirect_uri must match). Mutating this field is the + // upstream-supported way of patching the strategy after listen(0). + (params.strategy as unknown as { _callbackURL: string })._callbackURL = callbackURL; + + // Routes β€” must be registered after listen(), but before + // onListening() returns control to the caller (otherwise the user's + // browser races us). + // + // /auth/start kicks off the authorize redirect with the + // Google-specific accessType=offline + prompt=consent options so we + // get a refresh token even if the user has previously authorized + // this OAuth client. passport-google-oauth20 forwards these into + // the authorize URL natively (see strategy.js:138-143). + app.get( + '/auth/start', + passport.authenticate(strategyName, { + session: false, + accessType: 'offline', + prompt: 'consent' + } as Parameters[1]) + ); + + // /auth/callback runs the verify closure built by + // buildBigQueryGoogleOAuthStrategy. The closure resolves the + // `completion` promise on success and rejects on missing refresh + // token. We render a success page on success and an error page on + // failure β€” passport invokes the express error middleware on + // failures, so we attach one below. + app.get( + '/auth/callback', + passport.authenticate(strategyName, { session: false } as Parameters[1]), + (_req: Request, res: Response) => { + res.status(200).send(renderSuccessPage()); + } + ); + + // Express error handler β€” any error thrown by the passport + // middleware or our routes lands here. We render an inline HTML + // error page with the message so the user sees something + // intelligible in their browser. The promise rejection comes from + // the verify closure separately. + app.use((err: unknown, _req: Request, res: Response, _next: unknown) => { + const message = err instanceof Error ? err.message : 'Authentication failed.'; + logger.error('OAuth loopback flow rendered error page.', err); + res.status(400).send(renderErrorPage(message)); + }); + + // Set up cancellation + timeout BEFORE invoking onListening, so a + // synchronous-or-fast cancellation inside the caller's onListening + // handler is observed. (We can't rely on the token's listener + // catching up after the fact β€” VSCode's EventEmitter does not + // replay past fires to late subscribers.) + const cancellationPromise = new Promise((_, reject) => { + if (params.token.isCancellationRequested) { + reject(new CancellationError()); + return; + } + cancellationSubscription = params.token.onCancellationRequested(() => { + reject(new CancellationError()); + }); + }); + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`OAuth flow timed out after ${Math.round(timeoutMs / 1000)} seconds.`)); + }, timeoutMs); + }); + + await params.onListening(startUrl); + + const result = await Promise.race<{ refreshToken: string }>([ + params.completion, + timeoutPromise, + cancellationPromise + ]); + + return result; + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + if (cancellationSubscription !== undefined) { + cancellationSubscription.dispose(); + } + // `closeAllConnections` is required when long-poll/streaming clients + // are connected β€” passport's redirect flow uses short-lived + // connections in practice, but if the user closes the browser tab + // mid-flow, this prevents the server from hanging on a half-open + // TCP connection. + if (typeof (server as { closeAllConnections?: () => void }).closeAllConnections === 'function') { + (server as { closeAllConnections: () => void }).closeAllConnections(); + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + passport.unuse(strategyName); + } +} + +/** + * Inline-CSS success page rendered to the user's browser after consent. + * The user can close the tab and return to VS Code. + */ +function renderSuccessPage(): string { + return ` + + + + Authentication succeeded + + + +
+

Authentication succeeded.

+

You can close this window and return to VS Code.

+
+ +`; +} + +/** + * Inline-CSS error page rendered to the user's browser when the OAuth + * flow fails β€” exposes the underlying message so the user can act on it + * (e.g. "Revoke the app at myaccount.google.com/permissions and try + * again."). + */ +function renderErrorPage(message: string): string { + const safeMessage = String(message) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + return ` + + + + Authentication failed + + + +
+

Authentication failed.

+

${safeMessage}

+
+ +`; +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts new file mode 100644 index 0000000000..b6603cee7e --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts @@ -0,0 +1,472 @@ +import { assert } from 'chai'; +import * as crypto from 'crypto'; +import * as http from 'http'; +import { type AddressInfo } from 'net'; +import { CancellationError, CancellationTokenSource } from 'vscode'; + +import { buildBigQueryGoogleOAuthStrategy, createInMemoryPkceStore } from './googleOAuthProvider.node'; +import { OAUTH_FLOW_TIMEOUT_MS, runOAuthFlow } from './oauthLoopbackFlow.node'; + +/** + * Stub OAuth provider used by the loopback-flow tests. Exposes + * `/oauth/authorize` and `/oauth/token` endpoints that mimic Google's + * production endpoints enough for `passport-google-oauth20` to drive a + * full flow. + * + * Configurable per-test via `setBehavior`: + * - `failTokenWithoutRefresh`: token endpoint omits `refresh_token`, + * triggering the "no refresh token" rejection in + * `buildBigQueryGoogleOAuthStrategy`'s verify. + * - `tokenStatus` / `tokenError`: simulate non-2xx token responses. + * + * Captures the authorize query (so tests can assert PKCE / scope / + * access_type) and the token form (so tests can assert `code_verifier`). + */ +interface StubBehavior { + failTokenWithoutRefresh?: boolean; + tokenError?: { error: string; status: number }; +} + +interface StubCapture { + authorizeQuery?: URLSearchParams; + tokenForm?: URLSearchParams; +} + +class StubOAuthProvider { + public readonly capture: StubCapture = {}; + + private behavior: StubBehavior = {}; + + private codeForVerifier = new Map(); // issued code -> code_challenge + + private server: http.Server; + + public get authorizeURL(): string { + return `${this.baseURL}/oauth/authorize`; + } + + public get baseURL(): string { + const address = this.server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; + } + + public get tokenURL(): string { + return `${this.baseURL}/oauth/token`; + } + + public constructor() { + this.server = http.createServer((req, res) => this.handle(req, res)); + } + + public async close(): Promise { + await new Promise((resolve) => { + this.server.close(() => resolve()); + }); + } + + public async listen(): Promise { + await new Promise((resolve) => { + this.server.listen(0, '127.0.0.1', () => resolve()); + }); + } + + public setBehavior(behavior: StubBehavior): void { + this.behavior = behavior; + } + + private handle(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url ?? '/', this.baseURL); + if (url.pathname === '/oauth/authorize' && req.method === 'GET') { + this.handleAuthorize(url, res); + return; + } + if (url.pathname === '/oauth/token' && req.method === 'POST') { + this.handleToken(req, res); + return; + } + res.statusCode = 404; + res.end('not found'); + } + + private handleAuthorize(url: URL, res: http.ServerResponse): void { + this.capture.authorizeQuery = url.searchParams; + + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + const state = url.searchParams.get('state') ?? ''; + const codeChallenge = url.searchParams.get('code_challenge') ?? ''; + + const code = crypto.randomBytes(16).toString('hex'); + this.codeForVerifier.set(code, codeChallenge); + + const callback = new URL(redirectUri); + callback.searchParams.set('code', code); + callback.searchParams.set('state', state); + res.statusCode = 302; + res.setHeader('Location', callback.toString()); + res.end(); + } + + private handleToken(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString('utf8'); + }); + req.on('end', () => { + const form = new URLSearchParams(body); + this.capture.tokenForm = form; + + if (this.behavior.tokenError) { + res.statusCode = this.behavior.tokenError.status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: this.behavior.tokenError.error })); + return; + } + + // Validate the PKCE verifier matches the challenge issued at /authorize. + const code = form.get('code') ?? ''; + const verifier = form.get('code_verifier') ?? ''; + const expectedChallenge = this.codeForVerifier.get(code); + const actualChallenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + if (expectedChallenge !== actualChallenge) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'invalid_grant', detail: 'PKCE verifier mismatch' })); + return; + } + + const responseBody: Record = { + access_token: 'stub-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'email profile https://www.googleapis.com/auth/bigquery' + }; + if (!this.behavior.failTokenWithoutRefresh) { + responseBody.refresh_token = 'test-refresh-token'; + } + + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(responseBody)); + }); + } +} + +suite('oauthLoopbackFlow', () => { + let stub: StubOAuthProvider; + + setup(async () => { + stub = new StubOAuthProvider(); + await stub.listen(); + }); + + teardown(async () => { + await stub.close(); + }); + + /** + * Helper: drive the full loopback flow end-to-end against the stub + * provider. The `onListening` callback hits the loopback /auth/start + * URL with `redirect: 'manual'`, follows the redirect to the stub's + * /oauth/authorize (which redirects back to the loopback /auth/callback + * with `code` + `state`), and asserts the relevant invariants along + * the way. + */ + async function drive(opts: { + token?: CancellationTokenSource; + timeoutMs?: number; + capturedQueries?: (q: URLSearchParams) => void; + }): Promise<{ + refreshToken: string; + }> { + const tokenSource = opts.token ?? new CancellationTokenSource(); + try { + const pkceStore = createInMemoryPkceStore(); + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'stub-client-id', + clientSecret: 'stub-client-secret', + store: pkceStore, + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + return await runOAuthFlow({ + integrationId: 'integration-1', + strategy, + completion, + token: tokenSource.token, + timeoutMs: opts.timeoutMs, + onListening: async (startUrl) => { + // Hit /auth/start to get the redirect to the stub's authorize endpoint. + const startResponse = await fetch(startUrl, { redirect: 'manual' }); + assert.isAtLeast(startResponse.status, 300, 'expected redirect from /auth/start'); + assert.isBelow(startResponse.status, 400); + const authorizeLocation = startResponse.headers.get('location'); + assert.isString(authorizeLocation); + + if (opts.capturedQueries) { + const parsed = new URL(authorizeLocation!); + opts.capturedQueries(parsed.searchParams); + } + + // Follow the authorize redirect (stub returns a redirect to /auth/callback). + const authorizeResponse = await fetch(authorizeLocation!, { redirect: 'manual' }); + assert.isAtLeast(authorizeResponse.status, 300); + assert.isBelow(authorizeResponse.status, 400); + const callbackLocation = authorizeResponse.headers.get('location'); + assert.isString(callbackLocation); + + // Hit /auth/callback to drive the verify closure. Either 200 + // (success page) or 400 (error page) — the completion promise + // carries the outcome. + const callbackResponse = await fetch(callbackLocation!); + await callbackResponse.text(); + } + }); + } finally { + tokenSource.dispose(); + } + } + + test('end-to-end happy path resolves with the stub refresh token', async () => { + const result = await drive({}); + assert.deepStrictEqual(result, { refreshToken: 'test-refresh-token' }); + }); + + test('authorize redirect carries access_type=offline, prompt=consent, code_challenge, S256, scope, and state', async () => { + let queries: URLSearchParams | undefined; + await drive({ + capturedQueries: (q) => { + queries = q; + } + }); + assert.isDefined(queries); + assert.strictEqual(queries!.get('access_type'), 'offline'); + assert.strictEqual(queries!.get('prompt'), 'consent'); + assert.isString(queries!.get('state')); + assert.isAbove(queries!.get('state')!.length, 0); + assert.isString(queries!.get('code_challenge')); + assert.isAbove(queries!.get('code_challenge')!.length, 0); + assert.strictEqual(queries!.get('code_challenge_method'), 'S256'); + assert.include(queries!.get('scope') ?? '', 'https://www.googleapis.com/auth/bigquery'); + }); + + test('token endpoint receives the matching code_verifier', async () => { + await drive({}); + const form = stub.capture.tokenForm; + assert.isDefined(form); + const verifier = form!.get('code_verifier'); + assert.isString(verifier); + assert.isAbove(verifier!.length, 0); + // The stub already validated verifier→challenge inside handleToken; + // if we made it here, the verifier matched the issued challenge. + }); + + test('two concurrent flows pick different ports', async () => { + // Two flows running in parallel must bind distinct ephemeral ports. + const observedPorts = new Set(); + const tokenA = new CancellationTokenSource(); + const tokenB = new CancellationTokenSource(); + try { + const pkceA = createInMemoryPkceStore(); + const pkceB = createInMemoryPkceStore(); + const { strategy: sA, completion: cA } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'c', + clientSecret: 's', + store: pkceA, + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + const { strategy: sB, completion: cB } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'c', + clientSecret: 's', + store: pkceB, + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + const driveOne = (strategy: typeof sA, completion: typeof cA, token: CancellationTokenSource) => + runOAuthFlow({ + integrationId: 'i', + strategy, + completion, + token: token.token, + onListening: async (startUrl) => { + const url = new URL(startUrl); + observedPorts.add(parseInt(url.port, 10)); + const startResponse = await fetch(startUrl, { redirect: 'manual' }); + const authorizeLocation = startResponse.headers.get('location')!; + const authorizeResponse = await fetch(authorizeLocation, { redirect: 'manual' }); + const callbackLocation = authorizeResponse.headers.get('location')!; + await fetch(callbackLocation); + } + }); + + await Promise.all([driveOne(sA, cA, tokenA), driveOne(sB, cB, tokenB)]); + + assert.strictEqual(observedPorts.size, 2, 'concurrent flows should bind distinct ports'); + } finally { + tokenA.dispose(); + tokenB.dispose(); + } + }); + + test('cancellation rejects with CancellationError and closes the server', async () => { + const tokenSource = new CancellationTokenSource(); + try { + const pkceStore = createInMemoryPkceStore(); + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'c', + clientSecret: 's', + store: pkceStore, + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + let observedStartUrl!: string; + + const flowPromise = runOAuthFlow({ + integrationId: 'i', + strategy, + completion, + token: tokenSource.token, + onListening: async (startUrl) => { + observedStartUrl = startUrl; + // Cancel after the server is up but before the user + // completes the flow. + tokenSource.cancel(); + } + }); + + try { + await flowPromise; + assert.fail('expected rejection'); + } catch (err) { + assert.instanceOf(err, CancellationError); + } + + // Server should be torn down — a fetch attempt should fail. + try { + await fetch(observedStartUrl); + assert.fail('expected fetch to fail against a closed server'); + } catch (err) { + assert.instanceOf(err, Error); + } + } finally { + tokenSource.dispose(); + } + }); + + test('timeout rejects with a timeout error', async () => { + const tokenSource = new CancellationTokenSource(); + try { + const pkceStore = createInMemoryPkceStore(); + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'c', + clientSecret: 's', + store: pkceStore, + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + const flowPromise = runOAuthFlow({ + integrationId: 'i', + strategy, + completion, + token: tokenSource.token, + timeoutMs: 100, + onListening: async () => { + // Do nothing — let the flow hit the timeout. + } + }); + + try { + await flowPromise; + assert.fail('expected rejection'); + } catch (err) { + assert.instanceOf(err, Error); + assert.match((err as Error).message, /timed out/i); + } + } finally { + tokenSource.dispose(); + } + }); + + test('missing refresh token rejects with the documented message', async () => { + stub.setBehavior({ failTokenWithoutRefresh: true }); + + try { + await drive({}); + assert.fail('expected rejection'); + } catch (err) { + assert.instanceOf(err, Error); + assert.include((err as Error).message, 'No refresh token returned'); + assert.include((err as Error).message, 'myaccount.google.com/permissions'); + } + }); + + test('missing refresh token: callback page renders the documented error', async () => { + // Catches: passport routing failures yield an unfriendly browser page + // even though the completion promise carries the right message. We + // assert on the HTTP status + HTML body the user actually sees. + stub.setBehavior({ failTokenWithoutRefresh: true }); + + let callbackBody: string | undefined; + let callbackStatus: number | undefined; + const tokenSource = new CancellationTokenSource(); + try { + const pkceStore = createInMemoryPkceStore(); + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId: 'c', + clientSecret: 's', + store: pkceStore, + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + const promise = runOAuthFlow({ + integrationId: 'i', + strategy, + completion, + token: tokenSource.token, + onListening: async (startUrl) => { + const startResponse = await fetch(startUrl, { redirect: 'manual' }); + const authorizeLocation = startResponse.headers.get('location'); + assert.isString(authorizeLocation); + const authorizeResponse = await fetch(authorizeLocation!, { redirect: 'manual' }); + const callbackLocation = authorizeResponse.headers.get('location'); + assert.isString(callbackLocation); + const callbackResponse = await fetch(callbackLocation!); + callbackStatus = callbackResponse.status; + callbackBody = await callbackResponse.text(); + } + }); + + try { + await promise; + assert.fail('expected rejection'); + } catch { + // Inspect the captured body below — the runOAuthFlow rejection + // is asserted elsewhere ("missing refresh token rejects with + // the documented message"). + } + assert.strictEqual(callbackStatus, 400, 'callback should render the error page status'); + assert.isString(callbackBody); + assert.include(callbackBody!, 'No refresh token returned'); + assert.include(callbackBody!, 'myaccount.google.com/permissions'); + } finally { + tokenSource.dispose(); + } + }); + + test('flow completes without express-session middleware (custom PKCE store works)', async () => { + // The runOAuthFlow factory does not mount express-session anywhere — + // a successful completion is itself proof that the custom PKCE store + // handled the verifier round-trip without req.session. + const result = await drive({}); + assert.strictEqual(result.refreshToken, 'test-refresh-token'); + }); + + test('OAUTH_FLOW_TIMEOUT_MS is 5 minutes', () => { + assert.strictEqual(OAUTH_FLOW_TIMEOUT_MS, 5 * 60 * 1000); + }); +}); From de1076b6924d5b71b50df7cca10e18dc40a1a1ec Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 21 May 2026 19:30:25 +0000 Subject: [PATCH 03/13] feat(integrations): inject fresh BigQuery OAuth credentials per SQL execution (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each federated SQL cell now runs through a code generator that mints a fresh access token from Google before execution, defines it in a kernel-side variable via a silent prelude (store_history disabled), and references that variable from the cell's main Python — so the access token never lands in Jupyter's In[] history. Plumbs the generator through KernelProvider → NotebookKernelExecution → CellExecutionFactory → CellExecution as an optional dep so the web build degrades gracefully. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cellExecution.federatedAuth.unit.test.ts | 415 ++++++++++++++++ src/kernels/execution/cellExecution.ts | 115 ++++- src/kernels/kernelExecution.ts | 20 +- src/kernels/kernelProvider.node.ts | 16 +- ...federatedAuthSqlBlockCodeGenerator.node.ts | 253 ++++++++++ ...uthSqlBlockCodeGenerator.node.unit.test.ts | 443 ++++++++++++++++++ src/notebooks/serviceRegistry.node.ts | 9 + 7 files changed, 1259 insertions(+), 12 deletions(-) create mode 100644 src/kernels/execution/cellExecution.federatedAuth.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts diff --git a/src/kernels/execution/cellExecution.federatedAuth.unit.test.ts b/src/kernels/execution/cellExecution.federatedAuth.unit.test.ts new file mode 100644 index 0000000000..70e76822e4 --- /dev/null +++ b/src/kernels/execution/cellExecution.federatedAuth.unit.test.ts @@ -0,0 +1,415 @@ +// Unit tests for the federated-auth branch of `CellExecution.execute()`. +// +// The full `CellExecution` orchestration depends on a number of VS Code +// globals (`workspace.onDidCloseTextDocument`, the kernel controller's +// `createNotebookCellExecution`, etc.). These tests focus exclusively on +// the federated branch and stub the surrounding machinery just enough to +// drive `start()` to completion. Deviation from existing test patterns: +// no `fakeKernelConnection.node`-style end-to-end socket simulation — +// instead we capture `requestExecute` calls on a Sinon stub. Documented +// in the test file header so the next agent knows the shape. + +import type { Kernel, KernelMessage } from '@jupyterlab/services'; +import type { IKernelConnection } from '@jupyterlab/services/lib/kernel/kernel'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { NotebookCell, NotebookCellKind, Uri } from 'vscode'; + +import { CancellationTokenSource } from 'vscode'; +import { dispose } from '../../platform/common/utils/lifecycle'; +import { createDeferred, Deferred } from '../../platform/common/utils/async'; +import { IDisposable } from '../../platform/common/types'; +import { + IFederatedAuthSqlBlockCodeGenerator, + NotAuthenticatedError +} from '../../notebooks/deepnote/integrations/types'; +import { IKernelController, IKernelSession, KernelConnectionMetadata } from '../types'; +import { createKernelController } from '../../test/datascience/notebook/executionHelper'; +import { CellExecution, CellExecutionFactory } from './cellExecution'; +import { CellExecutionMessageHandlerService } from './cellExecutionMessageHandlerService'; + +suite('CellExecution federated-auth branch', () => { + let disposables: IDisposable[] = []; + let controller: IKernelController; + let requestListener: CellExecutionMessageHandlerService; + let session: IKernelSession; + let kernel: IKernelConnection; + let request: Kernel.IShellFuture; + let requestDone: Deferred; + let preludeRequest: Kernel.IShellFuture; + let preludeDone: Deferred; + let requestExecuteSpy: sinon.SinonSpy; + let tokenSource: CancellationTokenSource; + let connectionMetadata: KernelConnectionMetadata; + let cell: NotebookCell; + + const successReply: KernelMessage.IExecuteReplyMsg = { + channel: 'shell', + content: { + execution_count: 1, + status: 'ok', + user_expressions: {} + }, + header: { + msg_id: '1', + msg_type: 'execute_reply', + session: '1', + username: '1', + date: new Date().toString(), + version: '5.0' + } as KernelMessage.IExecuteReplyMsg['header'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parent_header: {} as any + }; + + /** + * Construct a minimal mocked NotebookCell whose `index`, `document`, + * `notebook`, `kind`, `metadata`, and `outputs` are all populated. + * `CellExecution`'s constructor + execute method touch all of these. + */ + function buildCell(opts: { + content: string; + languageId?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record; + }): NotebookCell { + const document = { + getText: () => opts.content, + languageId: opts.languageId ?? 'sql', + isClosed: false, + uri: Uri.parse(`untitled:test-cell-${Math.random()}.py`) + }; + const notebook = { + isClosed: false, + uri: Uri.parse('untitled:test-notebook.deepnote') + }; + return { + index: 0, + kind: NotebookCellKind.Code, + document, + notebook, + metadata: opts.metadata ?? {}, + outputs: [], + executionSummary: undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as NotebookCell; + } + + setup(() => { + disposables = []; + tokenSource = new CancellationTokenSource(); + disposables.push(tokenSource); + + controller = createKernelController(); + // Minimal stub of CellExecutionMessageHandlerService — the + // federated branch issues its silent pre-execute *before* the + // main `requestExecute`, so the listener is only registered for + // the main execute (which we let succeed without messages). + requestListener = { + registerListenerForExecution: () => + ({ + onErrorHandlingExecuteRequestIOPubMessage: () => ({ dispose: () => undefined }), + completed: Promise.resolve(), + dispose: () => undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + registerListenerForResumingExecution: () => + ({ + onErrorHandlingExecuteRequestIOPubMessage: () => ({ dispose: () => undefined }), + completed: Promise.resolve(), + dispose: () => undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + dispose: () => undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as CellExecutionMessageHandlerService; + + session = mock(); + kernel = mock(); + request = mock>(); + preludeRequest = mock>(); + requestDone = createDeferred(); + preludeDone = createDeferred(); + + when(request.dispose()).thenReturn(); + when(request.done).thenReturn(requestDone.promise); + when(preludeRequest.dispose()).thenReturn(); + when(preludeRequest.done).thenReturn(preludeDone.promise); + + when(session.kernel).thenReturn(instance(kernel)); + when(session.isDisposed).thenReturn(false); + when(session.kind).thenReturn('localRaw'); + when(session.status).thenReturn('idle'); + when(kernel.isDisposed).thenReturn(false); + + // The federated branch invokes `requestExecute(args, true, undefined)` (dispose=true) for + // the silent prelude; the main execute is `requestExecute(args, false, metadata)`. + // Differentiate by the 2nd positional argument so order can be asserted. + requestExecuteSpy = sinon.spy( + (_args: KernelMessage.IExecuteRequestMsg['content'], disposeOnDone: boolean, _metadata: unknown) => { + return disposeOnDone ? instance(preludeRequest) : instance(request); + } + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (when(kernel.requestExecute(anything(), anything(), anything())) as any).thenCall(requestExecuteSpy); + // Allow the *main* execute to complete immediately so `result` + // resolves; the *prelude* deferred is intentionally left pending + // here so individual tests can drive its resolution (or rejection) + // explicitly. This is what lets us detect a missing `await` on the + // prelude `.done` — see "main requestExecute waits for prelude .done" + // below. + requestDone.resolve(successReply); + + connectionMetadata = { + id: 'test-kernel', + kind: 'startUsingLocalKernelSpec', + interpreter: undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as KernelConnectionMetadata; + + cell = buildCell({ content: 'SELECT 1', languageId: 'sql' }); + }); + + teardown(() => { + disposables = dispose(disposables); + }); + + function createExecution(generator?: IFederatedAuthSqlBlockCodeGenerator) { + const factory = new CellExecutionFactory(controller, requestListener, generator); + const execution = factory.create(cell, undefined, connectionMetadata) as CellExecution; + disposables.push(execution); + return execution; + } + + test('when generator is undefined (web): never calls generate, single requestExecute', async () => { + const execution = createExecution(undefined); + await execution.start(instance(session)); + await execution.result.catch(() => undefined); + + // Exactly one requestExecute call (the main one with store_history: true). + const calls = requestExecuteSpy.getCalls(); + assert.strictEqual(calls.length, 1, `expected exactly 1 requestExecute call, got ${calls.length}`); + const [args, dispose] = calls[0].args; + assert.strictEqual((args as KernelMessage.IExecuteRequestMsg['content']).silent, false); + assert.strictEqual((args as KernelMessage.IExecuteRequestMsg['content']).store_history, true); + assert.strictEqual(dispose, false); + }); + + test('when generate() returns undefined: no silent pre-execute, single main requestExecute', async () => { + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves(undefined) + }; + const execution = createExecution(generator); + await execution.start(instance(session)); + await execution.result.catch(() => undefined); + + sinon.assert.calledOnce(generator.generate as sinon.SinonStub); + const calls = requestExecuteSpy.getCalls(); + assert.strictEqual(calls.length, 1, `expected exactly 1 requestExecute call, got ${calls.length}`); + const [args, dispose] = calls[0].args; + assert.strictEqual((args as KernelMessage.IExecuteRequestMsg['content']).silent, false); + assert.strictEqual((args as KernelMessage.IExecuteRequestMsg['content']).store_history, true); + assert.strictEqual(dispose, false); + }); + + test('when generate() returns {prelude, cellCode}: silent prelude first, then main execute', async () => { + const ACCESS_TOKEN = 'access-token-secret-do-not-log'; + const prelude = `__deepnote_federated_sql_connection__abc = '{"params":{"access_token":"${ACCESS_TOKEN}"}}'`; + const cellCode = `_dntk.execute_sql_with_connection_json('SELECT 1', __deepnote_federated_sql_connection__abc)`; + + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves({ prelude, cellCode }) + }; + const execution = createExecution(generator); + // Resolve the prelude so the `await` on its `.done` returns + // (otherwise `execution.result` would hang). + preludeDone.resolve(successReply); + await execution.start(instance(session)); + await execution.result.catch(() => undefined); + + sinon.assert.calledOnce(generator.generate as sinon.SinonStub); + + // Exactly two requestExecute calls. + const calls = requestExecuteSpy.getCalls(); + assert.strictEqual(calls.length, 2, `expected 2 requestExecute calls, got ${calls.length}`); + + // First call: silent prelude. + const [preludeArgs, preludeDispose] = calls[0].args; + const preludeContent = preludeArgs as KernelMessage.IExecuteRequestMsg['content']; + assert.strictEqual(preludeContent.code, prelude); + assert.strictEqual(preludeContent.silent, true); + assert.strictEqual(preludeContent.store_history, false); + assert.strictEqual(preludeContent.allow_stdin, false); + assert.strictEqual(preludeContent.stop_on_error, true); + assert.strictEqual(preludeDispose, true); + + // Second call: main execute. + const [mainArgs, mainDispose] = calls[1].args; + const mainContent = mainArgs as KernelMessage.IExecuteRequestMsg['content']; + assert.strictEqual(mainContent.code, cellCode); + assert.strictEqual(mainContent.silent, false); + assert.strictEqual(mainContent.store_history, true); + assert.strictEqual(mainDispose, false); + + // Critical M3 invariant: the access token must not appear in the main execute's code. + assert.isFalse( + mainContent.code.includes(ACCESS_TOKEN), + `Main execute code unexpectedly contains the access token: ${mainContent.code}` + ); + + // Call order: the prelude is at index 0 and the main call is at + // index 1. Sinon records calls in the order they were invoked, + // so the array index alone proves the order. Cross-check via + // `calledBefore` to be explicit. + assert.isTrue( + calls[0].calledBefore(calls[1]), + 'silent prelude requestExecute must be issued before the main requestExecute' + ); + }); + + test('main requestExecute waits for prelude .done before being issued', async () => { + // Regression guard for the `await` on the prelude `.done` in + // `cellExecution.execute`. If a future change drops the `await`, + // the main `requestExecute` would be issued synchronously after + // the prelude `requestExecute`, before the prelude has actually + // completed. To detect that, this test leaves `preludeDone` + // pending, ticks the microtask queue exhaustively, and asserts + // only the prelude has been issued. Then it resolves + // `preludeDone` and asserts the main call lands. + const prelude = `__deepnote_federated_sql_connection__abc = '{}'`; + const cellCode = `_dntk.execute_sql_with_connection_json('SELECT 1', __deepnote_federated_sql_connection__abc)`; + + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves({ prelude, cellCode }) + }; + const execution = createExecution(generator); + // Kick off execution without awaiting; if the `await` on + // `preludeDone` is honored, the main `requestExecute` will not be + // issued yet. + const startPromise = execution.start(instance(session)); + + // Flush pending microtasks by yielding multiple times. + // Anything that the implementation queues synchronously / + // microtask-only will have run by now. Real I/O is mocked, so + // there is nothing else competing for the event loop. + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + + sinon.assert.calledOnce(requestExecuteSpy); + const [preludeArgs, preludeDispose] = requestExecuteSpy.getCalls()[0].args; + assert.strictEqual( + (preludeArgs as KernelMessage.IExecuteRequestMsg['content']).silent, + true, + 'first call should be the silent prelude' + ); + assert.strictEqual(preludeDispose, true, 'first call should dispose-on-done (prelude convention)'); + + // Now resolve the prelude — the main `requestExecute` should be + // issued and the cell should complete. + preludeDone.resolve(successReply); + if (startPromise) { + await startPromise.catch(() => undefined); + } + await execution.result.catch(() => undefined); + + sinon.assert.calledTwice(requestExecuteSpy); + const [mainArgs, mainDispose] = requestExecuteSpy.getCalls()[1].args; + const mainContent = mainArgs as KernelMessage.IExecuteRequestMsg['content']; + assert.strictEqual(mainContent.code, cellCode); + assert.strictEqual(mainContent.silent, false); + assert.strictEqual(mainContent.store_history, true); + assert.strictEqual(mainDispose, false); + }); + + test('when prelude requestExecute rejects: main requestExecute is NOT called and cell fails', async () => { + // Hard invariant from the plan: a kernel rejection of the silent + // prelude must block the main `requestExecute` from being issued. + // The `try/catch` around `await kernelConnection.requestExecute(...).done` + // in `execute()` is what enforces this — without the `await` or + // with a missing `catch`, the rejected promise would either fire + // unhandled or let the main execute through. + const prelude = `__deepnote_federated_sql_connection__abc = '{}'`; + const cellCode = `_dntk.execute_sql_with_connection_json('SELECT 1', __deepnote_federated_sql_connection__abc)`; + + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves({ prelude, cellCode }) + }; + const execution = createExecution(generator); + + const preludeRejection = new Error('kernel error during prelude'); + // Reject the prelude before kicking off execution so the + // implementation observes the rejection on its first await. + preludeDone.reject(preludeRejection); + let caught: unknown; + const startPromise = execution.start(instance(session)); + if (startPromise) { + await startPromise.catch((err) => { + caught = err; + }); + } + await execution.result.catch(() => undefined); + + // Exactly one `requestExecute` call — the prelude. The main + // execute must NOT have been called. + sinon.assert.calledOnce(requestExecuteSpy); + const [preludeArgs, preludeDispose] = requestExecuteSpy.getCalls()[0].args; + assert.strictEqual( + (preludeArgs as KernelMessage.IExecuteRequestMsg['content']).silent, + true, + 'the single call should be the silent prelude' + ); + assert.strictEqual(preludeDispose, true); + + // The cell-execution failure should surface the underlying error. + assert.ok(caught instanceof Error, 'expected the cell to fail'); + assert.strictEqual((caught as Error).message, preludeRejection.message); + }); + + test('when generate() throws NotAuthenticatedError: cell fails, main requestExecute is NOT called', async () => { + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().rejects(new NotAuthenticatedError('My BigQuery')) + }; + const execution = createExecution(generator); + + // start() returns the same promise as `result`; await it via .catch() + // since the failure path rejects. + let caught: unknown; + const startPromise = execution.start(instance(session)); + if (startPromise) { + await startPromise.catch((err) => { + caught = err; + }); + } + assert.ok(caught instanceof Error, 'expected the cell to fail'); + // Hardcoded English string per M3 (M4 wires l10n). Assert on the + // user-facing prefix, not the full message, to avoid coupling + // tests to copy. + assert.include((caught as Error).message, 'not authenticated'); + + // No requestExecute should have been issued. + sinon.assert.notCalled(requestExecuteSpy); + }); + + test('when generate() throws a generic error: cell fails, main requestExecute is NOT called', async () => { + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().rejects(new Error('Some other error')) + }; + const execution = createExecution(generator); + + let caught: unknown; + const startPromise = execution.start(instance(session)); + if (startPromise) { + await startPromise.catch((err) => { + caught = err; + }); + } + assert.ok(caught instanceof Error, 'expected the cell to fail'); + + // No requestExecute should have been issued. + sinon.assert.notCalled(requestExecuteSpy); + }); +}); diff --git a/src/kernels/execution/cellExecution.ts b/src/kernels/execution/cellExecution.ts index 5bedc16970..b514ee8b56 100644 --- a/src/kernels/execution/cellExecution.ts +++ b/src/kernels/execution/cellExecution.ts @@ -34,14 +34,30 @@ import { getCachedSysPrefix } from '../../platform/interpreter/helpers'; import { getCellMetadata } from '../../platform/common/utils'; import { NotebookCellExecutionState, notebookCellExecutions } from '../../platform/notebooks/cellExecutionStateService'; import { DeepnoteDataConverter } from '../../notebooks/deepnote/deepnoteDataConverter'; +import { + IFederatedAuthSqlBlockCodeGenerator, + NotAuthenticatedError +} from '../../notebooks/deepnote/integrations/types'; /** * Factory for CellExecution objects. + * + * Constructed manually outside the inversify container (see + * `NotebookKernelExecution`), so optional dependencies are passed through + * as plain constructor arguments rather than via `@inject(...) @optional()`. */ export class CellExecutionFactory { constructor( private readonly controller: IKernelController, - private readonly requestListener: CellExecutionMessageHandlerService + private readonly requestListener: CellExecutionMessageHandlerService, + /** + * Federated-auth code generator. Resolved as `@optional()` in + * `NotebookKernelExecution` so the web build (where the symbol is + * unbound) passes `undefined` here; the federated branch in + * {@link CellExecution.execute} is then skipped via optional + * chaining. + */ + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) {} public create( @@ -51,7 +67,15 @@ export class CellExecutionFactory { info?: ResumeCellExecutionInformation ) { // eslint-disable-next-line @typescript-eslint/no-use-before-define - return CellExecution.fromCell(cell, code, metadata, this.controller, this.requestListener, info); + return CellExecution.fromCell( + cell, + code, + metadata, + this.controller, + this.requestListener, + info, + this.federatedAuthSqlBlockCodeGenerator + ); } } @@ -99,7 +123,8 @@ export class CellExecution implements ICellExecution, IDisposable { private readonly kernelConnection: Readonly, private readonly controller: IKernelController, private readonly requestListener: CellExecutionMessageHandlerService, - private readonly resumeExecution?: ResumeCellExecutionInformation + private readonly resumeExecution?: ResumeCellExecutionInformation, + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { workspace.onDidCloseTextDocument( (e) => { @@ -147,9 +172,18 @@ export class CellExecution implements ICellExecution, IDisposable { metadata: Readonly, controller: IKernelController, requestListener: CellExecutionMessageHandlerService, - info?: ResumeCellExecutionInformation + info?: ResumeCellExecutionInformation, + federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { - return new CellExecution(cell, code, metadata, controller, requestListener, info); + return new CellExecution( + cell, + code, + metadata, + controller, + requestListener, + info, + federatedAuthSqlBlockCodeGenerator + ); } public async start(session: IKernelSession) { this.session = session; @@ -393,6 +427,34 @@ export class CellExecution implements ICellExecution, IDisposable { return !this.cell.document.isClosed; } + /** + * Surfaces a federated-auth `generate()` failure as a cell-execution + * failure with a clear message. The plan's M3 scope is to emit a + * clear error here; the actual "Authenticate with Google" UX button + * is M4's job. + */ + private handleFederatedGenerateError(ex: unknown) { + if (ex instanceof NotAuthenticatedError) { + logger.warn( + `Federated BigQuery integration "${ex.integrationName}" is not authenticated; cell Index ${this.cell.index} cannot run.` + ); + // TODO(m4-l10n): wire through localize.ts once + // bundle.l10n.json runtime wiring lands in M4. Until then the + // hardcoded English string keeps the UX intelligible. + return this.completedWithErrors( + new Error( + 'BigQuery integration is not authenticated. Click "Authenticate with Google" in the integration panel to continue.' + ) + ); + } + logger.error(`Federated SQL code generation failed for cell Index ${this.cell.index}`, ex); + // Narrow the catch-variable to a shape that satisfies + // `completedWithErrors(error: Partial)` without an `as` + // cast: pass the original Error through, otherwise wrap a + // non-Error throw value so the diagnostic is preserved. + return this.completedWithErrors(ex instanceof Error ? ex : new Error(String(ex))); + } + private async execute(code: string, session: IKernelSession) { if (!session.kernel) { throw new Error('No kernel available to execute code'); @@ -420,9 +482,6 @@ export class CellExecution implements ICellExecution, IDisposable { const dataConverter = new DeepnoteDataConverter(); const deepnoteBlock = dataConverter.convertCellToBlock(cellData, this.cell.index); - logger.info(`Cell ${this.cell.index}: Using createPythonCode for ${deepnoteBlock.type} block`); - code = createPythonCode(deepnoteBlock); - // Generate metadata from our cell (some kernels expect this.) // eslint-disable-next-line @typescript-eslint/no-explicit-any const metadata: any = { @@ -431,6 +490,46 @@ export class CellExecution implements ICellExecution, IDisposable { }; const kernelConnection = session.kernel; + + // Federated-auth path (BigQuery + google-oauth): + // 1. Resolve a fresh access token + emit a silent pre-execute + // that defines a kernel-global variable holding the connection + // JSON. `store_history: false` keeps the token out of `In[]`. + // 2. Replace `code` with the variable-reference-only cell source, + // which is safe to put in cell history. + // For non-federated cells (or when the generator is unbound on + // web), `federated` is undefined and we fall back to upstream + // `createPythonCode` exactly as before. + let federated: { prelude: string; cellCode: string } | undefined; + try { + federated = await this.federatedAuthSqlBlockCodeGenerator?.generate(deepnoteBlock); + } catch (ex) { + return this.handleFederatedGenerateError(ex); + } + if (federated) { + logger.info(`Cell ${this.cell.index}: Using federated BigQuery code path`); + try { + await kernelConnection.requestExecute( + { + code: federated.prelude, + silent: true, + store_history: false, + allow_stdin: false, + stop_on_error: true + }, + /* dispose: */ true, + /* metadata: */ undefined + ).done; + } catch (ex) { + logger.error(`Federated pre-execute failed for cell Index ${this.cell.index}`, ex); + return this.completedWithErrors(ex); + } + code = federated.cellCode; + } else { + logger.info(`Cell ${this.cell.index}: Using createPythonCode for ${deepnoteBlock.type} block`); + code = createPythonCode(deepnoteBlock); + } + try { // At this point we're about to ACTUALLY execute some code. Fire an event to indicate that notebookCellExecutions.changeCellState(this.cell, NotebookCellExecutionState.Executing); diff --git a/src/kernels/kernelExecution.ts b/src/kernels/kernelExecution.ts index 9158d7a2cf..5d650bb58a 100644 --- a/src/kernels/kernelExecution.ts +++ b/src/kernels/kernelExecution.ts @@ -46,6 +46,7 @@ import { CodeExecution } from './execution/codeExecution'; import type { ICodeExecution } from './execution/types'; import { NotebookCellExecutionState, notebookCellExecutions } from '../platform/notebooks/cellExecutionStateService'; import { ISnapshotMetadataService } from '../notebooks/deepnote/snapshots/snapshotService'; +import { IFederatedAuthSqlBlockCodeGenerator } from '../notebooks/deepnote/integrations/types'; /** * Everything in this classes gets disposed via the `onWillCancel` hook. @@ -67,7 +68,18 @@ export class NotebookKernelExecution implements INotebookKernelExecution { context: IExtensionContext, formatters: ITracebackFormatter[], private readonly notebook: NotebookDocument, - private readonly snapshotService?: ISnapshotMetadataService + private readonly snapshotService?: ISnapshotMetadataService, + /** + * Federated-auth code generator. Optional so the web build (where + * the symbol is unbound) resolves it to `undefined` and the + * federated branch in `CellExecution.execute` is skipped — the + * existing `createPythonCode` path runs as today. + * + * `NotebookKernelExecution` is the inversify-managed entry point + * for the execution chain; it threads the resolved value through + * `new CellExecutionFactory(...)` below. + */ + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { const requestListener = new CellExecutionMessageHandlerService( kernel.controller, @@ -76,7 +88,11 @@ export class NotebookKernelExecution implements INotebookKernelExecution { notebook ); this.disposables.push(requestListener); - this.executionFactory = new CellExecutionFactory(kernel.controller, requestListener); + this.executionFactory = new CellExecutionFactory( + kernel.controller, + requestListener, + this.federatedAuthSqlBlockCodeGenerator + ); notebookCellExecutions.onDidChangeNotebookCellExecutionState((e) => { if ( e.cell.notebook === kernel.notebook && diff --git a/src/kernels/kernelProvider.node.ts b/src/kernels/kernelProvider.node.ts index caf3c30028..9d149e5a4e 100644 --- a/src/kernels/kernelProvider.node.ts +++ b/src/kernels/kernelProvider.node.ts @@ -34,6 +34,8 @@ import { getDisplayPath } from '../platform/common/platform/fs-paths.node'; import { IRawNotebookSupportedService } from './raw/types'; // eslint-disable-next-line import/no-restricted-paths import { ISnapshotMetadataService } from '../notebooks/deepnote/snapshots/snapshotService'; +// eslint-disable-next-line import/no-restricted-paths +import { IFederatedAuthSqlBlockCodeGenerator } from '../notebooks/deepnote/integrations/types'; /** * Node version of a kernel provider. Needed in order to create the node version of a kernel. @@ -54,7 +56,10 @@ export class KernelProvider extends BaseCoreKernelProvider { @inject(IReplNotebookTrackerService) private readonly replTracker: IReplNotebookTrackerService, @inject(IKernelWorkingDirectory) private readonly kernelWorkingDirectory: IKernelWorkingDirectory, @inject(IRawNotebookSupportedService) private readonly rawKernelSupported: IRawNotebookSupportedService, - @inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataService + @inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataService, + @inject(IFederatedAuthSqlBlockCodeGenerator) + @optional() + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { super(asyncDisposables, disposables); disposables.push(jupyterServerUriStorage.onDidRemove(this.handleServerRemoval.bind(this))); @@ -115,7 +120,14 @@ export class KernelProvider extends BaseCoreKernelProvider { this.executions.set( kernel, - new NotebookKernelExecution(kernel, this.context, this.formatters, notebook, this.snapshotService) + new NotebookKernelExecution( + kernel, + this.context, + this.formatters, + notebook, + this.snapshotService, + this.federatedAuthSqlBlockCodeGenerator + ) ); this.asyncDisposables.push(kernel); this.storeKernel(notebook, options, kernel); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts new file mode 100644 index 0000000000..4734710e0d --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts @@ -0,0 +1,253 @@ +// VENDORED: this file vendors helpers that should land in +// `@deepnote/blocks` (see Step 1a of the plan at +// /home/ubuntu/.claude/plans/look-at-the-pr-curious-toast.md and the +// upstream-migration plan at Step 10). The local copy adapts upstream's +// `executeSqlQueryWithConnectionJson` by accepting a Python *expression* +// (`connectionJsonExpression`) instead of a literal JSON string, so the +// caller can reference a kernel-global variable that holds the freshly +// fetched access token. Delete this file once `@deepnote/blocks` exports +// the expression-form helper. + +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { BigQueryAuthMethods } from '@deepnote/database-integrations'; +import { inject, injectable, optional } from 'inversify'; +import { dedent } from 'ts-dedent'; + +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { fetchFreshAccessToken, InvalidGrantError, computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; +import { GOOGLE_TOKEN_URL } from './googleOAuthProvider.node'; +import { + createDataFrameConfig, + escapePythonString, + sanitizePythonVariableName, + SqlCacheMode, + SqlCellVariableType +} from './vendoredBlocksHelpers'; +import { IFederatedAuthSqlBlockCodeGenerator, IFederatedAuthTokenStorage, NotAuthenticatedError } from '../types'; + +/** + * Type signature of {@link fetchFreshAccessToken}. Used to declare the + * optional test seam on {@link FederatedAuthSqlBlockCodeGenerator}'s + * constructor. + */ +type FetchFreshAccessTokenFn = typeof fetchFreshAccessToken; + +/** + * Computes the kernel-global Python variable name used to hold the fresh + * SqlAlchemy JSON for a federated BigQuery integration. + * + * Naming convention (double-underscore prefix + dunder-pattern suffix) + * makes accidental shadowing unlikely; the per-integration scope means + * multiple federated integrations in the same notebook don't trample each + * other's tokens. + * + * The sanitization regex replaces any character outside `[A-Za-z0-9_]` + * with `_` so the resulting identifier is always a valid Python name even + * if the integration id contains characters like `-` (UUIDs frequently + * do). + */ +export function federatedSqlVariableName(integrationId: string): string { + const sanitized = integrationId.replace(/[^A-Za-z0-9_]/g, '_'); + return `__deepnote_federated_sql_connection__${sanitized}`; +} + +/** + * VENDORED helper. Mirrors the upstream-proposed + * `pythonCode.executeSqlQueryWithConnectionJson` in + * `@deepnote/blocks/python-snippets`, with one adjustment: the + * `connectionJsonExpression` parameter is interpolated *without* + * surrounding quotes so it is emitted as a Python expression (a bare + * identifier referencing a kernel global) rather than as a Python + * string literal. + * + * Output is structurally identical to upstream's + * `createPythonCodeForSqlBlock` shape, except it invokes + * `_dntk.execute_sql_with_connection_json(...)` and passes the + * pre-populated JSON via the variable reference rather than via an + * env-var name. + * + * TODO(deepnote-followups): remove when @deepnote/blocks exports the + * expression-form helper. + */ +function executeSqlQueryWithConnectionJson(params: { + query: string; + auditComment?: string; + connectionJsonExpression: string; + pythonVariableName?: string; + sqlCacheMode: SqlCacheMode; + returnVariableType: SqlCellVariableType; +}): string { + const escapedQuery = escapePythonString(params.query); + const escapedAuditComment = escapePythonString(params.auditComment ?? ''); + const executeSqlFunctionCall = dedent`_dntk.execute_sql_with_connection_json( + ${escapedQuery}, + ${params.connectionJsonExpression}, + audit_sql_comment=${escapedAuditComment}, + sql_cache_mode='${params.sqlCacheMode}', + return_variable_type='${params.returnVariableType}' + )`; + + return params.pythonVariableName === undefined + ? executeSqlFunctionCall + : dedent` + ${params.pythonVariableName} = ${executeSqlFunctionCall} + ${params.pythonVariableName} + `; +} + +/** + * Generates the Python prelude + cell code for a federated-authentication + * BigQuery SQL block. Returns `undefined` for any block that doesn't + * qualify, so call sites can fall back to upstream + * `@deepnote/blocks.createPythonCode`. + * + * The plan non-negotiable is that access tokens never appear in the cell's + * main `code` argument to `kernel.requestExecute` (which uses + * `store_history: true` — the input would land in the kernel's `In[]` + * history). To honor that, this generator returns: + * + * - `prelude`: a one-line assignment that puts the SqlAlchemy JSON + * (containing the fresh access token) into a kernel-global Python + * variable. The caller is expected to send this via a silent + * `requestExecute({ store_history: false })`. + * - `cellCode`: the Python source for the main cell execute, which only + * references the variable by name. Safe to put in `In[]` history. + * + * Plan non-negotiable: never cache the access token. Every call to + * `generate()` triggers an unconditional refresh against the OAuth + * provider's token endpoint. + */ +@injectable() +export class FederatedAuthSqlBlockCodeGenerator implements IFederatedAuthSqlBlockCodeGenerator { + /** + * Test seam: injectable replacement for {@link fetchFreshAccessToken}. + * Production callers should let this default to the real + * implementation. Tests pass a stub through the optional 3rd + * constructor parameter to avoid hitting the real Google token + * endpoint. + */ + private readonly fetchFreshAccessToken: FetchFreshAccessTokenFn; + + constructor( + @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, + @inject(IFederatedAuthTokenStorage) private readonly tokenStorage: IFederatedAuthTokenStorage, + @optional() fetcher?: FetchFreshAccessTokenFn + ) { + this.fetchFreshAccessToken = fetcher ?? fetchFreshAccessToken; + } + + public async generate(block: DeepnoteBlock): Promise<{ prelude: string; cellCode: string } | undefined> { + if (block.type !== 'sql') { + return undefined; + } + + // `SqlBlock = Extract` so the + // discriminator check above narrows `block` to `SqlBlock` already. + const sqlBlock = block; + const integrationId = sqlBlock.metadata?.sql_integration_id; + if (!integrationId) { + return undefined; + } + + const integration = await this.integrationStorage.getIntegrationConfig(integrationId); + if (!integration || integration.type !== 'big-query') { + return undefined; + } + if (integration.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + return undefined; + } + + // From here on the integration is BigQuery + google-oauth, so it's + // federated. Any "no usable token" branch must throw + // NotAuthenticatedError so the UI can offer the Authenticate + // command (Step 2 of the plan). + const entry = await this.tokenStorage.get(integrationId); + if (!entry) { + throw new NotAuthenticatedError(integration.name); + } + + const currentFingerprint = computeMetadataFingerprint({ + clientId: integration.metadata.clientId, + clientSecret: integration.metadata.clientSecret, + project: integration.metadata.project + }); + if (currentFingerprint !== entry.metadataFingerprint) { + // Metadata edited since the token was saved → the stored + // refresh token is bound to a different OAuth client. Drop + // it; `onDidChangeTokens` flips the pill in the UI. + await this.tokenStorage.delete(integrationId); + throw new NotAuthenticatedError(integration.name); + } + + let accessToken: string; + let newRefreshToken: string | undefined; + try { + const result = await this.fetchFreshAccessToken(entry, { + tokenUrl: GOOGLE_TOKEN_URL, + clientId: integration.metadata.clientId, + clientSecret: integration.metadata.clientSecret + }); + accessToken = result.accessToken; + newRefreshToken = result.newRefreshToken; + } catch (error) { + if (error instanceof InvalidGrantError) { + // The refresh token is no longer valid (revoked / + // expired). Drop it locally; rethrow as + // NotAuthenticatedError so the caller surfaces the + // re-authenticate path. + await this.tokenStorage.delete(integrationId); + throw new NotAuthenticatedError(integration.name); + } + // InvalidClientError and any other error mean the token is + // probably still valid — don't delete it. Rethrow so the + // caller can surface the underlying error. + throw error; + } + + // Persist a rotated refresh token if Google issued one. Mirrors + // production behavior described in plan Step 1a item 6. + if (newRefreshToken !== undefined && newRefreshToken !== entry.refreshToken) { + await this.tokenStorage.save({ ...entry, refreshToken: newRefreshToken }); + } + + const connectionJson = JSON.stringify({ + integration_id: integration.id, + url: 'bigquery://?user_supplied_client=true', + params: { access_token: accessToken, project: integration.metadata.project }, + param_style: 'pyformat' + }); + + const variableName = federatedSqlVariableName(integration.id); + + // Delegate to the vendored helper for the Python single-quoted + // literal: it doubles backslashes, escapes single quotes, and + // escapes newlines (and wraps the result in single quotes itself). + // A user-supplied `integration.id` containing `\` or `\n` would + // otherwise survive `JSON.stringify` and break Python's + // `json.loads` on the kernel side. + const prelude = `${variableName} = ${escapePythonString(connectionJson)}`; + + // Build the cell code by mirroring upstream + // `createPythonCodeForSqlBlock`'s metadata reads. + const query = sqlBlock.content ?? ''; + const rawVariableName = sqlBlock.metadata?.deepnote_variable_name; + const pythonVariableName = + rawVariableName !== undefined ? sanitizePythonVariableName(rawVariableName) ?? 'input_1' : undefined; + const returnVariableType: SqlCellVariableType = sqlBlock.metadata?.deepnote_return_variable_type ?? 'dataframe'; + const sqlCacheMode: SqlCacheMode = 'cache_disabled'; + + const dataFrameConfig = createDataFrameConfig(sqlBlock); + const executeSqlCall = executeSqlQueryWithConnectionJson({ + query, + auditComment: '', + connectionJsonExpression: variableName, + pythonVariableName, + sqlCacheMode, + returnVariableType + }); + + const cellCode = `${dataFrameConfig}\n\n${executeSqlCall}`; + + return { prelude, cellCode }; + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts new file mode 100644 index 0000000000..fb0a03acf8 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts @@ -0,0 +1,443 @@ +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { assert } from 'chai'; +import sinon from 'sinon'; + +import { ConfigurableDatabaseIntegrationConfig } from '../../../../platform/notebooks/deepnote/integrationTypes'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage, NotAuthenticatedError } from '../types'; +import { + FederatedAuthSqlBlockCodeGenerator, + federatedSqlVariableName +} from './federatedAuthSqlBlockCodeGenerator.node'; +import { InvalidClientError, InvalidGrantError, computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; + +type FetcherFn = ( + entry: FederatedAuthTokenEntry, + oauthConfig: { tokenUrl: string; clientId: string; clientSecret: string } +) => Promise<{ accessToken: string; newRefreshToken?: string }>; + +suite('FederatedAuthSqlBlockCodeGenerator', () => { + const INTEGRATION_ID = 'bq-integration-1'; + const PROJECT = 'my-gcp-project'; + const CLIENT_ID = 'client-id-abc'; + const CLIENT_SECRET = 'client-secret-xyz'; + const REFRESH_TOKEN = 'refresh-token-abc'; + const ACCESS_TOKEN = 'access-token-secret-do-not-log'; + const VALID_FINGERPRINT = computeMetadataFingerprint({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + project: PROJECT + }); + + let integrationStore: Map; + let tokenStore: Map; + let deleteSpy: sinon.SinonSpy; + let saveSpy: sinon.SinonSpy; + let fetcher: sinon.SinonStub, ReturnType>; + let integrationStorage: IIntegrationStorage; + let tokenStorage: IFederatedAuthTokenStorage; + let generator: FederatedAuthSqlBlockCodeGenerator; + + setup(() => { + integrationStore = new Map(); + tokenStore = new Map(); + + // Minimal IIntegrationStorage stub: only the method generate() calls. + integrationStorage = { + getIntegrationConfig: async (id: string) => integrationStore.get(id) + } as unknown as IIntegrationStorage; + + deleteSpy = sinon.spy(async (id: string) => { + tokenStore.delete(id); + }); + saveSpy = sinon.spy(async (entry: FederatedAuthTokenEntry) => { + tokenStore.set(entry.integrationId, entry); + }); + + tokenStorage = { + get: async (id: string) => tokenStore.get(id), + delete: deleteSpy as unknown as IFederatedAuthTokenStorage['delete'], + save: saveSpy as unknown as IFederatedAuthTokenStorage['save'] + } as unknown as IFederatedAuthTokenStorage; + + fetcher = sinon.stub, ReturnType>(); + fetcher.resolves({ accessToken: ACCESS_TOKEN }); + + generator = new FederatedAuthSqlBlockCodeGenerator( + integrationStorage, + tokenStorage, + fetcher as unknown as FetcherFn + ); + }); + + function setupValidFederatedIntegration() { + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'My BigQuery', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: PROJECT, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET + } + } as ConfigurableDatabaseIntegrationConfig); + + tokenStore.set(INTEGRATION_ID, { + integrationId: INTEGRATION_ID, + refreshToken: REFRESH_TOKEN, + metadataFingerprint: VALID_FINGERPRINT + }); + } + + function sqlBlock(overrides?: { sql_integration_id?: string; deepnote_variable_name?: string }): DeepnoteBlock { + return { + id: 'block-1', + type: 'sql', + blockGroup: 'group-1', + sortingKey: '0', + content: 'SELECT 1 AS one', + metadata: { + sql_integration_id: overrides?.sql_integration_id ?? INTEGRATION_ID, + deepnote_variable_name: overrides?.deepnote_variable_name + } + } as unknown as DeepnoteBlock; + } + + function codeBlock(): DeepnoteBlock { + return { + id: 'block-1', + type: 'code', + blockGroup: 'group-1', + sortingKey: '0', + content: 'print("hi")', + metadata: {} + } as unknown as DeepnoteBlock; + } + + test('returns undefined for a non-SQL block', async () => { + setupValidFederatedIntegration(); + const result = await generator.generate(codeBlock()); + assert.strictEqual(result, undefined); + sinon.assert.notCalled(fetcher); + }); + + test('returns undefined when SQL block has no sql_integration_id', async () => { + setupValidFederatedIntegration(); + const block = { + id: 'block-1', + type: 'sql', + blockGroup: 'group-1', + sortingKey: '0', + content: 'SELECT 1', + metadata: {} + } as unknown as DeepnoteBlock; + const result = await generator.generate(block); + assert.strictEqual(result, undefined); + sinon.assert.notCalled(fetcher); + }); + + test('returns undefined when integration is not BigQuery (e.g. pgsql)', async () => { + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'My Postgres', + type: 'pgsql', + metadata: { + host: 'db.example.com', + user: 'me', + database: 'mydb' + } + } as unknown as ConfigurableDatabaseIntegrationConfig); + + const result = await generator.generate(sqlBlock()); + assert.strictEqual(result, undefined); + sinon.assert.notCalled(fetcher); + }); + + test('returns undefined when BigQuery integration uses service-account auth', async () => { + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'My BigQuery (SA)', + type: 'big-query', + metadata: { + authMethod: 'service-account', + service_account: '{"type": "service_account"}' + } + } as unknown as ConfigurableDatabaseIntegrationConfig); + + const result = await generator.generate(sqlBlock()); + assert.strictEqual(result, undefined); + sinon.assert.notCalled(fetcher); + }); + + test('returns undefined when integration is not found (e.g. id typo)', async () => { + const result = await generator.generate(sqlBlock({ sql_integration_id: 'unknown-id' })); + assert.strictEqual(result, undefined); + sinon.assert.notCalled(fetcher); + }); + + test('throws NotAuthenticatedError when federated integration has no stored token', async () => { + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'My BigQuery', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: PROJECT, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET + } + } as ConfigurableDatabaseIntegrationConfig); + + try { + await generator.generate(sqlBlock()); + assert.fail('Expected NotAuthenticatedError'); + } catch (err) { + assert.instanceOf(err, NotAuthenticatedError); + assert.strictEqual((err as NotAuthenticatedError).integrationName, 'My BigQuery'); + } + sinon.assert.notCalled(fetcher); + }); + + test('throws NotAuthenticatedError and deletes the token when the metadata fingerprint is stale', async () => { + setupValidFederatedIntegration(); + // Overwrite with a token whose fingerprint won't match the integration metadata. + tokenStore.set(INTEGRATION_ID, { + integrationId: INTEGRATION_ID, + refreshToken: REFRESH_TOKEN, + metadataFingerprint: 'stale-fingerprint' + }); + + try { + await generator.generate(sqlBlock()); + assert.fail('Expected NotAuthenticatedError'); + } catch (err) { + assert.instanceOf(err, NotAuthenticatedError); + } + sinon.assert.calledOnceWithExactly(deleteSpy, INTEGRATION_ID); + sinon.assert.notCalled(fetcher); + }); + + test('returns { prelude, cellCode } for a valid federated SQL block', async () => { + setupValidFederatedIntegration(); + + const result = await generator.generate(sqlBlock()); + if (!result) { + throw new Error('expected a non-undefined result'); + } + + const expectedVariableName = federatedSqlVariableName(INTEGRATION_ID); + const escapedVariableName = expectedVariableName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // prelude is exactly ` = ''`. + const preludeRegex = new RegExp(`^${escapedVariableName} = '([^]*)'$`); + const match = preludeRegex.exec(result.prelude); + if (!match) { + throw new Error(`prelude did not match expected shape: ${result.prelude}`); + } + const safeJson = match[1]; + const parsed = JSON.parse(safeJson.replace(/\\'/g, "'")) as Record; + assert.deepStrictEqual(parsed, { + integration_id: INTEGRATION_ID, + url: 'bigquery://?user_supplied_client=true', + params: { access_token: ACCESS_TOKEN, project: PROJECT }, + param_style: 'pyformat' + }); + + // cellCode invokes the connection-json function and references the variable by name (no quotes). + assert.include(result.cellCode, '_dntk.execute_sql_with_connection_json('); + // The variable is referenced unquoted between the commas. + const inlineRef = new RegExp(`,\\s*${escapedVariableName}\\s*,`); + assert.match(result.cellCode, inlineRef, 'cellCode should reference the variable as a bare identifier'); + + // Critical M3 invariant: the access token MUST NOT appear in cellCode. + assert.isFalse( + result.cellCode.includes(ACCESS_TOKEN), + `cellCode unexpectedly contains the access token: ${result.cellCode}` + ); + }); + + test('prelude round-trips through Python+json.loads when integration id contains backslash, newline, and single quote', async () => { + // Plan invariant (Step 1a): the prelude is a Python single-quoted + // string literal that Python evaluates and then `json.loads` parses + // on the kernel side. The escape must double backslashes, escape + // single quotes, and escape newlines — otherwise a user-supplied + // `integration.id` containing any of those breaks `json.loads`. + // + // Catches: regressing to the old single-char `\\'` escape would + // leave embedded `\\` and `\\n` unhandled, and Python would decode + // them to a single backslash / real newline before `json.loads`, + // producing invalid JSON at the kernel. + const hostileIntegrationId = "bq-with-\\-and-\n-and-'-id"; + integrationStore.set(hostileIntegrationId, { + id: hostileIntegrationId, + name: 'My BigQuery', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: PROJECT, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET + } + } as ConfigurableDatabaseIntegrationConfig); + tokenStore.set(hostileIntegrationId, { + integrationId: hostileIntegrationId, + refreshToken: REFRESH_TOKEN, + metadataFingerprint: VALID_FINGERPRINT + }); + + const result = await generator.generate(sqlBlock({ sql_integration_id: hostileIntegrationId })); + if (!result) { + throw new Error('expected a non-undefined result'); + } + + // The prelude shape is ` = `. + // Strip the ` = ` prefix and parse what Python would. + const expectedVariableName = federatedSqlVariableName(hostileIntegrationId); + const assignmentPrefix = `${expectedVariableName} = `; + assert.isTrue( + result.prelude.startsWith(assignmentPrefix), + `prelude did not begin with the expected assignment prefix: ${result.prelude}` + ); + const literal = result.prelude.slice(assignmentPrefix.length); + + // Mirror the M1 escapePythonString round-trip test: parse the + // Python single-quoted literal manually (inverse of \\, \', \n). + function parsePythonSingleQuoted(escaped: string): string { + assert.isTrue(escaped.startsWith("'") && escaped.endsWith("'"), 'must be wrapped in single quotes'); + const body = escaped.slice(1, -1); + let out = ''; + for (let i = 0; i < body.length; i++) { + if (body[i] === '\\' && i + 1 < body.length) { + const next = body[i + 1]; + if (next === '\\') { + out += '\\'; + } else if (next === "'") { + out += "'"; + } else if (next === 'n') { + out += '\n'; + } else { + out += '\\' + next; + } + i++; + } else { + out += body[i]; + } + } + return out; + } + const decoded = parsePythonSingleQuoted(literal); + const parsed = JSON.parse(decoded) as Record; + assert.deepStrictEqual(parsed, { + integration_id: hostileIntegrationId, + url: 'bigquery://?user_supplied_client=true', + params: { access_token: ACCESS_TOKEN, project: PROJECT }, + param_style: 'pyformat' + }); + }); + + test('two sequential calls trigger two fetches (no caching)', async () => { + setupValidFederatedIntegration(); + fetcher.onFirstCall().resolves({ accessToken: 'token-1' }); + fetcher.onSecondCall().resolves({ accessToken: 'token-2' }); + + const first = await generator.generate(sqlBlock()); + const second = await generator.generate(sqlBlock()); + + sinon.assert.calledTwice(fetcher); + assert.notStrictEqual(first?.prelude, second?.prelude); + assert.include(first?.prelude ?? '', 'token-1'); + assert.include(second?.prelude ?? '', 'token-2'); + }); + + test('InvalidGrantError from refresh: throws NotAuthenticatedError and deletes the token', async () => { + setupValidFederatedIntegration(); + fetcher.rejects(new InvalidGrantError()); + + try { + await generator.generate(sqlBlock()); + assert.fail('Expected NotAuthenticatedError'); + } catch (err) { + assert.instanceOf(err, NotAuthenticatedError); + } + sinon.assert.calledOnceWithExactly(deleteSpy, INTEGRATION_ID); + }); + + test('InvalidClientError from refresh: rethrows InvalidClientError and does NOT delete the token', async () => { + setupValidFederatedIntegration(); + fetcher.rejects(new InvalidClientError()); + + try { + await generator.generate(sqlBlock()); + assert.fail('Expected InvalidClientError'); + } catch (err) { + assert.instanceOf(err, InvalidClientError); + assert.notInstanceOf(err, NotAuthenticatedError); + } + sinon.assert.notCalled(deleteSpy); + }); + + test('persists a rotated refresh token before resolving', async () => { + setupValidFederatedIntegration(); + fetcher.resolves({ accessToken: ACCESS_TOKEN, newRefreshToken: 'new-refresh-token' }); + + const result = await generator.generate(sqlBlock()); + assert.ok(result); + + sinon.assert.calledOnce(saveSpy); + const savedEntry = saveSpy.firstCall.args[0] as FederatedAuthTokenEntry; + assert.deepStrictEqual(savedEntry, { + integrationId: INTEGRATION_ID, + refreshToken: 'new-refresh-token', + metadataFingerprint: VALID_FINGERPRINT + }); + }); + + test('does NOT call save when the returned refresh token is identical to the stored one', async () => { + setupValidFederatedIntegration(); + fetcher.resolves({ accessToken: ACCESS_TOKEN, newRefreshToken: REFRESH_TOKEN }); + + await generator.generate(sqlBlock()); + + sinon.assert.notCalled(saveSpy); + }); + + test('does NOT call save when the response carries no refresh token at all', async () => { + setupValidFederatedIntegration(); + fetcher.resolves({ accessToken: ACCESS_TOKEN }); + + await generator.generate(sqlBlock()); + + sinon.assert.notCalled(saveSpy); + }); + + test('cellCode honors deepnote_variable_name by emitting an assignment', async () => { + setupValidFederatedIntegration(); + const result = await generator.generate(sqlBlock({ deepnote_variable_name: 'my_df' })); + if (!result) { + throw new Error('expected a non-undefined result'); + } + // Match upstream's shape: `my_df = _dntk.execute_sql_with_connection_json(...)` followed by `my_df` on the next line. + assert.include(result.cellCode, 'my_df = _dntk.execute_sql_with_connection_json('); + }); + + suite('federatedSqlVariableName', () => { + test('replaces non-identifier characters with underscores', () => { + assert.strictEqual( + federatedSqlVariableName('abc-123-def'), + '__deepnote_federated_sql_connection__abc_123_def' + ); + }); + + test('leaves already-valid identifier characters alone', () => { + assert.strictEqual(federatedSqlVariableName('abc_123'), '__deepnote_federated_sql_connection__abc_123'); + }); + + test('replaces a UUID-style id with underscores', () => { + assert.strictEqual( + federatedSqlVariableName('11111111-2222-3333-4444-555555555555'), + '__deepnote_federated_sql_connection__11111111_2222_3333_4444_555555555555' + ); + }); + }); +}); diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index cbd8b860fe..b5cf25f744 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -48,11 +48,15 @@ import { IntegrationDetector } from './deepnote/integrations/integrationDetector import { IntegrationManager } from './deepnote/integrations/integrationManager'; import { IntegrationWebviewProvider } from './deepnote/integrations/integrationWebview'; import { + IFederatedAuthSqlBlockCodeGenerator, + IFederatedAuthTokenStorage, IIntegrationDetector, IIntegrationManager, IIntegrationStorage, IIntegrationWebviewProvider } from './deepnote/integrations/types'; +import { FederatedAuthSqlBlockCodeGenerator } from './deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node'; +import { FederatedAuthTokenStorage } from './deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node'; import { IPlatformNotebookEditorProvider, IPlatformDeepnoteNotebookManager @@ -180,6 +184,11 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); serviceManager.addSingleton(IIntegrationManager, IntegrationManager); + serviceManager.addSingleton(IFederatedAuthTokenStorage, FederatedAuthTokenStorage); + serviceManager.addSingleton( + IFederatedAuthSqlBlockCodeGenerator, + FederatedAuthSqlBlockCodeGenerator + ); serviceManager.addSingleton( IExtensionSyncActivationService, SqlCellStatusBarProvider From 4b9992fc9da619501c4d9ee9a3c01f44fd1e05d1 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 21 May 2026 20:12:28 +0000 Subject: [PATCH 04/13] feat(integrations): expose BigQuery OAuth to the user via UI + command (M4) Wire the federated auth flow into reachable surfaces: a "Google OAuth" option on the BigQuery integration form, an Authenticate button with status pill in the integration list, a command that drives the loopback flow on desktop (and a clear "not supported" toast on web/remote), and all the localization plumbing. Stale tokens are cleared automatically when OAuth metadata changes, and federated integrations skip the kernel-startup env-var batch so the per-cell refresh path owns credential delivery. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 9 + src/kernels/execution/cellExecution.ts | 10 +- .../federatedAuthCommandHandler.node.ts | 153 ++++ ...eratedAuthCommandHandler.node.unit.test.ts | 222 ++++++ .../federatedAuthCommandHandler.web.ts | 29 + ...deratedAuthCommandHandler.web.unit.test.ts | 49 ++ .../federatedAuthTokenStorage.node.ts | 10 + .../integrations/integrationWebview.ts | 223 +++++- .../integrationWebview.unit.test.ts | 714 ++++++++++++++++++ src/notebooks/deepnote/integrations/types.ts | 20 + src/notebooks/serviceRegistry.node.ts | 5 + src/notebooks/serviceRegistry.web.ts | 5 + src/platform/common/utils/localize.ts | 41 + ...IntegrationEnvironmentVariablesProvider.ts | 49 +- ...nEnvironmentVariablesProvider.unit.test.ts | 78 ++ .../integrations/BigQueryForm.tsx | 230 +++++- .../integrations/IntegrationItem.tsx | 43 +- .../integrations/IntegrationList.tsx | 10 +- .../integrations/IntegrationPanel.tsx | 23 +- 19 files changed, 1856 insertions(+), 67 deletions(-) create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts create mode 100644 src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts create mode 100644 src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts diff --git a/package.json b/package.json index beaf643127..ba1a371099 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,11 @@ "category": "Deepnote", "icon": "$(plug)" }, + { + "command": "deepnote.authenticateIntegration", + "title": "%deepnote.commands.authenticateIntegration.title%", + "category": "Deepnote" + }, { "command": "deepnote.openInDeepnote", "title": "Open in Deepnote", @@ -1199,6 +1204,10 @@ "title": "%deepnote.commandPalette.deepnote.replayPylanceLog.title%", "when": "deepnote.development && isWorkspaceTrusted" }, + { + "command": "deepnote.authenticateIntegration", + "when": "false" + }, { "command": "deepnote.manageAccessToKernels", "title": "%deepnote.command.manageAccessToKernels%" diff --git a/src/kernels/execution/cellExecution.ts b/src/kernels/execution/cellExecution.ts index b514ee8b56..dbb1bd6587 100644 --- a/src/kernels/execution/cellExecution.ts +++ b/src/kernels/execution/cellExecution.ts @@ -38,6 +38,7 @@ import { IFederatedAuthSqlBlockCodeGenerator, NotAuthenticatedError } from '../../notebooks/deepnote/integrations/types'; +import { Integrations } from '../../platform/common/utils/localize'; /** * Factory for CellExecution objects. @@ -438,14 +439,7 @@ export class CellExecution implements ICellExecution, IDisposable { logger.warn( `Federated BigQuery integration "${ex.integrationName}" is not authenticated; cell Index ${this.cell.index} cannot run.` ); - // TODO(m4-l10n): wire through localize.ts once - // bundle.l10n.json runtime wiring lands in M4. Until then the - // hardcoded English string keeps the UX intelligible. - return this.completedWithErrors( - new Error( - 'BigQuery integration is not authenticated. Click "Authenticate with Google" in the integration panel to continue.' - ) - ); + return this.completedWithErrors(new Error(Integrations.bigQueryNotAuthenticated(ex.integrationName))); } logger.error(`Federated SQL code generation failed for cell Index ${this.cell.index}`, ex); // Narrow the catch-variable to a shape that satisfies diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts new file mode 100644 index 0000000000..74c572867a --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts @@ -0,0 +1,153 @@ +import { inject, injectable } from 'inversify'; +import { CancellationError, CancellationToken, ProgressLocation, Uri, commands, env, window } from 'vscode'; + +import { BigQueryAuthMethods } from '@deepnote/database-integrations'; + +import { IExtensionSyncActivationService } from '../../../../platform/activation/types'; +import { Commands } from '../../../../platform/common/constants'; +import { IExtensionContext } from '../../../../platform/common/types'; +import { Integrations } from '../../../../platform/common/utils/localize'; +import { logger } from '../../../../platform/logging'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { IFederatedAuthTokenStorage, type FederatedAuthTokenEntry } from '../types'; +import { buildBigQueryGoogleOAuthStrategy, createInMemoryPkceStore } from './googleOAuthProvider.node'; +import { computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; +import { runOAuthFlow, type RunOAuthFlowParams } from './oauthLoopbackFlow.node'; + +/** + * Function signature of {@link runOAuthFlow}. Exposed as a constructor seam + * so unit tests can inject a stub without monkey-patching the real + * implementation (which would also boot an `express` loopback server). + */ +export type RunOAuthFlowFn = (params: RunOAuthFlowParams) => Promise<{ refreshToken: string }>; + +/** + * Node-side command handler for `deepnote.authenticateIntegration`. + * + * Looks up the requested integration, validates that it's a BigQuery + * integration configured for Google OAuth, then runs the loopback OAuth + * flow built on `passport-google-oauth20`. On success the refresh token is + * persisted via {@link IFederatedAuthTokenStorage}; on cancellation the + * command exits silently; on any other failure it shows a localized error + * toast. + * + * Remote VS Code (SSH-remote, Codespaces, WSL) is not supported in this + * milestone — Google "Desktop app" OAuth clients only accept + * `http://127.0.0.1:/auth/callback` redirects, and tunneling the + * callback through `asExternalUri` produces an `https://*.vscode.dev/...` + * URL that Google rejects. We surface a clear message and exit early. + */ +@injectable() +export class FederatedAuthCommandHandlerNode implements IExtensionSyncActivationService { + constructor( + @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, + @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, + @inject(IFederatedAuthTokenStorage) private readonly tokenStorage: IFederatedAuthTokenStorage, + private readonly runOAuthFlowFn: RunOAuthFlowFn = runOAuthFlow + ) {} + + public activate(): void { + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.AuthenticateIntegration, (integrationId: string) => + this.authenticate(integrationId) + ) + ); + } + + /** + * Core flow. Public so tests can drive the handler without going + * through `commands.executeCommand`. + */ + public async authenticate(integrationId: string): Promise { + if (typeof integrationId !== 'string' || integrationId.length === 0) { + logger.warn( + `FederatedAuthCommandHandlerNode: invoked without a valid integrationId (received: ${String( + integrationId + )})` + ); + return; + } + + // Remote VS Code is not supported — see class comment. + if (env.remoteName !== undefined) { + logger.info( + `FederatedAuthCommandHandlerNode: remote scenario detected (${env.remoteName}); aborting federated auth.` + ); + void window.showInformationMessage(Integrations.federatedAuthNotSupportedInRemote); + return; + } + + const integration = await this.integrationStorage.getIntegrationConfig(integrationId); + if (!integration) { + logger.warn(`FederatedAuthCommandHandlerNode: integration "${integrationId}" not found.`); + void window.showErrorMessage(Integrations.federatedAuthIntegrationNotFound(integrationId)); + return; + } + + if (integration.type !== 'big-query' || integration.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + logger.warn( + `FederatedAuthCommandHandlerNode: integration "${integration.name}" is not configured for Google OAuth.` + ); + void window.showErrorMessage(Integrations.federatedAuthIntegrationNotConfiguredForOAuth(integration.name)); + return; + } + + const { clientId, clientSecret, project } = integration.metadata; + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId, + clientSecret, + store: createInMemoryPkceStore() + }); + + try { + const refreshTokenResult = await window.withProgress( + { + location: ProgressLocation.Notification, + title: Integrations.authenticating(integration.name), + cancellable: true + }, + async (_progress, token: CancellationToken) => { + return this.runOAuthFlowFn({ + integrationId, + strategy, + completion, + token, + onListening: async (startUrl: string) => { + try { + const externalUri = await env.asExternalUri(Uri.parse(startUrl)); + const opened = await env.openExternal(externalUri); + if (!opened) { + logger.warn( + `FederatedAuthCommandHandlerNode: openExternal returned false for ${startUrl}; the user can paste the URL manually.` + ); + } + } catch (err) { + logger.warn( + `FederatedAuthCommandHandlerNode: failed to open browser for ${startUrl}.`, + err + ); + } + } + }); + } + ); + + const entry: FederatedAuthTokenEntry = { + integrationId, + refreshToken: refreshTokenResult.refreshToken, + metadataFingerprint: computeMetadataFingerprint({ clientId, clientSecret, project }) + }; + await this.tokenStorage.save(entry); + + void window.showInformationMessage(Integrations.authenticationSucceeded(integration.name)); + } catch (err) { + if (err instanceof CancellationError) { + logger.info(`FederatedAuthCommandHandlerNode: authentication cancelled for "${integration.name}".`); + return; + } + const message = err instanceof Error ? err.message : String(err); + logger.error(`FederatedAuthCommandHandlerNode: authentication failed for "${integration.name}".`, err); + void window.showErrorMessage(Integrations.authenticationFailed(message)); + } + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts new file mode 100644 index 0000000000..0ce188f081 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts @@ -0,0 +1,222 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { CancellationError, CancellationTokenSource } from 'vscode'; +import { anyString, anything, instance, mock, reset, when } from 'ts-mockito'; + +import { ConfigurableDatabaseIntegrationConfig } from '../../../../platform/notebooks/deepnote/integrationTypes'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { IExtensionContext, IDisposable } from '../../../../platform/common/types'; +import { FederatedAuthCommandHandlerNode } from './federatedAuthCommandHandler.node'; +import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage } from '../types'; +import { computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../../test/vscode-mock'; +import type { RunOAuthFlowParams } from './oauthLoopbackFlow.node'; + +suite('FederatedAuthCommandHandlerNode', () => { + const INTEGRATION_ID = 'bq-1'; + const PROJECT = 'my-gcp-project'; + const CLIENT_ID = 'client-abc'; + const CLIENT_SECRET = 'secret-xyz'; + const REFRESH_TOKEN = 'refresh-token-value'; + + let extensionContext: IExtensionContext; + let integrationStorage: IIntegrationStorage; + let tokenStorage: IFederatedAuthTokenStorage; + let subscriptions: IDisposable[]; + let runOAuthFlowStub: sinon.SinonStub<[RunOAuthFlowParams], Promise<{ refreshToken: string }>>; + let handler: FederatedAuthCommandHandlerNode; + + let integrationStore: Map; + let savedTokens: FederatedAuthTokenEntry[]; + + setup(() => { + resetVSCodeMocks(); + subscriptions = []; + integrationStore = new Map(); + savedTokens = []; + + extensionContext = mock(); + when(extensionContext.subscriptions).thenReturn(subscriptions); + + integrationStorage = { + getIntegrationConfig: async (id: string) => integrationStore.get(id) + } as unknown as IIntegrationStorage; + + tokenStorage = { + save: async (entry: FederatedAuthTokenEntry) => { + savedTokens.push(entry); + } + } as unknown as IFederatedAuthTokenStorage; + + runOAuthFlowStub = sinon.stub<[RunOAuthFlowParams], Promise<{ refreshToken: string }>>(); + runOAuthFlowStub.resolves({ refreshToken: REFRESH_TOKEN }); + + // env.asExternalUri returns the input untouched in the mock. + when(mockedVSCodeNamespaces.env.asExternalUri(anything())).thenCall((uri) => Promise.resolve(uri)); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenResolve(true as unknown as void); + + handler = new FederatedAuthCommandHandlerNode( + instance(extensionContext), + integrationStorage, + tokenStorage, + runOAuthFlowStub + ); + }); + + teardown(() => { + // Note: tests intentionally configure env.remoteName per-test; + // resetVSCodeMocks() in setup clears stale state. + reset(mockedVSCodeNamespaces.env); + reset(mockedVSCodeNamespaces.window); + }); + + function setupValidGoogleOauthIntegration(): void { + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'My BigQuery', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: PROJECT, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET + } + } as ConfigurableDatabaseIntegrationConfig); + } + + test('shows remote-not-supported toast and does not start the OAuth flow when env.remoteName is set', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn('ssh-remote'); + setupValidGoogleOauthIntegration(); + + await handler.authenticate(INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 0, 'runOAuthFlow should not have been called'); + assert.lengthOf(savedTokens, 0, 'no token should be saved'); + }); + + test('shows error toast for a non-existent integration', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + + await handler.authenticate('unknown-integration-id'); + + assert.strictEqual(runOAuthFlowStub.callCount, 0); + assert.lengthOf(savedTokens, 0); + }); + + test('shows error toast for a non-BigQuery integration', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'My Postgres', + type: 'pgsql', + metadata: { + host: 'localhost', + port: '5432', + database: 'db', + user: 'u', + password: 'p', + sslEnabled: false + } + } as ConfigurableDatabaseIntegrationConfig); + + await handler.authenticate(INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 0); + assert.lengthOf(savedTokens, 0); + }); + + test('shows error toast for a service-account BigQuery integration', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + integrationStore.set(INTEGRATION_ID, { + id: INTEGRATION_ID, + name: 'SA BigQuery', + type: 'big-query', + metadata: { + authMethod: 'service-account', + service_account: '{}' + } + } as ConfigurableDatabaseIntegrationConfig); + + await handler.authenticate(INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 0); + assert.lengthOf(savedTokens, 0); + }); + + test('happy path: saves the captured refresh token with a fresh fingerprint and surfaces the success toast', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + setupValidGoogleOauthIntegration(); + + await handler.authenticate(INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 1); + assert.lengthOf(savedTokens, 1); + assert.deepStrictEqual(savedTokens[0], { + integrationId: INTEGRATION_ID, + refreshToken: REFRESH_TOKEN, + metadataFingerprint: computeMetadataFingerprint({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + project: PROJECT + }) + }); + + // Sanity-check that the strategy + completion were threaded through. + const callArg = runOAuthFlowStub.firstCall.args[0]; + assert.strictEqual(callArg.integrationId, INTEGRATION_ID); + assert.isFunction(callArg.onListening); + }); + + test('silently returns when the user cancels the flow', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + setupValidGoogleOauthIntegration(); + runOAuthFlowStub.rejects(new CancellationError()); + + await handler.authenticate(INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 1); + assert.lengthOf(savedTokens, 0); + }); + + test('surfaces a generic OAuth error via the failure toast and does not save a token', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + setupValidGoogleOauthIntegration(); + runOAuthFlowStub.rejects(new Error('boom')); + + await handler.authenticate(INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 1); + assert.lengthOf(savedTokens, 0); + }); + + test('activate registers the command and pushes a disposable into the extension context subscriptions', () => { + when(mockedVSCodeNamespaces.commands.registerCommand(anyString(), anything())).thenReturn({ + dispose: () => undefined + } as IDisposable); + + handler.activate(); + + assert.strictEqual(subscriptions.length, 1, 'one disposable subscription should be registered'); + }); + + test('cancellation token from withProgress is threaded into runOAuthFlow', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + setupValidGoogleOauthIntegration(); + + // Drive the withProgress mock with a pre-cancelled token so we can + // assert it lands in runOAuthFlow's params. + const tokenSource = new CancellationTokenSource(); + try { + when(mockedVSCodeNamespaces.window.withProgress(anything(), anything())).thenCall((_options, callback) => + Promise.resolve(callback({ report: () => undefined }, tokenSource.token)) + ); + + await handler.authenticate(INTEGRATION_ID); + + const callArg = runOAuthFlowStub.firstCall.args[0]; + assert.strictEqual(callArg.token, tokenSource.token); + } finally { + tokenSource.dispose(); + } + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts new file mode 100644 index 0000000000..accd0cee8a --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts @@ -0,0 +1,29 @@ +import { inject, injectable } from 'inversify'; +import { commands, window } from 'vscode'; + +import { IExtensionSyncActivationService } from '../../../../platform/activation/types'; +import { Commands } from '../../../../platform/common/constants'; +import { IExtensionContext } from '../../../../platform/common/types'; +import { Integrations } from '../../../../platform/common/utils/localize'; + +/** + * Web-side command handler for `deepnote.authenticateIntegration`. + * + * The OAuth loopback flow (Step 5 of the M2 plan) depends on Node's `http`, + * `express`, and `passport`, none of which run in the web extension host. + * Rather than bind nothing on web — which would make the command id throw + * `command 'deepnote.authenticateIntegration' not found` — we register a + * stub that shows a localized "not supported in web" toast. + */ +@injectable() +export class FederatedAuthCommandHandlerWeb implements IExtensionSyncActivationService { + constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + + public activate(): void { + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.AuthenticateIntegration, () => { + void window.showInformationMessage(Integrations.federatedAuthNotSupportedInWeb); + }) + ); + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts new file mode 100644 index 0000000000..3aa06015f0 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts @@ -0,0 +1,49 @@ +import { assert } from 'chai'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; + +import { IDisposable, IExtensionContext } from '../../../../platform/common/types'; +import { Commands } from '../../../../platform/common/constants'; +import { FederatedAuthCommandHandlerWeb } from './federatedAuthCommandHandler.web'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../../test/vscode-mock'; + +suite('FederatedAuthCommandHandlerWeb', () => { + let extensionContext: IExtensionContext; + let subscriptions: IDisposable[]; + let registeredCallback: ((...args: unknown[]) => unknown) | undefined; + let handler: FederatedAuthCommandHandlerWeb; + + setup(() => { + resetVSCodeMocks(); + subscriptions = []; + registeredCallback = undefined; + + extensionContext = mock(); + when(extensionContext.subscriptions).thenReturn(subscriptions); + + when(mockedVSCodeNamespaces.commands.registerCommand(anyString(), anything())).thenCall( + (_command: string, callback: (...args: unknown[]) => unknown) => { + registeredCallback = callback; + return { dispose: () => undefined } as IDisposable; + } + ); + + handler = new FederatedAuthCommandHandlerWeb(instance(extensionContext)); + }); + + test('activate registers the AuthenticateIntegration command with the extension context', () => { + handler.activate(); + + verify(mockedVSCodeNamespaces.commands.registerCommand(Commands.AuthenticateIntegration, anything())).once(); + assert.strictEqual(subscriptions.length, 1); + }); + + test('the registered command surfaces the not-supported-in-web information toast', () => { + handler.activate(); + assert.isDefined(registeredCallback, 'command callback should have been captured'); + + // Invoke the command — should not throw and should show the toast. + registeredCallback!('some-integration-id'); + + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts index d8e44ef38d..f50cfb06b8 100644 --- a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts @@ -195,6 +195,16 @@ export class FederatedAuthTokenStorage implements IFederatedAuthTokenStorage { asyncRegistry.push(this); } + /** + * Instance method form of {@link computeMetadataFingerprint}, exposed so + * cross-platform callers (e.g. {@link IntegrationWebviewProvider}) can + * fingerprint OAuth-client metadata via the injected token-storage + * instance instead of importing the node-only helper directly. + */ + public computeMetadataFingerprint(metadata: { clientId: string; clientSecret: string; project: string }): string { + return computeMetadataFingerprint(metadata); + } + public async delete(integrationId: string): Promise { await this.ensureCacheLoaded(); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 3daf4dc479..9c3ddc794c 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -1,14 +1,18 @@ -import { inject, injectable } from 'inversify'; -import { Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { inject, injectable, optional } from 'inversify'; +import { commands, Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { BigQueryAuthMethods } from '@deepnote/database-integrations'; + +import { Commands } from '../../../platform/common/constants'; import { IExtensionContext } from '../../../platform/common/types'; import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; -import { IIntegrationStorage, IIntegrationWebviewProvider } from './types'; +import { IFederatedAuthTokenStorage, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; import { ConfigurableDatabaseIntegrationConfig, + FederatedAuthTokenStatus, IntegrationStatus, IntegrationWithStatus } from '../../../platform/notebooks/deepnote/integrationTypes'; @@ -26,11 +30,43 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private projectId: string | undefined; + /** + * Monotonically-incremented per `updateWebview()` call. Used as a + * "latest call wins" generation counter so an overlapping in-flight + * update that resolves last cannot overwrite a fresher one. + */ + private updateGeneration = 0; + + /** + * Long-lived disposables — notably the `onDidChangeTokens` subscription — + * that must survive panel close/reopen. The provider is a DI singleton, + * so the constructor runs once per extension lifetime; if we lumped this + * subscription into `this.disposables`, the panel's `onDidDispose` + * handler would tear it down and we'd never re-subscribe. + */ + private readonly tokenStorageDisposables: Disposable[] = []; + constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, - @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager - ) {} + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(IFederatedAuthTokenStorage) + @optional() + private readonly tokenStorage?: IFederatedAuthTokenStorage + ) { + // Refresh the webview whenever the token storage signals a change — + // so the pill flips between "Authenticated" and "Not authenticated" + // without requiring the user to reload the panel manually. Kept in + // `tokenStorageDisposables` (NOT `disposables`) so the subscription + // survives panel close/reopen. + if (this.tokenStorage) { + this.tokenStorageDisposables.push( + this.tokenStorage.onDidChangeTokens(() => { + void this.updateWebview(); + }) + ); + } + } /** * Show the integration management webview @@ -177,6 +213,28 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { integrationsBigQueryCredentialsLabel: localize.Integrations.bigQueryCredentialsLabel, integrationsBigQueryCredentialsPlaceholder: localize.Integrations.bigQueryCredentialsPlaceholder, integrationsBigQueryCredentialsRequired: localize.Integrations.bigQueryCredentialsRequired, + // BigQuery federated-auth form strings (M4) + integrationsBigQueryAuthMethodLabel: localize.Integrations.bigQueryAuthMethodLabel, + integrationsBigQueryAuthMethodServiceAccount: localize.Integrations.bigQueryAuthMethodServiceAccount, + integrationsBigQueryAuthMethodGoogleOauth: localize.Integrations.bigQueryAuthMethodGoogleOauth, + integrationsBigQueryProjectLabel: localize.Integrations.bigQueryProjectLabel, + integrationsBigQueryProjectPlaceholder: localize.Integrations.bigQueryProjectPlaceholder, + integrationsBigQueryClientIdLabel: localize.Integrations.bigQueryClientIdLabel, + integrationsBigQueryClientIdPlaceholder: localize.Integrations.bigQueryClientIdPlaceholder, + integrationsBigQueryClientSecretLabel: localize.Integrations.bigQueryClientSecretLabel, + integrationsBigQueryClientSecretPlaceholder: localize.Integrations.bigQueryClientSecretPlaceholder, + integrationsBigQueryGoogleOauthHelp: localize.Integrations.bigQueryGoogleOauthHelp, + // Federated-auth integration management strings (M4) + integrationsAuthenticate: localize.Integrations.authenticate, + integrationsReauthenticate: localize.Integrations.reauthenticate, + integrationsTokenStatusAuthenticated: localize.Integrations.tokenStatusAuthenticated, + integrationsTokenStatusDisconnected: localize.Integrations.tokenStatusDisconnected, + integrationsAuthenticating: localize.Integrations.authenticating('{0}'), + integrationsAuthenticationSucceeded: localize.Integrations.authenticationSucceeded('{0}'), + integrationsAuthenticationFailed: localize.Integrations.authenticationFailed('{0}'), + integrationsBigQueryNotAuthenticated: localize.Integrations.bigQueryNotAuthenticated('{0}'), + integrationsFederatedAuthNotSupportedInWeb: localize.Integrations.federatedAuthNotSupportedInWeb, + integrationsFederatedAuthNotSupportedInRemote: localize.Integrations.federatedAuthNotSupportedInRemote, integrationsSnowflakeNameLabel: localize.Integrations.snowflakeNameLabel, integrationsSnowflakeNamePlaceholder: localize.Integrations.snowflakeNamePlaceholder, integrationsSnowflakeAccountLabel: localize.Integrations.snowflakeAccountLabel, @@ -392,7 +450,13 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { } /** - * Update the webview with current integration data + * Update the webview with current integration data. + * + * Race safety: overlapping `updateWebview()` calls are possible — e.g. + * `show()` runs one inline while `onDidChangeTokens` fires another from + * inside `tokenStorage.save()`. We assign each call a generation number + * and bail out at every async boundary if either (a) a newer generation + * has started or (b) the panel was disposed during the await. */ private async updateWebview(): Promise { if (!this.currentPanel) { @@ -400,13 +464,37 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { return; } - const integrationsData = Array.from(this.integrations.entries()).map(([id, integration]) => ({ - config: integration.config, - id, - integrationName: integration.integrationName, - integrationType: integration.integrationType, - status: integration.status - })); + this.updateGeneration += 1; + const generation = this.updateGeneration; + + const integrationsData = await Promise.all( + Array.from(this.integrations.entries()).map(async ([id, integration]) => ({ + config: integration.config, + id, + integrationName: integration.integrationName, + integrationType: integration.integrationType, + status: integration.status, + tokenStatus: await this.deriveTokenStatus(id, integration.config) + })) + ); + + // The panel may have been disposed (e.g. user closed it) while the + // tokenStorage.has() round-trip was in flight; bail before + // dereferencing `this.currentPanel.webview`. + if (!this.currentPanel) { + logger.debug('IntegrationWebviewProvider: Panel disposed during update, skipping postMessage'); + return; + } + + // A newer updateWebview() call started while we awaited; let it + // post the fresher state instead of us posting stale data. + if (generation !== this.updateGeneration) { + logger.debug( + `IntegrationWebviewProvider: Superseded by newer update (gen ${generation} < ${this.updateGeneration}), skipping postMessage` + ); + return; + } + logger.debug(`IntegrationWebviewProvider: Sending ${integrationsData.length} integrations to webview`); // Get the project name from the notebook manager @@ -423,8 +511,88 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { }); } + /** + * Invalidates any stored federated refresh token whose + * OAuth-client-metadata fingerprint no longer matches the incoming + * config. Also drops the token when the new config switches away from + * `google-oauth` entirely (or away from `big-query`). No-ops when no + * token storage is bound, or when no token is stored for this + * integration. + */ + private async invalidateStaleFederatedToken( + integrationId: string, + newConfig: ConfigurableDatabaseIntegrationConfig + ): Promise { + if (!this.tokenStorage) { + return; + } + + const stored = await this.tokenStorage.get(integrationId); + if (!stored) { + return; + } + + // Switched away from google-oauth (or to a different integration type): + // the previously-captured token is meaningless against the new metadata. + if (newConfig.type !== 'big-query' || newConfig.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + logger.info( + `IntegrationWebviewProvider: deleting stale federated token for ${integrationId} (auth method changed).` + ); + await this.tokenStorage.delete(integrationId); + return; + } + + // Same auth method but the OAuth client metadata changed: fingerprint + // mismatch means the stored refresh token was issued against a + // different client and is no longer valid. + const { clientId, clientSecret, project } = newConfig.metadata; + const newFingerprint = this.tokenStorage.computeMetadataFingerprint({ clientId, clientSecret, project }); + if (newFingerprint !== stored.metadataFingerprint) { + logger.info( + `IntegrationWebviewProvider: deleting stale federated token for ${integrationId} (fingerprint changed).` + ); + await this.tokenStorage.delete(integrationId); + } + } + + /** + * Derive the federated-auth token status for an integration. Returns + * `'unsupported'` when: + * - the token storage is not bound (web build, or node before + * `IFederatedAuthTokenStorage` is registered), + * - the integration is not BigQuery, or + * - the integration's `authMethod` is not `'google-oauth'`. + * Otherwise consults the token storage and returns `'authenticated'` + * if a token entry exists for the integration, `'disconnected'` if not. + */ + private async deriveTokenStatus( + integrationId: string, + config: ConfigurableDatabaseIntegrationConfig | null + ): Promise { + if (!this.tokenStorage) { + return 'unsupported'; + } + if (!config || config.type !== 'big-query' || config.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + return 'unsupported'; + } + try { + const hasToken = await this.tokenStorage.has(integrationId); + return hasToken ? 'authenticated' : 'disconnected'; + } catch (err) { + logger.warn( + `IntegrationWebviewProvider: failed to check token for ${integrationId}; reporting disconnected.`, + err + ); + return 'disconnected'; + } + } + /** * Handle messages from the webview + * + * The shape mirrors the webview-side `WebviewOutboundMessage` discriminated + * union in `src/webviews/webview-side/integrations/types.ts`. Every case + * tag from that union should be handled here. */ private async handleMessage(message: { type: string; @@ -452,6 +620,21 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { await this.deleteConfiguration(message.integrationId); } break; + case 'authenticate': + if (message.integrationId) { + try { + await commands.executeCommand(Commands.AuthenticateIntegration, message.integrationId); + } catch (error) { + // The command handler shows its own user-facing toasts; + // we just log here so a rejection doesn't surface as an + // unhandled-promise warning in the extension host. + logger.error( + `IntegrationWebviewProvider: AuthenticateIntegration command failed for ${message.integrationId}`, + error + ); + } + } + break; } } @@ -481,6 +664,18 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { config: ConfigurableDatabaseIntegrationConfig ): Promise { try { + // Before persisting the new config, invalidate any stale federated + // refresh token. There are two cases: + // - new config is `google-oauth`, but the OAuth-client metadata + // (clientId/clientSecret/project) changed since the token was + // captured. The stored fingerprint no longer matches the new + // config, so the next `generate()` would fail anyway. Delete + // up front so the UI pill flips to "Not authenticated". + // - new config is NOT `google-oauth` (or no longer `big-query`). + // The previously-captured token is meaningless against the + // new auth method; drop it. + await this.invalidateStaleFederatedToken(integrationId, config); + await this.integrationStorage.save(config); // Update local state @@ -528,6 +723,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private async resetConfiguration(integrationId: string): Promise { try { await this.integrationStorage.delete(integrationId); + await this.tokenStorage?.delete(integrationId); // Update local state const integration = this.integrations.get(integrationId); @@ -563,6 +759,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private async deleteConfiguration(integrationId: string): Promise { try { await this.integrationStorage.delete(integrationId); + await this.tokenStorage?.delete(integrationId); // Remove from local state this.integrations.delete(integrationId); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts new file mode 100644 index 0000000000..9381676e6b --- /dev/null +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -0,0 +1,714 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { EventEmitter, Uri } from 'vscode'; +import { anyString, anything, capture, instance, mock, reset, verify, when } from 'ts-mockito'; + +import { IExtensionContext, IDisposable } from '../../../platform/common/types'; +import { Commands } from '../../../platform/common/constants'; +import { IDeepnoteNotebookManager } from '../../types'; +import { IntegrationWebviewProvider } from './integrationWebview'; +import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage, IIntegrationStorage } from './types'; +import { + ConfigurableDatabaseIntegrationConfig, + IntegrationStatus, + IntegrationWithStatus +} from '../../../platform/notebooks/deepnote/integrationTypes'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; + +// Minimal in-memory token-storage stub for tests. Mirrors the real +// FederatedAuthTokenStorage interface enough to drive +// IntegrationWebviewProvider's federated-auth code paths without pulling in +// the node-only implementation file. +function createFakeTokenStorage(): { + storage: IFederatedAuthTokenStorage; + tokens: Map; + fingerprintForTest: (metadata: { clientId: string; clientSecret: string; project: string }) => string; + deletedIds: string[]; + onDidChangeEmitter: EventEmitter; +} { + const tokens = new Map(); + const deletedIds: string[] = []; + const onDidChangeEmitter = new EventEmitter(); + const fingerprintForTest = (m: { clientId: string; clientSecret: string; project: string }): string => + `${m.clientId}|${m.clientSecret}|${m.project}`; + const storage: IFederatedAuthTokenStorage = { + onDidChangeTokens: onDidChangeEmitter.event, + async get(integrationId: string) { + return tokens.get(integrationId); + }, + async has(integrationId: string) { + return tokens.has(integrationId); + }, + async save(entry: FederatedAuthTokenEntry) { + tokens.set(entry.integrationId, entry); + onDidChangeEmitter.fire(entry.integrationId); + }, + async delete(integrationId: string) { + const had = tokens.delete(integrationId); + deletedIds.push(integrationId); + if (had) { + onDidChangeEmitter.fire(integrationId); + } + }, + computeMetadataFingerprint(metadata) { + return fingerprintForTest(metadata); + } + }; + return { storage, tokens, fingerprintForTest, deletedIds, onDidChangeEmitter }; +} + +interface CapturedMessage { + type: string; + integrations?: Array<{ id: string; tokenStatus?: string }>; + [key: string]: unknown; +} + +interface FakeWebviewPanel { + panel: import('vscode').WebviewPanel; + posted: CapturedMessage[]; + onDidReceiveMessage: (message: unknown) => Promise; + triggerDispose: () => void; + setPostMessageImpl: (impl: (message: CapturedMessage) => Promise) => void; +} + +function createFakeWebviewPanel(): FakeWebviewPanel { + const posted: CapturedMessage[] = []; + let messageHandler: ((message: unknown) => Promise | void) | undefined; + let onDidDisposeCb: (() => void) | undefined; + let postMessageImpl: (message: CapturedMessage) => Promise = async (message) => { + posted.push(message); + return true; + }; + const webview = { + html: '', + cspSource: 'mock-csp', + asWebviewUri: (uri: unknown) => uri, + postMessage: (message: CapturedMessage) => postMessageImpl(message), + onDidReceiveMessage: ( + cb: (message: unknown) => Promise | void, + _thisArg?: unknown, + disposables?: IDisposable[] + ): IDisposable => { + messageHandler = cb; + const disposable: IDisposable = { dispose: () => undefined }; + disposables?.push(disposable); + return disposable; + } + }; + const panel = { + webview, + reveal: () => undefined, + dispose: () => undefined, + onDidDispose: (cb: () => void, _thisArg?: unknown, disposables?: IDisposable[]): IDisposable => { + onDidDisposeCb = cb; + const disposable: IDisposable = { dispose: () => undefined }; + disposables?.push(disposable); + return disposable; + } + }; + return { + panel: panel as unknown as import('vscode').WebviewPanel, + posted, + onDidReceiveMessage: async (message: unknown) => { + if (messageHandler) { + await messageHandler(message); + } + }, + triggerDispose: () => { + if (onDidDisposeCb) { + onDidDisposeCb(); + } + }, + setPostMessageImpl: (impl) => { + postMessageImpl = impl; + } + }; +} + +suite('IntegrationWebviewProvider', () => { + const PROJECT_ID = 'project-id-1'; + + let extensionContext: IExtensionContext; + let integrationStorage: IIntegrationStorage; + let notebookManager: IDeepnoteNotebookManager; + let fakeTokenStorage: ReturnType; + let extensionSubscriptions: IDisposable[]; + let fakePanel: FakeWebviewPanel; + + setup(() => { + resetVSCodeMocks(); + extensionContext = mock(); + integrationStorage = mock(); + notebookManager = mock(); + extensionSubscriptions = []; + when(extensionContext.subscriptions).thenReturn(extensionSubscriptions); + when(extensionContext.extensionUri).thenReturn(Uri.file('/ext')); + + fakeTokenStorage = createFakeTokenStorage(); + fakePanel = createFakeWebviewPanel(); + + when( + mockedVSCodeNamespaces.window.createWebviewPanel(anyString(), anyString(), anything(), anything()) + ).thenReturn(fakePanel.panel); + }); + + teardown(() => { + reset(mockedVSCodeNamespaces.window); + reset(mockedVSCodeNamespaces.commands); + }); + + function makeBigQueryGoogleOauthConfig(id: string): ConfigurableDatabaseIntegrationConfig { + return { + id, + name: 'My BQ', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: 'proj', + clientId: 'client-id', + clientSecret: 'client-secret' + } + } as ConfigurableDatabaseIntegrationConfig; + } + + function makeBigQueryServiceAccountConfig(id: string): ConfigurableDatabaseIntegrationConfig { + return { + id, + name: 'My SA BQ', + type: 'big-query', + metadata: { authMethod: 'service-account', service_account: '{}' } + } as ConfigurableDatabaseIntegrationConfig; + } + + function makePostgresConfig(id: string): ConfigurableDatabaseIntegrationConfig { + return { + id, + name: 'My PG', + type: 'pgsql', + metadata: { + host: 'localhost', + port: '5432', + database: 'db', + user: 'u', + password: 'p', + sslEnabled: false + } + } as ConfigurableDatabaseIntegrationConfig; + } + + async function show(provider: IntegrationWebviewProvider, integrations: Map) { + await provider.show(PROJECT_ID, integrations); + } + + test('updateWebview: tokenStatus is "unsupported" for every integration when tokenStorage is undefined', async () => { + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager) + ); + + const integrations: Map = new Map([ + ['bq-1', { config: makeBigQueryGoogleOauthConfig('bq-1'), status: IntegrationStatus.Connected }], + ['pg-1', { config: makePostgresConfig('pg-1'), status: IntegrationStatus.Connected }] + ]); + + await show(provider, integrations); + + const updates = fakePanel.posted.filter((m) => m.type === 'update'); + assert.isNotEmpty(updates); + const last = updates[updates.length - 1]; + assert.lengthOf(last.integrations || [], 2); + for (const integration of last.integrations || []) { + assert.strictEqual(integration.tokenStatus, 'unsupported'); + } + }); + + test('updateWebview: tokenStatus is "unsupported" for service-account BigQuery and Postgres', async () => { + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrations: Map = new Map([ + ['bq-sa', { config: makeBigQueryServiceAccountConfig('bq-sa'), status: IntegrationStatus.Connected }], + ['pg-1', { config: makePostgresConfig('pg-1'), status: IntegrationStatus.Connected }] + ]); + + await show(provider, integrations); + + const last = fakePanel.posted.filter((m) => m.type === 'update').pop()!; + const byId = new Map((last.integrations || []).map((i) => [i.id, i.tokenStatus])); + assert.strictEqual(byId.get('bq-sa'), 'unsupported'); + assert.strictEqual(byId.get('pg-1'), 'unsupported'); + }); + + test('updateWebview: BigQuery + google-oauth + stored token -> tokenStatus "authenticated"', async () => { + const integrationId = 'bq-1'; + fakeTokenStorage.tokens.set(integrationId, { + integrationId, + refreshToken: 'r', + metadataFingerprint: 'whatever' + }); + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + + await show(provider, integrations); + + const last = fakePanel.posted.filter((m) => m.type === 'update').pop()!; + const item = (last.integrations || []).find((i) => i.id === integrationId); + assert.strictEqual(item?.tokenStatus, 'authenticated'); + }); + + test('updateWebview: BigQuery + google-oauth + no stored token -> tokenStatus "disconnected"', async () => { + const integrationId = 'bq-2'; + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + + await show(provider, integrations); + + const last = fakePanel.posted.filter((m) => m.type === 'update').pop()!; + const item = (last.integrations || []).find((i) => i.id === integrationId); + assert.strictEqual(item?.tokenStatus, 'disconnected'); + }); + + test('onDidChangeTokens fires -> updateWebview is invoked again', async () => { + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrations: Map = new Map([ + ['bq-3', { config: makeBigQueryGoogleOauthConfig('bq-3'), status: IntegrationStatus.Connected }] + ]); + + await show(provider, integrations); + const updatesBefore = fakePanel.posted.filter((m) => m.type === 'update').length; + assert.isAtLeast(updatesBefore, 1); + + // Simulate a save -> change event fires -> webview should refresh. + await fakeTokenStorage.storage.save({ + integrationId: 'bq-3', + refreshToken: 'r', + metadataFingerprint: 'fp' + }); + // Yield to the async update. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const updatesAfter = fakePanel.posted.filter((m) => m.type === 'update').length; + assert.isAbove(updatesAfter, updatesBefore); + }); + + test('handleMessage: "authenticate" -> commands.executeCommand(AuthenticateIntegration, integrationId)', async () => { + const executeCommandStub = sinon.stub().resolves(undefined); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString(), anything())).thenCall((command, arg) => + executeCommandStub(command, arg) + ); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString())).thenCall((command) => + executeCommandStub(command) + ); + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrationId = 'bq-auth'; + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + await show(provider, integrations); + + await fakePanel.onDidReceiveMessage({ type: 'authenticate', integrationId }); + + assert.isTrue( + executeCommandStub.calledWith(Commands.AuthenticateIntegration, integrationId), + 'expected executeCommand to be called with AuthenticateIntegration and the integration id' + ); + }); + + test('resetConfiguration: deletes the federated token in addition to the integration config', async () => { + when(integrationStorage.delete(anyString())).thenResolve(); + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrationId = 'bq-reset'; + fakeTokenStorage.tokens.set(integrationId, { + integrationId, + refreshToken: 'r', + metadataFingerprint: 'fp' + }); + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + + await show(provider, integrations); + await fakePanel.onDidReceiveMessage({ type: 'reset', integrationId }); + + assert.includeMembers(fakeTokenStorage.deletedIds, [integrationId]); + verify(integrationStorage.delete(integrationId)).once(); + }); + + test('deleteConfiguration: deletes the federated token in addition to the integration config', async () => { + when(integrationStorage.delete(anyString())).thenResolve(); + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrationId = 'bq-del'; + fakeTokenStorage.tokens.set(integrationId, { + integrationId, + refreshToken: 'r', + metadataFingerprint: 'fp' + }); + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + + await show(provider, integrations); + await fakePanel.onDidReceiveMessage({ type: 'delete', integrationId }); + + assert.includeMembers(fakeTokenStorage.deletedIds, [integrationId]); + verify(integrationStorage.delete(integrationId)).once(); + }); + + test('saveConfiguration: deletes the token BEFORE save when fingerprint changes', async () => { + const integrationId = 'bq-save-fp'; + const saveOrder: string[] = []; + when(integrationStorage.save(anything())).thenCall(async () => { + saveOrder.push('storage.save'); + }); + // Re-bind the fake to capture delete order too. + const originalDelete = fakeTokenStorage.storage.delete.bind(fakeTokenStorage.storage); + fakeTokenStorage.storage.delete = async (id: string) => { + saveOrder.push(`token.delete:${id}`); + await originalDelete(id); + }; + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + // Existing token captured against the OLD fingerprint. + fakeTokenStorage.tokens.set(integrationId, { + integrationId, + refreshToken: 'r', + metadataFingerprint: 'old-fingerprint' + }); + + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + await show(provider, integrations); + + // Save a config that produces a DIFFERENT fingerprint than what's stored. + const newConfig: ConfigurableDatabaseIntegrationConfig = { + id: integrationId, + name: 'New name', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: 'new-proj', + clientId: 'new-client', + clientSecret: 'new-secret' + } + } as ConfigurableDatabaseIntegrationConfig; + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId, config: newConfig }); + + // Delete must happen BEFORE save. + const deleteIdx = saveOrder.findIndex((o) => o.startsWith('token.delete')); + const saveIdx = saveOrder.indexOf('storage.save'); + assert.notStrictEqual(deleteIdx, -1, 'token.delete should have been called'); + assert.notStrictEqual(saveIdx, -1, 'storage.save should have been called'); + assert.isBelow(deleteIdx, saveIdx, 'token.delete must occur BEFORE storage.save'); + }); + + test('saveConfiguration: deletes the token when authMethod switches away from google-oauth', async () => { + const integrationId = 'bq-switch'; + when(integrationStorage.save(anything())).thenResolve(); + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + fakeTokenStorage.tokens.set(integrationId, { + integrationId, + refreshToken: 'r', + metadataFingerprint: 'fp-1' + }); + + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + await show(provider, integrations); + + // Switch to service-account. + const newConfig = makeBigQueryServiceAccountConfig(integrationId); + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId, config: newConfig }); + + assert.includeMembers(fakeTokenStorage.deletedIds, [integrationId]); + assert.isFalse(fakeTokenStorage.tokens.has(integrationId)); + }); + + test('saveConfiguration: leaves the token intact when fingerprint matches', async () => { + const integrationId = 'bq-stable'; + when(integrationStorage.save(anything())).thenResolve(); + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const sameConfig = makeBigQueryGoogleOauthConfig(integrationId); + const stableFingerprint = fakeTokenStorage.fingerprintForTest({ + clientId: 'client-id', + clientSecret: 'client-secret', + project: 'proj' + }); + fakeTokenStorage.tokens.set(integrationId, { + integrationId, + refreshToken: 'r', + metadataFingerprint: stableFingerprint + }); + + const integrations: Map = new Map([ + [integrationId, { config: sameConfig, status: IntegrationStatus.Connected }] + ]); + await show(provider, integrations); + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId, config: sameConfig }); + + // No delete should have happened. + assert.notInclude(fakeTokenStorage.deletedIds, integrationId); + assert.isTrue(fakeTokenStorage.tokens.has(integrationId)); + }); + + test('onDidChangeTokens subscription survives panel close and reopen', async () => { + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrationId = 'bq-reopen'; + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + + // First open of the panel. + await show(provider, integrations); + assert.isAtLeast(fakePanel.posted.filter((m) => m.type === 'update').length, 1); + + // Simulate the user closing the panel: VS Code fires onDidDispose, + // which clears `this.disposables`. The token-change subscription + // MUST live in a separate slot and therefore survive. + fakePanel.triggerDispose(); + + // Open the panel a second time using a brand-new fake panel. We + // rebind the createWebviewPanel mock so `show()` gets the new one. + fakePanel = createFakeWebviewPanel(); + when( + mockedVSCodeNamespaces.window.createWebviewPanel(anyString(), anyString(), anything(), anything()) + ).thenReturn(fakePanel.panel); + + await show(provider, integrations); + const updatesAfterReopen = fakePanel.posted.filter((m) => m.type === 'update').length; + assert.isAtLeast(updatesAfterReopen, 1, 'reopened panel should receive an initial update'); + + // Fire a token change. If the subscription was lost on dispose, the + // webview would NOT see an additional update message. + await fakeTokenStorage.storage.save({ + integrationId, + refreshToken: 'r', + metadataFingerprint: 'fp' + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const updatesAfterTokenChange = fakePanel.posted.filter((m) => m.type === 'update').length; + assert.isAbove( + updatesAfterTokenChange, + updatesAfterReopen, + 'token-change after reopen should still trigger an update' + ); + }); + + test('updateWebview does not postMessage when panel is disposed during the tokenStorage.has() await', async () => { + // Build a token storage whose `has()` is a deferred promise so we + // can dispose the panel mid-update. + let resolveHas: ((value: boolean) => void) | undefined; + const deferredHasPromise = new Promise((resolve) => { + resolveHas = resolve; + }); + const onDidChangeEmitter = new EventEmitter(); + const slowTokenStorage: IFederatedAuthTokenStorage = { + onDidChangeTokens: onDidChangeEmitter.event, + async get() { + return undefined; + }, + has: () => deferredHasPromise, + async save() { + /* no-op */ + }, + async delete() { + /* no-op */ + }, + computeMetadataFingerprint() { + return 'fp'; + } + }; + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + slowTokenStorage + ); + + const integrationId = 'bq-disposed-during-update'; + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + + // Capture every postMessage call so we can assert no `update` is + // posted after dispose. + const allPostedMessages: CapturedMessage[] = []; + fakePanel.setPostMessageImpl(async (message) => { + allPostedMessages.push(message); + return true; + }); + + // Fire `show()` but do NOT await: it will block on + // Promise.all([slowTokenStorage.has(...)]) until we resolve. + const showPromise = show(provider, integrations); + + // Yield once so `show()` starts the update and parks on `has()`. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Simulate panel disposal mid-update. The provider's onDidDispose + // sets `this.currentPanel = undefined`. + fakePanel.triggerDispose(); + + // Resolve the slow `has()` so updateWebview can finish. The + // post-await guard MUST detect the panel is gone and skip + // postMessage — otherwise we'd dereference undefined. + resolveHas?.(false); + await showPromise; + onDidChangeEmitter.dispose(); + + // Updates posted AFTER dispose should be zero. Updates BEFORE + // dispose are fine (and there may be none because the panel was + // disposed before the first await resolved). + const updateMessages = allPostedMessages.filter((m) => m.type === 'update'); + // No throws (verified by showPromise resolving) and no postMessage + // posted after the panel was disposed. + assert.isEmpty(updateMessages, 'no `update` postMessage should be issued after the panel disposes mid-update'); + }); + + test('handleMessage: "authenticate" logs and does not throw when commands.executeCommand rejects', async () => { + const rejection = new Error('boom'); + const executeCommandStub = sinon.stub().rejects(rejection); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString(), anything())).thenCall((command, arg) => + executeCommandStub(command, arg) + ); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString())).thenCall((command) => + executeCommandStub(command) + ); + + const provider = new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + fakeTokenStorage.storage + ); + + const integrationId = 'bq-auth-err'; + const integrations: Map = new Map([ + [ + integrationId, + { config: makeBigQueryGoogleOauthConfig(integrationId), status: IntegrationStatus.Connected } + ] + ]); + await show(provider, integrations); + + // Should not reject — the provider must swallow the failure so it + // doesn't bubble out of the message handler as an unhandled- + // rejection in the extension host. + await fakePanel.onDidReceiveMessage({ type: 'authenticate', integrationId }); + + assert.isTrue( + executeCommandStub.calledWith(Commands.AuthenticateIntegration, integrationId), + 'expected executeCommand to be invoked' + ); + }); + + // Silence unused-warning compiler complaints from `capture` import in + // case any future test wants to add capture-based assertions. + void capture; +}); diff --git a/src/notebooks/deepnote/integrations/types.ts b/src/notebooks/deepnote/integrations/types.ts index d53c8402c6..ac277aaf7b 100644 --- a/src/notebooks/deepnote/integrations/types.ts +++ b/src/notebooks/deepnote/integrations/types.ts @@ -57,12 +57,32 @@ export interface FederatedAuthTokenEntry { metadataFingerprint: string; } +/** + * Shape of the OAuth-client metadata fingerprinted by + * {@link IFederatedAuthTokenStorage.computeMetadataFingerprint}. Mirrors + * the `google-oauth` branch of the BigQuery integration metadata schema in + * `@deepnote/database-integrations`. + */ +export interface FederatedAuthFingerprintInput { + clientId: string; + clientSecret: string; + project: string; +} + export const IFederatedAuthTokenStorage = Symbol('IFederatedAuthTokenStorage'); export interface IFederatedAuthTokenStorage { /** * Fires when a token is saved or deleted; the payload is the integration id. */ readonly onDidChangeTokens: Event; + /** + * Computes the canonical fingerprint of the OAuth-client metadata on a + * federated BigQuery integration. Exposed on the interface (rather than + * imported directly from `federatedAuthTokenStorage.node`) so callers + * bound on both node and web — notably `IntegrationWebviewProvider` — + * don't have to import the node-only implementation file. + */ + computeMetadataFingerprint(metadata: FederatedAuthFingerprintInput): string; delete(integrationId: string): Promise; get(integrationId: string): Promise; has(integrationId: string): Promise; diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index b5cf25f744..176cc0ca45 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -55,6 +55,7 @@ import { IIntegrationStorage, IIntegrationWebviewProvider } from './deepnote/integrations/types'; +import { FederatedAuthCommandHandlerNode } from './deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node'; import { FederatedAuthSqlBlockCodeGenerator } from './deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node'; import { FederatedAuthTokenStorage } from './deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node'; import { @@ -189,6 +190,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IFederatedAuthSqlBlockCodeGenerator, FederatedAuthSqlBlockCodeGenerator ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + FederatedAuthCommandHandlerNode + ); serviceManager.addSingleton( IExtensionSyncActivationService, SqlCellStatusBarProvider diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index 2488ff73d7..31afe56ca0 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -54,6 +54,7 @@ import { DeepnoteBigNumberCellStatusBarProvider } from './deepnote/deepnoteBigNu import { DeepnoteNewCellLanguageService } from './deepnote/deepnoteNewCellLanguageService'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { IntegrationKernelRestartHandler } from './deepnote/integrations/integrationKernelRestartHandler'; +import { FederatedAuthCommandHandlerWeb } from './deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web'; import { DeepnoteFileChangeWatcher } from './deepnote/deepnoteFileChangeWatcher'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { @@ -137,6 +138,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, IntegrationKernelRestartHandler ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + FederatedAuthCommandHandlerWeb + ); serviceManager.addSingleton( IExtensionSyncActivationService, DeepnoteFileChangeWatcher diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 12d4a62d01..8f69d80875 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -889,6 +889,47 @@ export namespace Integrations { export const bigQueryCredentialsRequired = l10n.t('Credentials are required'); export const bigQueryInvalidJson = (message: string) => l10n.t('Invalid JSON: {0}', message); + // BigQuery federated-auth form strings (M4) + export const bigQueryAuthMethodLabel = l10n.t('Authentication method'); + export const bigQueryAuthMethodServiceAccount = l10n.t('Service account'); + export const bigQueryAuthMethodGoogleOauth = l10n.t('Google OAuth'); + export const bigQueryProjectLabel = l10n.t('Project'); + export const bigQueryProjectPlaceholder = l10n.t('my-project-id'); + export const bigQueryClientIdLabel = l10n.t('OAuth client ID'); + export const bigQueryClientIdPlaceholder = l10n.t('1234567890-abc.apps.googleusercontent.com'); + export const bigQueryClientSecretLabel = l10n.t('OAuth client secret'); + export const bigQueryClientSecretPlaceholder = l10n.t('GOCSPX-...'); + export const bigQueryGoogleOauthHelp = l10n.t( + "Create a 'Desktop app' OAuth client in Google Cloud Console and paste the client ID and secret above. The redirect URI is configured automatically." + ); + + // Federated-auth integration management strings (M4) + export const authenticate = l10n.t('Authenticate with Google'); + export const reauthenticate = l10n.t('Re-authenticate with Google'); + export const tokenStatusAuthenticated = l10n.t('Authenticated'); + export const tokenStatusDisconnected = l10n.t('Not authenticated'); + export const authenticating = (integrationName: string) => l10n.t('Authenticating {0}...', integrationName); + export const authenticationSucceeded = (integrationName: string) => l10n.t('Authenticated {0}', integrationName); + export const authenticationFailed = (errorMessage: string) => l10n.t('Authentication failed: {0}', errorMessage); + export const bigQueryNotAuthenticated = (integrationName: string) => + l10n.t( + 'BigQuery integration "{0}" is not authenticated. Click Authenticate with Google in Manage Integrations to sign in.', + integrationName + ); + export const federatedAuthNotSupportedInWeb = l10n.t( + 'Federated authentication is not supported in the web extension. Open the workspace in desktop VS Code to authenticate.' + ); + export const federatedAuthNotSupportedInRemote = l10n.t( + 'Federated authentication is not yet supported in remote VS Code. Open the workspace locally to authenticate.' + ); + export const federatedAuthIntegrationNotConfiguredForOAuth = (integrationName: string) => + l10n.t('Integration "{0}" is not configured for Google OAuth authentication.', integrationName); + export const federatedAuthIntegrationNotFound = (integrationId: string) => + l10n.t('Integration "{0}" was not found.', integrationId); + export const federatedAuthOAuthClientMisconfigured = l10n.t( + 'The OAuth client is misconfigured. Verify the client ID and client secret in the integration settings.' + ); + // Snowflake form strings export const snowflakeNameLabel = l10n.t('Name (optional)'); export const snowflakeNamePlaceholder = l10n.t('My Snowflake Database'); diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts index 89f5b61dad..27fffd67ba 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts @@ -11,7 +11,35 @@ import { IPlatformDeepnoteNotebookManager } from './types'; import { DATAFRAME_SQL_INTEGRATION_ID } from './integrationTypes'; -import { DatabaseIntegrationConfig, getEnvironmentVariablesForIntegrations } from '@deepnote/database-integrations'; +import { + DatabaseIntegrationConfig, + FederatedAuthMethod, + getEnvironmentVariablesForIntegrations, + isFederatedAuthMethod +} from '@deepnote/database-integrations'; + +/** + * Narrow `DatabaseIntegrationConfig['metadata']` to the federated-auth variant + * without an `as` cast. We can't reuse the upstream `isFederatedAuthMetadata` + * directly because its generic constraint (`M extends { authMethod?: string }`) + * does not unify with the discriminated-union shape of + * `DatabaseIntegrationConfig['metadata']` — several branches do not declare + * `authMethod` at all. The check delegates to the upstream + * `isFederatedAuthMethod` helper at runtime so the set of federated methods + * stays in sync with `@deepnote/database-integrations`. + */ +function isFederatedAuthMetadata( + metadata: DatabaseIntegrationConfig['metadata'] +): metadata is Extract { + if (typeof metadata !== 'object' || metadata === null) { + return false; + } + if (!('authMethod' in metadata)) { + return false; + } + const authMethod = metadata.authMethod; + return typeof authMethod === 'string' && isFederatedAuthMethod(authMethod); +} /** * Provides environment variables for SQL integrations. @@ -88,7 +116,7 @@ export class SqlIntegrationEnvironmentVariablesProvider implements ISqlIntegrati `SqlIntegrationEnvironmentVariablesProvider: Found ${projectIntegrations.length} integrations in project` ); - const projectIntegrationConfigs: Array = ( + const allConfigs: Array = ( await Promise.all( projectIntegrations.map((integration) => { return this.integrationStorage.getIntegrationConfig(integration.id); @@ -96,6 +124,23 @@ export class SqlIntegrationEnvironmentVariablesProvider implements ISqlIntegrati ) ).filter((config) => config != null); + // Skip federated-auth integrations at kernel startup; their access + // tokens are fetched fresh on every cell execution via the silent + // pre-execute hook in `CellExecution`. Emitting an env var here + // would either bake a stale token into the kernel env or require + // running an OAuth refresh during kernel startup — neither is + // acceptable per the plan. + const projectIntegrationConfigs: Array = []; + for (const config of allConfigs) { + if (isFederatedAuthMetadata(config.metadata)) { + logger.debug( + `SqlIntegrationEnvironmentVariablesProvider: Skipping federated integration ${config.id} (${config.type}); per-cell pre-execute handles its token.` + ); + continue; + } + projectIntegrationConfigs.push(config); + } + // Always add the internal DuckDB integration projectIntegrationConfigs.push({ id: DATAFRAME_SQL_INTEGRATION_ID, diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts index 497bd6718a..4b5faeaa0a 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts @@ -409,6 +409,84 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { }); }); + suite('Federated-auth integrations are skipped', () => { + test('Federated BigQuery integration produces zero SQL_* env vars', async () => { + const resource = Uri.file('/test/notebook.deepnote'); + const notebook = mock(); + const federatedConfig: DatabaseIntegrationConfig = { + id: 'bq-oauth', + name: 'OAuth BQ', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: 'oauth-project', + clientId: 'client', + clientSecret: 'secret' + } + }; + const project = createMockProject('project-123', [{ id: 'bq-oauth', name: 'OAuth BQ', type: 'big-query' }]); + + when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); + when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(integrationStorage.getIntegrationConfig('bq-oauth')).thenResolve(federatedConfig); + + const result = await provider.getEnvironmentVariables(resource); + + assert.strictEqual( + result['SQL_BQ_OAUTH'], + undefined, + 'No env var should be emitted for a federated integration' + ); + // DuckDB is still emitted, but the federated integration must not be. + assert.ok(result[`SQL_${DATAFRAME_SQL_INTEGRATION_ID.toUpperCase().replace(/-/g, '_')}`]); + }); + + test('Mixed project: federated integration is skipped, non-federated is included', async () => { + const resource = Uri.file('/test/notebook.deepnote'); + const notebook = mock(); + const postgresConfig: DatabaseIntegrationConfig = { + id: 'pg-1', + name: 'Postgres', + type: 'pgsql', + metadata: { + host: 'localhost', + port: '5432', + database: 'db', + user: 'u', + password: 'p', + sslEnabled: false + } + }; + const federatedConfig: DatabaseIntegrationConfig = { + id: 'bq-oauth', + name: 'OAuth BQ', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: 'oauth-project', + clientId: 'client', + clientSecret: 'secret' + } + }; + const project = createMockProject('project-123', [ + { id: 'pg-1', name: 'Postgres', type: 'pgsql' }, + { id: 'bq-oauth', name: 'OAuth BQ', type: 'big-query' } + ]); + + when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); + when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(integrationStorage.getIntegrationConfig('pg-1')).thenResolve(postgresConfig); + when(integrationStorage.getIntegrationConfig('bq-oauth')).thenResolve(federatedConfig); + + const result = await provider.getEnvironmentVariables(resource); + + assert.ok(result['SQL_PG_1'], 'Non-federated postgres env var should be present'); + assert.strictEqual(result['SQL_BQ_OAUTH'], undefined, 'Federated integration env var should be omitted'); + }); + }); + suite('onDidChangeEnvironmentVariables event', () => { test('Fires when integration storage changes', (done) => { let eventFired = false; diff --git a/src/webviews/webview-side/integrations/BigQueryForm.tsx b/src/webviews/webview-side/integrations/BigQueryForm.tsx index b4ae2b2087..dd6e3635bd 100644 --- a/src/webviews/webview-side/integrations/BigQueryForm.tsx +++ b/src/webviews/webview-side/integrations/BigQueryForm.tsx @@ -4,11 +4,35 @@ import { BigQueryAuthMethods, DatabaseIntegrationConfig } from '@deepnote/databa import { getDefaultIntegrationName } from './integrationUtils'; type BigQueryConfig = Extract; +type BigQueryAuthMethod = BigQueryConfig['metadata']['authMethod']; -function createEmptyBigQueryConfig(params: { id: string; name?: string }): BigQueryConfig { +function isBigQueryAuthMethod(value: string | undefined): value is BigQueryAuthMethod { + return value === BigQueryAuthMethods.ServiceAccount || value === BigQueryAuthMethods.GoogleOauth; +} + +function createEmptyBigQueryConfig(params: { + id: string; + name?: string; + authMethod?: BigQueryAuthMethod; +}): BigQueryConfig { + const name = (params.name || getDefaultIntegrationName('big-query')).trim(); + const authMethod = params.authMethod ?? BigQueryAuthMethods.ServiceAccount; + if (authMethod === BigQueryAuthMethods.GoogleOauth) { + return { + id: params.id, + name, + type: 'big-query', + metadata: { + authMethod: BigQueryAuthMethods.GoogleOauth, + project: '', + clientId: '', + clientSecret: '' + } + }; + } return { id: params.id, - name: (params.name || getDefaultIntegrationName('big-query')).trim(), + name, type: 'big-query', metadata: { authMethod: BigQueryAuthMethods.ServiceAccount, @@ -17,6 +41,19 @@ function createEmptyBigQueryConfig(params: { id: string; name?: string }): BigQu }; } +function buildInitialConfig( + existingConfig: BigQueryConfig | null, + integrationId: string, + defaultName?: string +): BigQueryConfig { + if (!existingConfig) { + return createEmptyBigQueryConfig({ id: integrationId, name: defaultName }); + } + // Preserve existing config when its auth method is supported. Both + // service-account and google-oauth are editable in this milestone. + return structuredClone(existingConfig); +} + export interface IBigQueryFormProps { integrationId: string; existingConfig: BigQueryConfig | null; @@ -32,29 +69,34 @@ export const BigQueryForm: React.FC = ({ onSave, onCancel }) => { - const [pendingConfig, setPendingConfig] = React.useState( - existingConfig && existingConfig.metadata.authMethod === BigQueryAuthMethods.ServiceAccount - ? structuredClone(existingConfig) - : createEmptyBigQueryConfig({ id: integrationId, name: defaultName }) + const [pendingConfig, setPendingConfig] = React.useState(() => + buildInitialConfig(existingConfig, integrationId, defaultName) ); const [credentialsError, setCredentialsError] = React.useState(null); React.useEffect(() => { - setPendingConfig( - existingConfig && existingConfig.metadata.authMethod === BigQueryAuthMethods.ServiceAccount - ? structuredClone(existingConfig) - : createEmptyBigQueryConfig({ id: integrationId, name: defaultName }) - ); + setPendingConfig(buildInitialConfig(existingConfig, integrationId, defaultName)); setCredentialsError(null); }, [existingConfig, integrationId, defaultName]); + const authMethod = pendingConfig.metadata.authMethod ?? BigQueryAuthMethods.ServiceAccount; + // Extract service account value with proper type narrowing const serviceAccountValue = pendingConfig.metadata.authMethod === BigQueryAuthMethods.ServiceAccount ? pendingConfig.metadata.service_account : ''; + const oauthProject = + pendingConfig.metadata.authMethod === BigQueryAuthMethods.GoogleOauth ? pendingConfig.metadata.project : ''; + const oauthClientId = + pendingConfig.metadata.authMethod === BigQueryAuthMethods.GoogleOauth ? pendingConfig.metadata.clientId : ''; + const oauthClientSecret = + pendingConfig.metadata.authMethod === BigQueryAuthMethods.GoogleOauth + ? pendingConfig.metadata.clientSecret + : ''; + const handleNameChange = (e: React.ChangeEvent) => { const value = e.target.value; setPendingConfig((prev) => ({ @@ -63,6 +105,19 @@ export const BigQueryForm: React.FC = ({ })); }; + const handleAuthMethodChange = (e: React.ChangeEvent) => { + const nextAuthMethod = e.target.value; + if (!isBigQueryAuthMethod(nextAuthMethod)) { + // The