@pikku/core 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -2
- package/dist/crypto-utils.d.ts +2 -0
- package/dist/crypto-utils.js +78 -0
- package/dist/errors/errors.d.ts +8 -0
- package/dist/errors/errors.js +16 -0
- package/dist/errors/index.d.ts +0 -1
- package/dist/errors/index.js +0 -1
- package/dist/function/function-runner.d.ts +7 -1
- package/dist/function/function-runner.js +82 -23
- package/dist/function/functions.types.d.ts +31 -87
- package/dist/function/functions.types.js +16 -46
- package/dist/function/index.d.ts +2 -2
- package/dist/function/index.js +1 -2
- package/dist/handle-error.js +1 -10
- package/dist/index.d.ts +28 -26
- package/dist/index.js +18 -29
- package/dist/middleware/auth-apikey.d.ts +2 -2
- package/dist/middleware/auth-apikey.js +3 -3
- package/dist/middleware/auth-bearer.d.ts +2 -2
- package/dist/middleware/auth-bearer.js +3 -6
- package/dist/middleware/auth-cookie.d.ts +2 -2
- package/dist/middleware/auth-cookie.js +6 -8
- package/dist/middleware/cors.d.ts +46 -0
- package/dist/middleware/cors.js +73 -0
- package/dist/middleware/index.d.ts +5 -3
- package/dist/middleware/index.js +5 -3
- package/dist/middleware/remote-auth.d.ts +5 -0
- package/dist/middleware/remote-auth.js +55 -0
- package/dist/middleware/timeout.d.ts +2 -2
- package/dist/middleware-runner.js +8 -10
- package/dist/permissions.js +11 -15
- package/dist/pikku-state.d.ts +1 -0
- package/dist/pikku-state.js +18 -8
- package/dist/schema.js +12 -11
- package/dist/services/ai-agent-runner-service.d.ts +24 -0
- package/dist/services/ai-agent-runner-service.js +1 -0
- package/dist/services/ai-run-state-service.d.ts +13 -0
- package/dist/services/ai-run-state-service.js +1 -0
- package/dist/services/ai-storage-service.d.ts +18 -0
- package/dist/services/ai-storage-service.js +1 -0
- package/dist/services/deployment-service.d.ts +19 -0
- package/dist/services/deployment-service.js +1 -0
- package/dist/services/gopass-secrets.d.ts +47 -0
- package/dist/services/gopass-secrets.js +94 -0
- package/dist/services/in-memory-trigger-service.d.ts +23 -0
- package/dist/services/in-memory-trigger-service.js +50 -0
- package/dist/services/in-memory-workflow-service.d.ts +59 -0
- package/dist/services/in-memory-workflow-service.js +287 -0
- package/dist/services/index.d.ts +25 -11
- package/dist/services/index.js +10 -13
- package/dist/services/local-secrets.d.ts +23 -0
- package/dist/services/local-secrets.js +45 -0
- package/dist/services/local-variables.d.ts +5 -0
- package/dist/services/local-variables.js +18 -0
- package/dist/services/scheduler-service.d.ts +21 -1
- package/dist/services/scheduler-service.js +14 -0
- package/dist/services/schema-service.d.ts +6 -0
- package/dist/services/scoped-secret-service.d.ts +16 -0
- package/dist/services/scoped-secret-service.js +35 -0
- package/dist/services/secret-service.d.ts +25 -4
- package/dist/services/trigger-service.d.ts +23 -0
- package/dist/services/trigger-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +30 -0
- package/dist/services/typed-secret-service.js +40 -0
- package/dist/services/typed-variables-service.d.ts +27 -0
- package/dist/services/typed-variables-service.js +46 -0
- package/dist/services/user-session-service.d.ts +13 -0
- package/dist/services/user-session-service.js +17 -0
- package/dist/services/variables-service.d.ts +5 -0
- package/dist/services/workflow-service.d.ts +9 -4
- package/dist/types/core.types.d.ts +48 -12
- package/dist/types/core.types.js +8 -1
- package/dist/types/state.types.d.ts +43 -51
- package/dist/utils.d.ts +3 -2
- package/dist/utils.js +15 -3
- package/dist/version.d.ts +6 -0
- package/dist/version.js +19 -0
- package/dist/wirings/ai-agent/ai-agent-memory.d.ts +29 -0
- package/dist/wirings/ai-agent/ai-agent-memory.js +199 -0
- package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +9 -0
- package/dist/wirings/ai-agent/ai-agent-model-config.js +36 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +60 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +312 -0
- package/dist/wirings/ai-agent/ai-agent-registry.d.ts +15 -0
- package/dist/wirings/ai-agent/ai-agent-registry.js +47 -0
- package/dist/wirings/ai-agent/ai-agent-runner.d.ts +3 -0
- package/dist/wirings/ai-agent/ai-agent-runner.js +96 -0
- package/dist/wirings/ai-agent/ai-agent-stream.d.ts +11 -0
- package/dist/wirings/ai-agent/ai-agent-stream.js +503 -0
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +254 -0
- package/dist/wirings/ai-agent/ai-agent.types.js +1 -0
- package/dist/wirings/ai-agent/index.d.ts +5 -0
- package/dist/wirings/ai-agent/index.js +4 -0
- package/dist/wirings/channel/channel-common.d.ts +7 -4
- package/dist/wirings/channel/channel-common.js +15 -8
- package/dist/wirings/channel/channel-handler.js +12 -3
- package/dist/wirings/channel/channel-middleware-runner.d.ts +9 -0
- package/dist/wirings/channel/channel-middleware-runner.js +73 -0
- package/dist/wirings/channel/channel-runner.js +7 -7
- package/dist/wirings/channel/channel.types.d.ts +5 -1
- package/dist/wirings/channel/define-channel-routes.d.ts +21 -0
- package/dist/wirings/channel/define-channel-routes.js +23 -0
- package/dist/wirings/channel/index.d.ts +10 -8
- package/dist/wirings/channel/index.js +7 -8
- package/dist/wirings/channel/local/index.d.ts +3 -3
- package/dist/wirings/channel/local/index.js +3 -3
- package/dist/wirings/channel/local/local-channel-runner.js +18 -6
- package/dist/wirings/channel/serverless/index.d.ts +1 -1
- package/dist/wirings/channel/serverless/index.js +1 -1
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +15 -4
- package/dist/wirings/cli/channel/index.d.ts +1 -1
- package/dist/wirings/cli/channel/index.js +1 -1
- package/dist/wirings/cli/cli-runner.d.ts +1 -1
- package/dist/wirings/cli/cli-runner.js +12 -8
- package/dist/wirings/cli/cli.types.d.ts +6 -6
- package/dist/wirings/cli/define-cli-commands.d.ts +19 -0
- package/dist/wirings/cli/define-cli-commands.js +20 -0
- package/dist/wirings/cli/index.d.ts +2 -3
- package/dist/wirings/cli/index.js +1 -3
- package/dist/wirings/http/http-routes.d.ts +69 -0
- package/dist/wirings/http/http-routes.js +139 -0
- package/dist/wirings/http/http-runner.js +49 -23
- package/dist/wirings/http/http.types.d.ts +78 -38
- package/dist/wirings/http/index.d.ts +5 -4
- package/dist/wirings/http/index.js +4 -3
- package/dist/wirings/mcp/index.d.ts +4 -3
- package/dist/wirings/mcp/index.js +3 -3
- package/dist/wirings/mcp/mcp-runner.d.ts +1 -3
- package/dist/wirings/mcp/mcp-runner.js +25 -39
- package/dist/wirings/mcp/mcp.types.d.ts +3 -3
- package/dist/wirings/node/index.d.ts +2 -0
- package/dist/wirings/node/index.js +1 -0
- package/dist/wirings/node/node.types.d.ts +20 -0
- package/dist/wirings/node/node.types.js +1 -0
- package/dist/wirings/oauth2/index.d.ts +3 -0
- package/dist/wirings/oauth2/index.js +2 -0
- package/dist/wirings/oauth2/oauth2-client.d.ts +47 -0
- package/dist/wirings/oauth2/oauth2-client.js +267 -0
- package/dist/wirings/oauth2/oauth2.types.d.ts +65 -0
- package/dist/wirings/oauth2/oauth2.types.js +4 -0
- package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +20 -0
- package/dist/wirings/oauth2/wire-oauth2-credential.js +21 -0
- package/dist/wirings/queue/index.d.ts +2 -2
- package/dist/wirings/queue/index.js +1 -1
- package/dist/wirings/queue/queue-runner.d.ts +7 -0
- package/dist/wirings/queue/queue-runner.js +17 -6
- package/dist/wirings/queue/queue.types.d.ts +8 -2
- package/dist/wirings/rpc/index.d.ts +1 -1
- package/dist/wirings/rpc/index.js +1 -1
- package/dist/wirings/rpc/rpc-runner.d.ts +33 -5
- package/dist/wirings/rpc/rpc-runner.js +134 -16
- package/dist/wirings/rpc/rpc-types.d.ts +8 -4
- package/dist/wirings/scheduler/index.d.ts +3 -3
- package/dist/wirings/scheduler/index.js +2 -3
- package/dist/wirings/scheduler/scheduler-runner.d.ts +5 -0
- package/dist/wirings/scheduler/scheduler-runner.js +23 -5
- package/dist/wirings/secret/index.d.ts +3 -0
- package/dist/wirings/secret/index.js +2 -0
- package/dist/wirings/secret/secret.types.d.ts +27 -0
- package/dist/wirings/secret/secret.types.js +1 -0
- package/dist/wirings/secret/validate-secret-definitions.d.ts +6 -0
- package/dist/wirings/secret/validate-secret-definitions.js +55 -0
- package/dist/wirings/trigger/index.d.ts +3 -2
- package/dist/wirings/trigger/index.js +2 -2
- package/dist/wirings/trigger/pikku-trigger-service.d.ts +26 -0
- package/dist/wirings/trigger/pikku-trigger-service.js +75 -0
- package/dist/wirings/trigger/trigger-runner.d.ts +21 -8
- package/dist/wirings/trigger/trigger-runner.js +46 -18
- package/dist/wirings/trigger/trigger.types.d.ts +99 -11
- package/dist/wirings/trigger/trigger.types.js +40 -1
- package/dist/wirings/variable/index.d.ts +3 -0
- package/dist/wirings/variable/index.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.d.ts +6 -0
- package/dist/wirings/variable/validate-variable-definitions.js +45 -0
- package/dist/wirings/variable/variable.types.d.ts +18 -0
- package/dist/wirings/variable/variable.types.js +1 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +7 -1
- package/dist/wirings/workflow/dsl/workflow-runner.js +3 -2
- package/dist/wirings/workflow/graph/graph-node.d.ts +1 -6
- package/dist/wirings/workflow/graph/graph-runner.d.ts +5 -31
- package/dist/wirings/workflow/graph/graph-runner.js +384 -296
- package/dist/wirings/workflow/graph/index.d.ts +4 -3
- package/dist/wirings/workflow/graph/index.js +3 -3
- package/dist/wirings/workflow/graph/template.d.ts +15 -0
- package/dist/wirings/workflow/graph/template.js +27 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +15 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +8 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +0 -13
- package/dist/wirings/workflow/index.d.ts +6 -6
- package/dist/wirings/workflow/index.js +6 -4
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +28 -14
- package/dist/wirings/workflow/pikku-workflow-service.js +200 -56
- package/dist/wirings/workflow/workflow-helpers.d.ts +36 -0
- package/dist/wirings/workflow/workflow-helpers.js +38 -0
- package/dist/wirings/workflow/workflow-utils.d.ts +1 -23
- package/dist/wirings/workflow/workflow-utils.js +1 -66
- package/dist/wirings/workflow/workflow.types.d.ts +40 -93
- package/package.json +13 -15
- package/run-tests.sh +4 -1
- package/src/crypto-utils.ts +99 -0
- package/src/errors/errors.ts +17 -0
- package/src/errors/index.ts +0 -1
- package/src/factory-functions.test.ts +42 -2
- package/src/function/function-runner.test.ts +4 -11
- package/src/function/function-runner.ts +114 -29
- package/src/function/functions.types.ts +62 -132
- package/src/function/index.ts +5 -2
- package/src/handle-error.ts +2 -10
- package/src/index.ts +99 -25
- package/src/middleware/auth-apikey.test.ts +15 -12
- package/src/middleware/auth-apikey.ts +3 -3
- package/src/middleware/auth-bearer.test.ts +17 -14
- package/src/middleware/auth-bearer.ts +25 -27
- package/src/middleware/auth-cookie.test.ts +16 -13
- package/src/middleware/auth-cookie.ts +6 -8
- package/src/middleware/cors.ts +96 -0
- package/src/middleware/index.ts +5 -3
- package/src/middleware/remote-auth.ts +68 -0
- package/src/middleware-runner.test.ts +122 -0
- package/src/middleware-runner.ts +12 -12
- package/src/permissions.test.ts +81 -7
- package/src/permissions.ts +15 -17
- package/src/pikku-state.ts +20 -8
- package/src/run-tests-script.test.ts +49 -0
- package/src/schema.ts +18 -15
- package/src/services/ai-agent-runner-service.ts +29 -0
- package/src/services/ai-run-state-service.ts +20 -0
- package/src/services/ai-storage-service.ts +39 -0
- package/src/services/deployment-service.ts +22 -0
- package/src/services/gopass-secrets.ts +95 -0
- package/src/services/in-memory-trigger-service.ts +68 -0
- package/src/services/in-memory-workflow-service.ts +395 -0
- package/src/services/index.ts +49 -14
- package/src/services/local-secrets.ts +51 -0
- package/src/services/local-variables.ts +22 -0
- package/src/services/scheduler-service.ts +29 -1
- package/src/services/schema-service.ts +7 -0
- package/src/services/scoped-secret-service.ts +41 -0
- package/src/services/secret-service.ts +25 -4
- package/src/services/trigger-service.ts +38 -0
- package/src/services/typed-secret-service.ts +70 -0
- package/src/services/typed-variables-service.ts +81 -0
- package/src/services/user-session-service.ts +23 -0
- package/src/services/variables-service.ts +5 -0
- package/src/services/workflow-service.ts +22 -2
- package/src/types/core.types.ts +76 -11
- package/src/types/state.types.ts +37 -63
- package/src/utils.ts +20 -6
- package/src/version.test.ts +80 -0
- package/src/version.ts +25 -0
- package/src/wirings/ai-agent/ai-agent-memory.ts +300 -0
- package/src/wirings/ai-agent/ai-agent-model-config.ts +43 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +470 -0
- package/src/wirings/ai-agent/ai-agent-registry.test.ts +37 -0
- package/src/wirings/ai-agent/ai-agent-registry.ts +82 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +75 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +137 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +246 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +774 -0
- package/src/wirings/ai-agent/ai-agent.types.ts +273 -0
- package/src/wirings/ai-agent/index.ts +28 -0
- package/src/wirings/channel/channel-common.ts +32 -19
- package/src/wirings/channel/channel-handler.ts +17 -4
- package/src/wirings/channel/channel-middleware-runner.ts +119 -0
- package/src/wirings/channel/channel-runner.ts +9 -10
- package/src/wirings/channel/channel.types.ts +17 -1
- package/src/wirings/channel/define-channel-routes.ts +25 -0
- package/src/wirings/channel/index.ts +23 -8
- package/src/wirings/channel/local/index.ts +3 -3
- package/src/wirings/channel/local/local-channel-runner.test.ts +44 -1
- package/src/wirings/channel/local/local-channel-runner.ts +22 -6
- package/src/wirings/channel/serverless/index.ts +5 -1
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +20 -4
- package/src/wirings/cli/channel/index.ts +1 -1
- package/src/wirings/cli/cli-runner.test.ts +9 -9
- package/src/wirings/cli/cli-runner.ts +17 -10
- package/src/wirings/cli/cli.types.ts +11 -11
- package/src/wirings/cli/command-parser.test.ts +8 -8
- package/src/wirings/cli/define-cli-commands.ts +24 -0
- package/src/wirings/cli/index.ts +10 -4
- package/src/wirings/http/http-routes.test.ts +322 -0
- package/src/wirings/http/http-routes.ts +198 -0
- package/src/wirings/http/http-runner.test.ts +3 -3
- package/src/wirings/http/http-runner.ts +63 -29
- package/src/wirings/http/http.types.ts +121 -37
- package/src/wirings/http/index.ts +16 -4
- package/src/wirings/mcp/index.ts +27 -3
- package/src/wirings/mcp/mcp-endpoint-registry.test.ts +3 -3
- package/src/wirings/mcp/mcp-runner.ts +39 -50
- package/src/wirings/mcp/mcp.types.ts +3 -3
- package/src/wirings/node/index.ts +2 -0
- package/src/wirings/node/node.types.ts +23 -0
- package/src/wirings/oauth2/index.ts +3 -0
- package/src/wirings/oauth2/oauth2-client.test.ts +929 -0
- package/src/wirings/oauth2/oauth2-client.ts +335 -0
- package/src/wirings/oauth2/oauth2.types.ts +69 -0
- package/src/wirings/oauth2/wire-oauth2-credential.ts +23 -0
- package/src/wirings/queue/index.ts +14 -2
- package/src/wirings/queue/queue-runner.test.ts +97 -55
- package/src/wirings/queue/queue-runner.ts +33 -7
- package/src/wirings/queue/queue.types.ts +12 -2
- package/src/wirings/rpc/index.ts +1 -1
- package/src/wirings/rpc/rpc-runner.ts +187 -17
- package/src/wirings/rpc/rpc-types.ts +13 -4
- package/src/wirings/scheduler/index.ts +7 -4
- package/src/wirings/scheduler/scheduler-runner.test.ts +70 -26
- package/src/wirings/scheduler/scheduler-runner.ts +45 -5
- package/src/wirings/secret/index.ts +9 -0
- package/src/wirings/secret/secret.types.ts +32 -0
- package/src/wirings/secret/validate-secret-definitions.ts +82 -0
- package/src/wirings/trigger/index.ts +10 -2
- package/src/wirings/trigger/pikku-trigger-service.ts +134 -0
- package/src/wirings/trigger/trigger-runner.ts +80 -38
- package/src/wirings/trigger/trigger.types.ts +144 -22
- package/src/wirings/variable/index.ts +8 -0
- package/src/wirings/variable/validate-variable-definitions.ts +69 -0
- package/src/wirings/variable/variable.types.ts +22 -0
- package/src/wirings/workflow/dsl/index.ts +1 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +9 -1
- package/src/wirings/workflow/dsl/workflow-runner.ts +4 -3
- package/src/wirings/workflow/graph/graph-node.ts +1 -6
- package/src/wirings/workflow/graph/graph-runner.test.ts +495 -0
- package/src/wirings/workflow/graph/graph-runner.ts +496 -374
- package/src/wirings/workflow/graph/index.ts +14 -3
- package/src/wirings/workflow/graph/template.ts +42 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +29 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +0 -22
- package/src/wirings/workflow/index.ts +21 -10
- package/src/wirings/workflow/pikku-workflow-service.test.ts +212 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +285 -66
- package/src/wirings/workflow/workflow-helpers.ts +79 -0
- package/src/wirings/workflow/workflow-utils.ts +0 -120
- package/src/wirings/workflow/workflow.types.ts +45 -90
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/wirings/forge-node/forge-node.types.d.ts +0 -120
- package/dist/wirings/forge-node/forge-node.types.js +0 -38
- package/dist/wirings/forge-node/index.d.ts +0 -1
- package/dist/wirings/forge-node/index.js +0 -1
- package/dist/wirings/workflow/wire-workflow.d.ts +0 -42
- package/dist/wirings/workflow/wire-workflow.js +0 -53
- package/src/wirings/forge-node/forge-node.types.ts +0 -135
- package/src/wirings/forge-node/index.ts +0 -1
- package/src/wirings/workflow/wire-workflow.ts +0 -94
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
import { describe, test, afterEach, mock } from 'node:test'
|
|
2
|
+
import * as assert from 'node:assert/strict'
|
|
3
|
+
import { OAuth2Client } from './oauth2-client.js'
|
|
4
|
+
import type { OAuth2Token, OAuth2AppCredential } from './oauth2.types.js'
|
|
5
|
+
import type { OAuth2CredentialConfig } from '../secret/secret.types.js'
|
|
6
|
+
import type { SecretService } from '../../services/secret-service.js'
|
|
7
|
+
|
|
8
|
+
// Store the original fetch for restoration
|
|
9
|
+
const originalFetch = globalThis.fetch
|
|
10
|
+
|
|
11
|
+
// Mock SecretService with in-memory store
|
|
12
|
+
function createMockSecretService(
|
|
13
|
+
initialSecrets: Record<string, unknown> = {}
|
|
14
|
+
): SecretService & { getStoredSecrets: () => Map<string, unknown> } {
|
|
15
|
+
const secrets = new Map<string, unknown>(Object.entries(initialSecrets))
|
|
16
|
+
return {
|
|
17
|
+
getSecret: async (key: string) => secrets.get(key) as string,
|
|
18
|
+
getSecretJSON: async <T>(key: string) => secrets.get(key) as T,
|
|
19
|
+
setSecretJSON: async (key: string, value: unknown) => {
|
|
20
|
+
secrets.set(key, value)
|
|
21
|
+
},
|
|
22
|
+
deleteSecret: async (_key: string) => {
|
|
23
|
+
// Not used in tests
|
|
24
|
+
},
|
|
25
|
+
hasSecret: async (key: string) => secrets.has(key),
|
|
26
|
+
// Helper for tests to inspect stored values
|
|
27
|
+
getStoredSecrets: () => secrets,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Helper to create a mock fetch response
|
|
32
|
+
function mockFetchResponse(data: unknown, status = 200, ok = true): Response {
|
|
33
|
+
return {
|
|
34
|
+
ok,
|
|
35
|
+
status,
|
|
36
|
+
json: async () => data,
|
|
37
|
+
text: async () => JSON.stringify(data),
|
|
38
|
+
} as Response
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default test configuration
|
|
42
|
+
const defaultConfig: OAuth2CredentialConfig = {
|
|
43
|
+
tokenSecretId: 'OAUTH_TOKENS',
|
|
44
|
+
authorizationUrl: 'https://example.com/oauth/authorize',
|
|
45
|
+
tokenUrl: 'https://example.com/oauth/token',
|
|
46
|
+
scopes: ['read', 'write'],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const defaultAppCredential: OAuth2AppCredential = {
|
|
50
|
+
clientId: 'test-client-id',
|
|
51
|
+
clientSecret: 'test-client-secret',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('OAuth2Client', () => {
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
// Restore original fetch after each test
|
|
57
|
+
globalThis.fetch = originalFetch
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('Token Management', () => {
|
|
61
|
+
test('returns cached valid token', async () => {
|
|
62
|
+
const validToken: OAuth2Token = {
|
|
63
|
+
accessToken: 'valid-access-token',
|
|
64
|
+
refreshToken: 'refresh-token',
|
|
65
|
+
expiresAt: Date.now() + 3600000, // 1 hour from now
|
|
66
|
+
tokenType: 'Bearer',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const secrets = createMockSecretService({
|
|
70
|
+
OAUTH_TOKENS: validToken,
|
|
71
|
+
APP_CREDS: defaultAppCredential,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
75
|
+
|
|
76
|
+
// First call loads from secrets
|
|
77
|
+
const token1 = await client.getAccessToken()
|
|
78
|
+
assert.equal(token1, 'valid-access-token')
|
|
79
|
+
|
|
80
|
+
// Second call should return cached token without hitting secrets
|
|
81
|
+
const token2 = await client.getAccessToken()
|
|
82
|
+
assert.equal(token2, 'valid-access-token')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('refreshes expired token', async () => {
|
|
86
|
+
const expiredToken: OAuth2Token = {
|
|
87
|
+
accessToken: 'expired-access-token',
|
|
88
|
+
refreshToken: 'refresh-token',
|
|
89
|
+
expiresAt: Date.now() - 1000, // Expired
|
|
90
|
+
tokenType: 'Bearer',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const secrets = createMockSecretService({
|
|
94
|
+
OAUTH_TOKENS: expiredToken,
|
|
95
|
+
APP_CREDS: defaultAppCredential,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
globalThis.fetch = mock.fn(async () =>
|
|
99
|
+
mockFetchResponse({
|
|
100
|
+
access_token: 'new-access-token',
|
|
101
|
+
refresh_token: 'new-refresh-token',
|
|
102
|
+
expires_in: 3600,
|
|
103
|
+
token_type: 'Bearer',
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
108
|
+
const token = await client.getAccessToken()
|
|
109
|
+
|
|
110
|
+
assert.equal(token, 'new-access-token')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('loads from secrets on first call', async () => {
|
|
114
|
+
const storedToken: OAuth2Token = {
|
|
115
|
+
accessToken: 'stored-access-token',
|
|
116
|
+
refreshToken: 'stored-refresh-token',
|
|
117
|
+
expiresAt: Date.now() + 3600000,
|
|
118
|
+
tokenType: 'Bearer',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const secrets = createMockSecretService({
|
|
122
|
+
OAUTH_TOKENS: storedToken,
|
|
123
|
+
APP_CREDS: defaultAppCredential,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
127
|
+
const token = await client.getAccessToken()
|
|
128
|
+
|
|
129
|
+
assert.equal(token, 'stored-access-token')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('refreshes expired token from secrets', async () => {
|
|
133
|
+
const expiredToken: OAuth2Token = {
|
|
134
|
+
accessToken: 'expired-token',
|
|
135
|
+
refreshToken: 'valid-refresh-token',
|
|
136
|
+
expiresAt: Date.now() - 1000, // Expired
|
|
137
|
+
tokenType: 'Bearer',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const secrets = createMockSecretService({
|
|
141
|
+
OAUTH_TOKENS: expiredToken,
|
|
142
|
+
APP_CREDS: defaultAppCredential,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
globalThis.fetch = mock.fn(async () =>
|
|
146
|
+
mockFetchResponse({
|
|
147
|
+
access_token: 'refreshed-token',
|
|
148
|
+
expires_in: 3600,
|
|
149
|
+
token_type: 'Bearer',
|
|
150
|
+
})
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
154
|
+
const token = await client.getAccessToken()
|
|
155
|
+
|
|
156
|
+
assert.equal(token, 'refreshed-token')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('throws when no token available', async () => {
|
|
160
|
+
const secrets = createMockSecretService({
|
|
161
|
+
APP_CREDS: defaultAppCredential,
|
|
162
|
+
// No OAUTH_TOKENS
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
166
|
+
|
|
167
|
+
await assert.rejects(
|
|
168
|
+
async () => await client.getAccessToken(),
|
|
169
|
+
/undefined/
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('uses 60-second expiry buffer', async () => {
|
|
174
|
+
// Token expires in 30 seconds (within the 60-second buffer)
|
|
175
|
+
const almostExpiredToken: OAuth2Token = {
|
|
176
|
+
accessToken: 'almost-expired-token',
|
|
177
|
+
refreshToken: 'refresh-token',
|
|
178
|
+
expiresAt: Date.now() + 30000, // 30 seconds from now
|
|
179
|
+
tokenType: 'Bearer',
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const secrets = createMockSecretService({
|
|
183
|
+
OAUTH_TOKENS: almostExpiredToken,
|
|
184
|
+
APP_CREDS: defaultAppCredential,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
globalThis.fetch = mock.fn(async () =>
|
|
188
|
+
mockFetchResponse({
|
|
189
|
+
access_token: 'refreshed-due-to-buffer',
|
|
190
|
+
expires_in: 3600,
|
|
191
|
+
token_type: 'Bearer',
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
196
|
+
const token = await client.getAccessToken()
|
|
197
|
+
|
|
198
|
+
// Should refresh because within 60-second buffer
|
|
199
|
+
assert.equal(token, 'refreshed-due-to-buffer')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('Token Refresh', () => {
|
|
204
|
+
test('makes correct HTTP request', async () => {
|
|
205
|
+
const tokenWithRefresh: OAuth2Token = {
|
|
206
|
+
accessToken: 'old-token',
|
|
207
|
+
refreshToken: 'my-refresh-token',
|
|
208
|
+
expiresAt: Date.now() - 1000,
|
|
209
|
+
tokenType: 'Bearer',
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const secrets = createMockSecretService({
|
|
213
|
+
OAUTH_TOKENS: tokenWithRefresh,
|
|
214
|
+
APP_CREDS: defaultAppCredential,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
let capturedRequest: { url: string; options: RequestInit } | null = null
|
|
218
|
+
|
|
219
|
+
globalThis.fetch = mock.fn(
|
|
220
|
+
async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
221
|
+
capturedRequest = { url: url.toString(), options: options! }
|
|
222
|
+
return mockFetchResponse({
|
|
223
|
+
access_token: 'new-token',
|
|
224
|
+
expires_in: 3600,
|
|
225
|
+
token_type: 'Bearer',
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
) as typeof fetch
|
|
229
|
+
|
|
230
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
231
|
+
await client.getAccessToken()
|
|
232
|
+
|
|
233
|
+
assert.ok(capturedRequest, 'Expected capturedRequest to be set')
|
|
234
|
+
const req = capturedRequest as { url: string; options: RequestInit }
|
|
235
|
+
assert.equal(req.url, 'https://example.com/oauth/token')
|
|
236
|
+
assert.equal(req.options.method, 'POST')
|
|
237
|
+
assert.equal(
|
|
238
|
+
(req.options.headers as Record<string, string>)?.['Content-Type'],
|
|
239
|
+
'application/x-www-form-urlencoded'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const body = req.options.body as string
|
|
243
|
+
assert.ok(body.includes('grant_type=refresh_token'))
|
|
244
|
+
assert.ok(body.includes('refresh_token=my-refresh-token'))
|
|
245
|
+
assert.ok(body.includes('client_id=test-client-id'))
|
|
246
|
+
assert.ok(body.includes('client_secret=test-client-secret'))
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('updates cached token', async () => {
|
|
250
|
+
const expiredToken: OAuth2Token = {
|
|
251
|
+
accessToken: 'expired-token',
|
|
252
|
+
refreshToken: 'refresh-token',
|
|
253
|
+
expiresAt: Date.now() - 1000,
|
|
254
|
+
tokenType: 'Bearer',
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const secrets = createMockSecretService({
|
|
258
|
+
OAUTH_TOKENS: expiredToken,
|
|
259
|
+
APP_CREDS: defaultAppCredential,
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
let fetchCallCount = 0
|
|
263
|
+
globalThis.fetch = mock.fn(async () => {
|
|
264
|
+
fetchCallCount++
|
|
265
|
+
return mockFetchResponse({
|
|
266
|
+
access_token: 'new-cached-token',
|
|
267
|
+
expires_in: 3600,
|
|
268
|
+
token_type: 'Bearer',
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
273
|
+
|
|
274
|
+
// First call - triggers refresh
|
|
275
|
+
const token1 = await client.getAccessToken()
|
|
276
|
+
assert.equal(token1, 'new-cached-token')
|
|
277
|
+
|
|
278
|
+
// Second call - should use cached token, no refresh
|
|
279
|
+
const token2 = await client.getAccessToken()
|
|
280
|
+
assert.equal(token2, 'new-cached-token')
|
|
281
|
+
assert.equal(fetchCallCount, 1) // Only one refresh call
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('throws on HTTP error', async () => {
|
|
285
|
+
const expiredToken: OAuth2Token = {
|
|
286
|
+
accessToken: 'expired-token',
|
|
287
|
+
refreshToken: 'refresh-token',
|
|
288
|
+
expiresAt: Date.now() - 1000,
|
|
289
|
+
tokenType: 'Bearer',
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const secrets = createMockSecretService({
|
|
293
|
+
OAUTH_TOKENS: expiredToken,
|
|
294
|
+
APP_CREDS: defaultAppCredential,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
globalThis.fetch = mock.fn(async () =>
|
|
298
|
+
mockFetchResponse({ error: 'invalid_grant' }, 400, false)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
302
|
+
|
|
303
|
+
await assert.rejects(
|
|
304
|
+
async () => await client.getAccessToken(),
|
|
305
|
+
/Token refresh failed: 400/
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('throws when no refresh token', async () => {
|
|
310
|
+
const tokenWithoutRefresh: OAuth2Token = {
|
|
311
|
+
accessToken: 'expired-token',
|
|
312
|
+
// No refreshToken
|
|
313
|
+
expiresAt: Date.now() - 1000,
|
|
314
|
+
tokenType: 'Bearer',
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const secrets = createMockSecretService({
|
|
318
|
+
OAUTH_TOKENS: tokenWithoutRefresh,
|
|
319
|
+
APP_CREDS: defaultAppCredential,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
323
|
+
|
|
324
|
+
await assert.rejects(
|
|
325
|
+
async () => await client.getAccessToken(),
|
|
326
|
+
/OAuth2 token expired and no refresh token available/
|
|
327
|
+
)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('persists refreshed token to secrets', async () => {
|
|
331
|
+
const expiredToken: OAuth2Token = {
|
|
332
|
+
accessToken: 'expired-token',
|
|
333
|
+
refreshToken: 'refresh-token',
|
|
334
|
+
expiresAt: Date.now() - 1000,
|
|
335
|
+
tokenType: 'Bearer',
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const secrets = createMockSecretService({
|
|
339
|
+
OAUTH_TOKENS: expiredToken,
|
|
340
|
+
APP_CREDS: defaultAppCredential,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
globalThis.fetch = mock.fn(async () =>
|
|
344
|
+
mockFetchResponse({
|
|
345
|
+
access_token: 'new-persisted-token',
|
|
346
|
+
refresh_token: 'new-refresh-token',
|
|
347
|
+
expires_in: 3600,
|
|
348
|
+
token_type: 'Bearer',
|
|
349
|
+
})
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
353
|
+
await client.getAccessToken()
|
|
354
|
+
|
|
355
|
+
// Verify the token was persisted to secrets
|
|
356
|
+
const storedToken = secrets
|
|
357
|
+
.getStoredSecrets()
|
|
358
|
+
.get('OAUTH_TOKENS') as OAuth2Token
|
|
359
|
+
assert.equal(storedToken.accessToken, 'new-persisted-token')
|
|
360
|
+
assert.equal(storedToken.refreshToken, 'new-refresh-token')
|
|
361
|
+
assert.ok(storedToken.expiresAt && storedToken.expiresAt > Date.now())
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('promise lock prevents concurrent refreshes', async () => {
|
|
365
|
+
const expiredToken: OAuth2Token = {
|
|
366
|
+
accessToken: 'expired-token',
|
|
367
|
+
refreshToken: 'refresh-token',
|
|
368
|
+
expiresAt: Date.now() - 1000,
|
|
369
|
+
tokenType: 'Bearer',
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const secrets = createMockSecretService({
|
|
373
|
+
OAUTH_TOKENS: expiredToken,
|
|
374
|
+
APP_CREDS: defaultAppCredential,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
let fetchCallCount = 0
|
|
378
|
+
globalThis.fetch = mock.fn(async () => {
|
|
379
|
+
fetchCallCount++
|
|
380
|
+
// Simulate network delay
|
|
381
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
382
|
+
return mockFetchResponse({
|
|
383
|
+
access_token: 'concurrent-token',
|
|
384
|
+
expires_in: 3600,
|
|
385
|
+
token_type: 'Bearer',
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
390
|
+
|
|
391
|
+
// Trigger concurrent refresh calls
|
|
392
|
+
const [token1, token2, token3] = await Promise.all([
|
|
393
|
+
client.getAccessToken(),
|
|
394
|
+
client.getAccessToken(),
|
|
395
|
+
client.getAccessToken(),
|
|
396
|
+
])
|
|
397
|
+
|
|
398
|
+
// All should get the same token
|
|
399
|
+
assert.equal(token1, 'concurrent-token')
|
|
400
|
+
assert.equal(token2, 'concurrent-token')
|
|
401
|
+
assert.equal(token3, 'concurrent-token')
|
|
402
|
+
|
|
403
|
+
// Only one actual refresh should have happened
|
|
404
|
+
assert.equal(fetchCallCount, 1)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('preserves refresh token if not returned', async () => {
|
|
408
|
+
const expiredToken: OAuth2Token = {
|
|
409
|
+
accessToken: 'expired-token',
|
|
410
|
+
refreshToken: 'original-refresh-token',
|
|
411
|
+
expiresAt: Date.now() - 1000,
|
|
412
|
+
tokenType: 'Bearer',
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const secrets = createMockSecretService({
|
|
416
|
+
OAUTH_TOKENS: expiredToken,
|
|
417
|
+
APP_CREDS: defaultAppCredential,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// Response doesn't include refresh_token
|
|
421
|
+
globalThis.fetch = mock.fn(async () =>
|
|
422
|
+
mockFetchResponse({
|
|
423
|
+
access_token: 'new-token',
|
|
424
|
+
expires_in: 3600,
|
|
425
|
+
token_type: 'Bearer',
|
|
426
|
+
// No refresh_token in response
|
|
427
|
+
})
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
431
|
+
await client.getAccessToken()
|
|
432
|
+
|
|
433
|
+
// Expire the new token to trigger another refresh
|
|
434
|
+
// The client should still have the original refresh token
|
|
435
|
+
// To verify this, we'd need to access internal state or trigger another refresh
|
|
436
|
+
// For now, we verify the first refresh worked
|
|
437
|
+
const token = await client.getAccessToken()
|
|
438
|
+
assert.equal(token, 'new-token')
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
describe('Request Method', () => {
|
|
443
|
+
test('adds Authorization header', async () => {
|
|
444
|
+
const validToken: OAuth2Token = {
|
|
445
|
+
accessToken: 'bearer-token',
|
|
446
|
+
expiresAt: Date.now() + 3600000,
|
|
447
|
+
tokenType: 'Bearer',
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const secrets = createMockSecretService({
|
|
451
|
+
OAUTH_TOKENS: validToken,
|
|
452
|
+
APP_CREDS: defaultAppCredential,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
let capturedHeaders: Record<string, string> | null = null
|
|
456
|
+
|
|
457
|
+
globalThis.fetch = mock.fn(
|
|
458
|
+
async (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
459
|
+
capturedHeaders = options?.headers as Record<string, string>
|
|
460
|
+
return mockFetchResponse({ data: 'test' })
|
|
461
|
+
}
|
|
462
|
+
) as typeof fetch
|
|
463
|
+
|
|
464
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
465
|
+
await client.request('https://api.example.com/resource')
|
|
466
|
+
|
|
467
|
+
assert.ok(capturedHeaders, 'Expected capturedHeaders to be set')
|
|
468
|
+
const headers = capturedHeaders as Record<string, string>
|
|
469
|
+
assert.equal(headers.Authorization, 'Bearer bearer-token')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('retries on 401 with fresh token', async () => {
|
|
473
|
+
const validToken: OAuth2Token = {
|
|
474
|
+
accessToken: 'initial-token',
|
|
475
|
+
refreshToken: 'refresh-token',
|
|
476
|
+
expiresAt: Date.now() + 3600000,
|
|
477
|
+
tokenType: 'Bearer',
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const secrets = createMockSecretService({
|
|
481
|
+
OAUTH_TOKENS: validToken,
|
|
482
|
+
APP_CREDS: defaultAppCredential,
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
let requestCount = 0
|
|
486
|
+
globalThis.fetch = mock.fn(
|
|
487
|
+
async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
488
|
+
requestCount++
|
|
489
|
+
const urlStr = url.toString()
|
|
490
|
+
|
|
491
|
+
// Token refresh endpoint
|
|
492
|
+
if (urlStr === 'https://example.com/oauth/token') {
|
|
493
|
+
return mockFetchResponse({
|
|
494
|
+
access_token: 'refreshed-token',
|
|
495
|
+
expires_in: 3600,
|
|
496
|
+
token_type: 'Bearer',
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// API endpoint
|
|
501
|
+
const headers = options?.headers as Record<string, string>
|
|
502
|
+
if (headers?.Authorization === 'Bearer initial-token') {
|
|
503
|
+
// First request returns 401
|
|
504
|
+
return mockFetchResponse({}, 401, false)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Retry with refreshed token succeeds
|
|
508
|
+
return mockFetchResponse({ success: true })
|
|
509
|
+
}
|
|
510
|
+
) as typeof fetch
|
|
511
|
+
|
|
512
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
513
|
+
const response = await client.request('https://api.example.com/resource')
|
|
514
|
+
|
|
515
|
+
assert.equal(response.status, 200)
|
|
516
|
+
assert.equal(requestCount, 3) // Initial request + refresh + retry
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('no double retry on 401', async () => {
|
|
520
|
+
const validToken: OAuth2Token = {
|
|
521
|
+
accessToken: 'token',
|
|
522
|
+
refreshToken: 'refresh-token',
|
|
523
|
+
expiresAt: Date.now() + 3600000,
|
|
524
|
+
tokenType: 'Bearer',
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const secrets = createMockSecretService({
|
|
528
|
+
OAUTH_TOKENS: validToken,
|
|
529
|
+
APP_CREDS: defaultAppCredential,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
let apiRequestCount = 0
|
|
533
|
+
globalThis.fetch = mock.fn(async (url: RequestInfo | URL) => {
|
|
534
|
+
const urlStr = url.toString()
|
|
535
|
+
|
|
536
|
+
if (urlStr === 'https://example.com/oauth/token') {
|
|
537
|
+
return mockFetchResponse({
|
|
538
|
+
access_token: 'new-token',
|
|
539
|
+
expires_in: 3600,
|
|
540
|
+
token_type: 'Bearer',
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
apiRequestCount++
|
|
545
|
+
// Always return 401
|
|
546
|
+
return mockFetchResponse({}, 401, false)
|
|
547
|
+
}) as typeof fetch
|
|
548
|
+
|
|
549
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
550
|
+
const response = await client.request('https://api.example.com/resource')
|
|
551
|
+
|
|
552
|
+
assert.equal(response.status, 401)
|
|
553
|
+
// Should only be 2 API requests (initial + one retry)
|
|
554
|
+
assert.equal(apiRequestCount, 2)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('passes through other status codes', async () => {
|
|
558
|
+
const validToken: OAuth2Token = {
|
|
559
|
+
accessToken: 'token',
|
|
560
|
+
expiresAt: Date.now() + 3600000,
|
|
561
|
+
tokenType: 'Bearer',
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const secrets = createMockSecretService({
|
|
565
|
+
OAUTH_TOKENS: validToken,
|
|
566
|
+
APP_CREDS: defaultAppCredential,
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
globalThis.fetch = mock.fn(async () =>
|
|
570
|
+
mockFetchResponse({ error: 'forbidden' }, 403, false)
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
574
|
+
const response = await client.request('https://api.example.com/resource')
|
|
575
|
+
|
|
576
|
+
assert.equal(response.status, 403)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
test('preserves user headers', async () => {
|
|
580
|
+
const validToken: OAuth2Token = {
|
|
581
|
+
accessToken: 'token',
|
|
582
|
+
expiresAt: Date.now() + 3600000,
|
|
583
|
+
tokenType: 'Bearer',
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const secrets = createMockSecretService({
|
|
587
|
+
OAUTH_TOKENS: validToken,
|
|
588
|
+
APP_CREDS: defaultAppCredential,
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
let capturedHeaders: Record<string, string> | null = null
|
|
592
|
+
|
|
593
|
+
globalThis.fetch = mock.fn(
|
|
594
|
+
async (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
595
|
+
capturedHeaders = options?.headers as Record<string, string>
|
|
596
|
+
return mockFetchResponse({ data: 'test' })
|
|
597
|
+
}
|
|
598
|
+
) as typeof fetch
|
|
599
|
+
|
|
600
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
601
|
+
await client.request('https://api.example.com/resource', {
|
|
602
|
+
headers: {
|
|
603
|
+
'Content-Type': 'application/json',
|
|
604
|
+
'X-Custom-Header': 'custom-value',
|
|
605
|
+
},
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
assert.ok(capturedHeaders, 'Expected capturedHeaders to be set')
|
|
609
|
+
const headers = capturedHeaders as Record<string, string>
|
|
610
|
+
assert.equal(headers['Content-Type'], 'application/json')
|
|
611
|
+
assert.equal(headers['X-Custom-Header'], 'custom-value')
|
|
612
|
+
assert.equal(headers.Authorization, 'Bearer token')
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
describe('Authorization URL', () => {
|
|
617
|
+
test('includes required params', async () => {
|
|
618
|
+
const secrets = createMockSecretService({
|
|
619
|
+
APP_CREDS: defaultAppCredential,
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
623
|
+
const url = await client.getAuthorizationUrl(
|
|
624
|
+
'test-state',
|
|
625
|
+
'http://localhost:3000/callback'
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
const parsed = new URL(url)
|
|
629
|
+
assert.equal(
|
|
630
|
+
parsed.origin + parsed.pathname,
|
|
631
|
+
'https://example.com/oauth/authorize'
|
|
632
|
+
)
|
|
633
|
+
assert.equal(parsed.searchParams.get('response_type'), 'code')
|
|
634
|
+
assert.equal(parsed.searchParams.get('client_id'), 'test-client-id')
|
|
635
|
+
assert.equal(
|
|
636
|
+
parsed.searchParams.get('redirect_uri'),
|
|
637
|
+
'http://localhost:3000/callback'
|
|
638
|
+
)
|
|
639
|
+
assert.equal(parsed.searchParams.get('scope'), 'read write')
|
|
640
|
+
assert.equal(parsed.searchParams.get('state'), 'test-state')
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
test('includes additional params', async () => {
|
|
644
|
+
const configWithParams: OAuth2CredentialConfig = {
|
|
645
|
+
...defaultConfig,
|
|
646
|
+
additionalParams: {
|
|
647
|
+
access_type: 'offline',
|
|
648
|
+
prompt: 'consent',
|
|
649
|
+
},
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const secrets = createMockSecretService({
|
|
653
|
+
APP_CREDS: defaultAppCredential,
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
const client = new OAuth2Client(configWithParams, 'APP_CREDS', secrets)
|
|
657
|
+
const url = await client.getAuthorizationUrl(
|
|
658
|
+
'state',
|
|
659
|
+
'http://localhost/callback'
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
const parsed = new URL(url)
|
|
663
|
+
assert.equal(parsed.searchParams.get('access_type'), 'offline')
|
|
664
|
+
assert.equal(parsed.searchParams.get('prompt'), 'consent')
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
test('URL-encodes values', async () => {
|
|
668
|
+
const configWithSpecialScopes: OAuth2CredentialConfig = {
|
|
669
|
+
...defaultConfig,
|
|
670
|
+
scopes: ['read:user', 'write:data'],
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const secrets = createMockSecretService({
|
|
674
|
+
APP_CREDS: defaultAppCredential,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
const client = new OAuth2Client(
|
|
678
|
+
configWithSpecialScopes,
|
|
679
|
+
'APP_CREDS',
|
|
680
|
+
secrets
|
|
681
|
+
)
|
|
682
|
+
const url = await client.getAuthorizationUrl(
|
|
683
|
+
'state with spaces',
|
|
684
|
+
'http://localhost/callback?param=value'
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
// URL should be properly encoded
|
|
688
|
+
assert.ok(
|
|
689
|
+
url.includes('state+with+spaces') ||
|
|
690
|
+
url.includes('state%20with%20spaces')
|
|
691
|
+
)
|
|
692
|
+
assert.ok(
|
|
693
|
+
url.includes(
|
|
694
|
+
'redirect_uri=http%3A%2F%2Flocalhost%2Fcallback%3Fparam%3Dvalue'
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
})
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
describe('Code Exchange', () => {
|
|
701
|
+
test('makes correct HTTP request', async () => {
|
|
702
|
+
const secrets = createMockSecretService({
|
|
703
|
+
APP_CREDS: defaultAppCredential,
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
let capturedRequest: { url: string; options: RequestInit } | null = null
|
|
707
|
+
|
|
708
|
+
globalThis.fetch = mock.fn(
|
|
709
|
+
async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
710
|
+
capturedRequest = { url: url.toString(), options: options! }
|
|
711
|
+
return mockFetchResponse({
|
|
712
|
+
access_token: 'exchanged-token',
|
|
713
|
+
refresh_token: 'refresh-token',
|
|
714
|
+
expires_in: 3600,
|
|
715
|
+
token_type: 'Bearer',
|
|
716
|
+
})
|
|
717
|
+
}
|
|
718
|
+
) as typeof fetch
|
|
719
|
+
|
|
720
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
721
|
+
await client.exchangeCode('auth-code', 'http://localhost/callback')
|
|
722
|
+
|
|
723
|
+
assert.ok(capturedRequest, 'Expected capturedRequest to be set')
|
|
724
|
+
const req = capturedRequest as { url: string; options: RequestInit }
|
|
725
|
+
assert.equal(req.url, 'https://example.com/oauth/token')
|
|
726
|
+
assert.equal(req.options.method, 'POST')
|
|
727
|
+
|
|
728
|
+
const body = req.options.body as string
|
|
729
|
+
assert.ok(body.includes('grant_type=authorization_code'))
|
|
730
|
+
assert.ok(body.includes('code=auth-code'))
|
|
731
|
+
assert.ok(body.includes('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback'))
|
|
732
|
+
assert.ok(body.includes('client_id=test-client-id'))
|
|
733
|
+
assert.ok(body.includes('client_secret=test-client-secret'))
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
test('caches resulting token', async () => {
|
|
737
|
+
const secrets = createMockSecretService({
|
|
738
|
+
APP_CREDS: defaultAppCredential,
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
let fetchCallCount = 0
|
|
742
|
+
globalThis.fetch = mock.fn(async () => {
|
|
743
|
+
fetchCallCount++
|
|
744
|
+
return mockFetchResponse({
|
|
745
|
+
access_token: 'new-token',
|
|
746
|
+
refresh_token: 'new-refresh',
|
|
747
|
+
expires_in: 3600,
|
|
748
|
+
token_type: 'Bearer',
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
753
|
+
const token = await client.exchangeCode(
|
|
754
|
+
'code',
|
|
755
|
+
'http://localhost/callback'
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
assert.equal(token.accessToken, 'new-token')
|
|
759
|
+
|
|
760
|
+
// Subsequent getAccessToken should use cached token
|
|
761
|
+
const accessToken = await client.getAccessToken()
|
|
762
|
+
assert.equal(accessToken, 'new-token')
|
|
763
|
+
assert.equal(fetchCallCount, 1) // No additional fetch
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
test('throws on HTTP error', async () => {
|
|
767
|
+
const secrets = createMockSecretService({
|
|
768
|
+
APP_CREDS: defaultAppCredential,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
globalThis.fetch = mock.fn(async () =>
|
|
772
|
+
mockFetchResponse({ error: 'invalid_code' }, 400, false)
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
776
|
+
|
|
777
|
+
await assert.rejects(
|
|
778
|
+
async () =>
|
|
779
|
+
await client.exchangeCode('bad-code', 'http://localhost/callback'),
|
|
780
|
+
/Token exchange failed: 400/
|
|
781
|
+
)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
test('handles missing optional fields', async () => {
|
|
785
|
+
const secrets = createMockSecretService({
|
|
786
|
+
APP_CREDS: defaultAppCredential,
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
globalThis.fetch = mock.fn(async () =>
|
|
790
|
+
mockFetchResponse({
|
|
791
|
+
access_token: 'token-only',
|
|
792
|
+
// No refresh_token, expires_in, or scope
|
|
793
|
+
})
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
797
|
+
const token = await client.exchangeCode(
|
|
798
|
+
'code',
|
|
799
|
+
'http://localhost/callback'
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
assert.equal(token.accessToken, 'token-only')
|
|
803
|
+
assert.equal(token.refreshToken, undefined)
|
|
804
|
+
assert.equal(token.expiresAt, undefined)
|
|
805
|
+
assert.equal(token.tokenType, 'Bearer') // Default
|
|
806
|
+
assert.equal(token.scope, undefined)
|
|
807
|
+
})
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
describe('Security', () => {
|
|
811
|
+
test('validates token response', async () => {
|
|
812
|
+
const secrets = createMockSecretService({
|
|
813
|
+
APP_CREDS: defaultAppCredential,
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
// Response without access_token
|
|
817
|
+
globalThis.fetch = mock.fn(async () =>
|
|
818
|
+
mockFetchResponse({
|
|
819
|
+
refresh_token: 'refresh-only',
|
|
820
|
+
expires_in: 3600,
|
|
821
|
+
})
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
825
|
+
|
|
826
|
+
await assert.rejects(
|
|
827
|
+
async () =>
|
|
828
|
+
await client.exchangeCode('code', 'http://localhost/callback'),
|
|
829
|
+
/Invalid token response: missing access_token/
|
|
830
|
+
)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
test('validates access_token is a string', async () => {
|
|
834
|
+
const secrets = createMockSecretService({
|
|
835
|
+
APP_CREDS: defaultAppCredential,
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
// Response with non-string access_token
|
|
839
|
+
globalThis.fetch = mock.fn(async () =>
|
|
840
|
+
mockFetchResponse({
|
|
841
|
+
access_token: 12345, // Number instead of string
|
|
842
|
+
expires_in: 3600,
|
|
843
|
+
})
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
847
|
+
|
|
848
|
+
await assert.rejects(
|
|
849
|
+
async () =>
|
|
850
|
+
await client.exchangeCode('code', 'http://localhost/callback'),
|
|
851
|
+
/Invalid token response: missing access_token/
|
|
852
|
+
)
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
test('sanitizes error messages', async () => {
|
|
856
|
+
const expiredToken: OAuth2Token = {
|
|
857
|
+
accessToken: 'expired',
|
|
858
|
+
refreshToken: 'refresh',
|
|
859
|
+
expiresAt: Date.now() - 1000,
|
|
860
|
+
tokenType: 'Bearer',
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const secrets = createMockSecretService({
|
|
864
|
+
OAUTH_TOKENS: expiredToken,
|
|
865
|
+
APP_CREDS: defaultAppCredential,
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
// Error response with sensitive info
|
|
869
|
+
globalThis.fetch = mock.fn(async () =>
|
|
870
|
+
mockFetchResponse(
|
|
871
|
+
{
|
|
872
|
+
error: 'invalid_grant',
|
|
873
|
+
error_description: 'Refresh token expired for user@email.com',
|
|
874
|
+
},
|
|
875
|
+
400,
|
|
876
|
+
false
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
881
|
+
|
|
882
|
+
await assert.rejects(
|
|
883
|
+
async () => await client.getAccessToken(),
|
|
884
|
+
(error: Error) => {
|
|
885
|
+
// Should NOT contain the email or detailed error
|
|
886
|
+
assert.ok(!error.message.includes('user@email.com'))
|
|
887
|
+
assert.ok(!error.message.includes('Refresh token expired'))
|
|
888
|
+
// Should contain generic error info
|
|
889
|
+
assert.ok(error.message.includes('400'))
|
|
890
|
+
return true
|
|
891
|
+
}
|
|
892
|
+
)
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
test('timeout works', async () => {
|
|
896
|
+
const expiredToken: OAuth2Token = {
|
|
897
|
+
accessToken: 'expired',
|
|
898
|
+
refreshToken: 'refresh',
|
|
899
|
+
expiresAt: Date.now() - 1000,
|
|
900
|
+
tokenType: 'Bearer',
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const secrets = createMockSecretService({
|
|
904
|
+
OAUTH_TOKENS: expiredToken,
|
|
905
|
+
APP_CREDS: defaultAppCredential,
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
|
|
909
|
+
|
|
910
|
+
// The timeout is 30 seconds, which is too long for a test
|
|
911
|
+
// Instead, we verify that the signal is being passed
|
|
912
|
+
// by checking that an abort signal is present in the fetch call
|
|
913
|
+
let signalPassed = false
|
|
914
|
+
globalThis.fetch = mock.fn(
|
|
915
|
+
async (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
916
|
+
signalPassed = options?.signal instanceof AbortSignal
|
|
917
|
+
return mockFetchResponse({
|
|
918
|
+
access_token: 'token',
|
|
919
|
+
expires_in: 3600,
|
|
920
|
+
token_type: 'Bearer',
|
|
921
|
+
})
|
|
922
|
+
}
|
|
923
|
+
) as typeof fetch
|
|
924
|
+
|
|
925
|
+
await client.getAccessToken()
|
|
926
|
+
assert.equal(signalPassed, true)
|
|
927
|
+
})
|
|
928
|
+
})
|
|
929
|
+
})
|