@liushuangls/open-connector-runtime 1.4.0-sider.1 → 1.4.0-sider.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,39 @@ const providerLoader = new ProviderLoader(executorModules);
32
32
  Provider executor modules remain lazy: loading the registry or catalog does not eagerly import every provider.
33
33
  Only documented subpath exports are public API.
34
34
 
35
+ ## Host-managed OAuth
36
+
37
+ Multi-user hosts can use `OAuthAuthorizationService` without giving the Runtime ownership of OAuth state or user
38
+ credentials:
39
+
40
+ ```ts
41
+ import { OAuthAuthorizationService } from "@liushuangls/open-connector-runtime/oauth/oauth-authorization-service";
42
+
43
+ const oauth = new OAuthAuthorizationService({ clientConfigs });
44
+ const prepared = await oauth.prepareAuthorization({
45
+ service: "github",
46
+ connectionName: "work",
47
+ });
48
+
49
+ await pendingAuthorizations.set({
50
+ principal,
51
+ returnUrl,
52
+ runtimeState: prepared.pending,
53
+ });
54
+
55
+ const ownedAuthorization = await pendingAuthorizations.take(callbackState);
56
+ const exchanged = await oauth.exchangeAuthorizationCode({
57
+ pending: ownedAuthorization.runtimeState,
58
+ code: callbackCode,
59
+ });
60
+ await principalConnections.setOAuthCredential(exchanged.service, exchanged.credential, exchanged.connectionName);
61
+ ```
62
+
63
+ The host must authenticate and bind the initiating principal, encrypt `prepared.pending`, enforce a short TTL, and
64
+ consume it atomically. It must also persist `exchanged.credential` only through a principal-scoped connection store.
65
+ The pending value can contain a PKCE verifier or a custom OAuth client secret and must never be logged or returned to
66
+ the browser; only `prepared.authorizationUrl` is client-facing.
67
+
35
68
  ## License
36
69
 
37
70
  Apache-2.0. The published tarball includes the upstream license and notice files.
@@ -0,0 +1,67 @@
1
+ import type { ResolvedCredential } from "../core/types.ts";
2
+ import type { OAuthClientConfig, OAuthClientConfigInput, OAuthClientConfigService } from "./oauth-client-config-service.ts";
3
+ type OAuthCredential = Extract<ResolvedCredential, {
4
+ authType: "oauth2";
5
+ }>;
6
+ /**
7
+ * Sensitive, serializable context needed to finish an OAuth authorization.
8
+ *
9
+ * A multi-user host must bind this value to its authenticated principal, encrypt
10
+ * it at rest, expire it, and consume it atomically. The Runtime does none of
11
+ * those lifecycle operations on behalf of the host.
12
+ */
13
+ export interface OAuthAuthorizationState {
14
+ service: string;
15
+ connectionName?: string;
16
+ state: string;
17
+ createdAt: string;
18
+ pkceCodeVerifier?: string;
19
+ clientConfig?: OAuthClientConfig;
20
+ }
21
+ export interface PrepareOAuthAuthorizationInput {
22
+ service: string;
23
+ connectionName?: string;
24
+ clientConfig?: OAuthClientConfigInput;
25
+ }
26
+ /**
27
+ * Authorization request plus the pending context that its host must persist.
28
+ */
29
+ export interface PreparedOAuthAuthorization {
30
+ authorizationUrl: string;
31
+ pending: OAuthAuthorizationState;
32
+ }
33
+ export interface ExchangeOAuthAuthorizationCodeInput {
34
+ pending: OAuthAuthorizationState;
35
+ code: string;
36
+ }
37
+ /**
38
+ * Normalized credential returned to the host without persisting it.
39
+ */
40
+ export interface ExchangedOAuthCredential {
41
+ service: string;
42
+ connectionName?: string;
43
+ credential: OAuthCredential;
44
+ }
45
+ export interface OAuthAuthorizationServiceOptions {
46
+ clientConfigs: OAuthClientConfigService;
47
+ isCustomClientConfigAllowed?: (service: string) => boolean;
48
+ }
49
+ /**
50
+ * Stateless OAuth protocol primitives for hosts that own state and credentials.
51
+ */
52
+ export declare class OAuthAuthorizationService {
53
+ private readonly clientConfigs;
54
+ private readonly isCustomClientConfigAllowed;
55
+ constructor(input: OAuthAuthorizationServiceOptions);
56
+ prepareAuthorization(input: PrepareOAuthAuthorizationInput): Promise<PreparedOAuthAuthorization>;
57
+ exchangeAuthorizationCode(input: ExchangeOAuthAuthorizationCodeInput): Promise<ExchangedOAuthCredential>;
58
+ private resolveCustomClientConfig;
59
+ }
60
+ /**
61
+ * Error with a stable code suitable for HTTP responses.
62
+ */
63
+ export declare class OAuthFlowError extends Error {
64
+ readonly code: string;
65
+ constructor(code: string, message: string);
66
+ }
67
+ export {};
@@ -0,0 +1,133 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { normalizeSlackAuthorizationCredential } from "../providers/slack/oauth.js";
3
+ import { requestAuthorizationCodeToken } from "./oauth-token.js";
4
+ /**
5
+ * Stateless OAuth protocol primitives for hosts that own state and credentials.
6
+ */
7
+ export class OAuthAuthorizationService {
8
+ clientConfigs;
9
+ isCustomClientConfigAllowed;
10
+ constructor(input) {
11
+ this.clientConfigs = input.clientConfigs;
12
+ this.isCustomClientConfigAllowed = input.isCustomClientConfigAllowed ?? (() => false);
13
+ }
14
+ async prepareAuthorization(input) {
15
+ const { service, connectionName } = input;
16
+ const auth = this.clientConfigs.getOAuthDefinition(service);
17
+ const config = input.clientConfig
18
+ ? this.resolveCustomClientConfig(service, input.clientConfig)
19
+ : await this.clientConfigs.getConfig(service);
20
+ if (!config) {
21
+ throw new OAuthFlowError("oauth_client_config_required", `Configure an OAuth client for ${service} first.`);
22
+ }
23
+ const state = randomUUID();
24
+ const pkceCodeVerifier = auth.pkce ? createPkceCodeVerifier() : undefined;
25
+ const pending = {
26
+ service,
27
+ connectionName,
28
+ state,
29
+ createdAt: new Date().toISOString(),
30
+ pkceCodeVerifier,
31
+ clientConfig: input.clientConfig ? config : undefined,
32
+ };
33
+ const authorizationUrl = new URL(this.clientConfigs.resolveEndpointUrl(service, auth.authorizationUrl, config));
34
+ for (const [key, value] of Object.entries(auth.authorizationParams ?? {})) {
35
+ authorizationUrl.searchParams.set(key, value);
36
+ }
37
+ setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.clientId, "client_id", config.clientId);
38
+ setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.redirectUri, "redirect_uri", this.clientConfigs.expectedRedirectUri(service));
39
+ setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.responseType, "response_type", "code");
40
+ setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.state, "state", state);
41
+ const effectiveScopes = this.clientConfigs.getEffectiveScopes(service, config);
42
+ if (effectiveScopes.length > 0 && auth.authorizationRequestFields?.scope !== false) {
43
+ authorizationUrl.searchParams.set(auth.authorizationRequestFields?.scope ?? "scope", effectiveScopes.join(auth.scopeSeparator ?? " "));
44
+ }
45
+ if (pkceCodeVerifier) {
46
+ authorizationUrl.searchParams.set("code_challenge", createPkceCodeChallenge(pkceCodeVerifier));
47
+ authorizationUrl.searchParams.set("code_challenge_method", auth.pkce?.method ?? "S256");
48
+ }
49
+ return {
50
+ authorizationUrl: authorizationUrl.toString(),
51
+ pending,
52
+ };
53
+ }
54
+ async exchangeAuthorizationCode(input) {
55
+ const { pending } = input;
56
+ const auth = this.clientConfigs.getOAuthDefinition(pending.service);
57
+ const config = pending.clientConfig ?? (await this.clientConfigs.getConfig(pending.service));
58
+ if (!config) {
59
+ throw new OAuthFlowError("oauth_client_config_required", `Configure an OAuth client for ${pending.service} first.`);
60
+ }
61
+ let tokenResponse = await requestAuthorizationCodeToken({
62
+ code: input.code,
63
+ state: pending.state,
64
+ clientId: config.clientId,
65
+ clientSecret: config.clientSecret,
66
+ redirectUri: this.clientConfigs.expectedRedirectUri(pending.service),
67
+ responseEnvelope: auth.tokenResponseEnvelope,
68
+ tokenRequestFields: auth.tokenRequestFields,
69
+ tokenEndpointAuthMethod: auth.tokenEndpointAuthMethod,
70
+ tokenRequestFormat: auth.tokenRequestFormat,
71
+ tokenUrl: this.clientConfigs.resolveEndpointUrl(pending.service, auth.tokenUrl, config),
72
+ extraFields: createTokenExtraFields(pending),
73
+ createError: (message) => new OAuthFlowError("oauth_token_exchange_failed", message),
74
+ });
75
+ if (pending.service === "slack") {
76
+ // Slack returns a separately rotated user grant in `authed_user`.
77
+ // Move it out of non-secret metadata before returning the credential.
78
+ tokenResponse = normalizeSlackAuthorizationCredential(tokenResponse);
79
+ }
80
+ return {
81
+ service: pending.service,
82
+ connectionName: pending.connectionName,
83
+ credential: {
84
+ ...tokenResponse,
85
+ metadata: {
86
+ ...tokenResponse.metadata,
87
+ oauthClientId: config.clientId,
88
+ oauthClientExtra: config.extra,
89
+ oauthClientSecretExtra: config.secretExtra,
90
+ oauthClientConfig: pending.clientConfig ? config : undefined,
91
+ },
92
+ },
93
+ };
94
+ }
95
+ resolveCustomClientConfig(service, input) {
96
+ if (!this.isCustomClientConfigAllowed(service)) {
97
+ throw new OAuthFlowError("oauth_custom_app_not_allowed", `Custom OAuth apps are not enabled for ${service} on this runtime.`);
98
+ }
99
+ return this.clientConfigs.normalizeConfig(service, input);
100
+ }
101
+ }
102
+ function setAuthorizationParam(url, fieldName, defaultFieldName, value) {
103
+ if (fieldName !== false) {
104
+ url.searchParams.set(fieldName ?? defaultFieldName, value);
105
+ }
106
+ }
107
+ function createTokenExtraFields(state) {
108
+ if (!state.pkceCodeVerifier) {
109
+ return undefined;
110
+ }
111
+ return {
112
+ code_verifier: state.pkceCodeVerifier,
113
+ };
114
+ }
115
+ function createPkceCodeVerifier() {
116
+ return encodeBase64Url(randomBytes(48));
117
+ }
118
+ function createPkceCodeChallenge(codeVerifier) {
119
+ return encodeBase64Url(createHash("sha256").update(codeVerifier).digest());
120
+ }
121
+ function encodeBase64Url(value) {
122
+ return Buffer.from(value).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
123
+ }
124
+ /**
125
+ * Error with a stable code suitable for HTTP responses.
126
+ */
127
+ export class OAuthFlowError extends Error {
128
+ code;
129
+ constructor(code, message) {
130
+ super(message);
131
+ this.code = code;
132
+ }
133
+ }
@@ -1,6 +1,9 @@
1
1
  import type { ConnectionService } from "../connection-service.ts";
2
2
  import type { ISecretCodec } from "../server/secrets/secret-codec-core.ts";
3
- import type { OAuthClientConfig, OAuthClientConfigInput, OAuthClientConfigService } from "./oauth-client-config-service.ts";
3
+ import type { OAuthAuthorizationState } from "./oauth-authorization-service.ts";
4
+ import type { OAuthClientConfigInput, OAuthClientConfigService } from "./oauth-client-config-service.ts";
5
+ export type { OAuthAuthorizationState } from "./oauth-authorization-service.ts";
6
+ export { OAuthFlowError } from "./oauth-authorization-service.ts";
4
7
  /**
5
8
  * Started OAuth authorization flow returned to the local console.
6
9
  */
@@ -17,17 +20,6 @@ export interface OAuthAuthorizationCompleteInput {
17
20
  state: string;
18
21
  code: string;
19
22
  }
20
- /**
21
- * Short-lived OAuth state stored while the browser completes authorization.
22
- */
23
- export interface OAuthAuthorizationState {
24
- service: string;
25
- connectionName?: string;
26
- state: string;
27
- createdAt: string;
28
- pkceCodeVerifier?: string;
29
- clientConfig?: OAuthClientConfig;
30
- }
31
23
  export interface OAuthFlowServiceOptions {
32
24
  clientConfigs: OAuthClientConfigService;
33
25
  connections: ConnectionService;
@@ -47,7 +39,7 @@ export interface IOAuthStateStore {
47
39
  * Coordinates runtime OAuth authorization and token exchange.
48
40
  */
49
41
  export declare class OAuthFlowService {
50
- private readonly clientConfigs;
42
+ private readonly authorizations;
51
43
  private readonly connections;
52
44
  private readonly states;
53
45
  private readonly stateMaxAgeMs;
@@ -59,12 +51,5 @@ export declare class OAuthFlowService {
59
51
  service: string;
60
52
  connected: true;
61
53
  }>;
62
- private resolveCustomClientConfig;
63
- }
64
- /**
65
- * Error with a stable code suitable for HTTP responses.
66
- */
67
- export declare class OAuthFlowError extends Error {
68
- readonly code: string;
69
- constructor(code: string, message: string);
54
+ private assertCustomClientConfigCanBeStored;
70
55
  }
@@ -1,18 +1,20 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { normalizeSlackAuthorizationCredential } from "../providers/slack/oauth.js";
3
- import { requestAuthorizationCodeToken } from "./oauth-token.js";
1
+ import { OAuthAuthorizationService, OAuthFlowError } from "./oauth-authorization-service.js";
2
+ export { OAuthFlowError } from "./oauth-authorization-service.js";
4
3
  /**
5
4
  * Coordinates runtime OAuth authorization and token exchange.
6
5
  */
7
6
  export class OAuthFlowService {
8
- clientConfigs;
7
+ authorizations;
9
8
  connections;
10
9
  states;
11
10
  stateMaxAgeMs;
12
11
  secretCodec;
13
12
  isCustomClientConfigAllowed;
14
13
  constructor(input) {
15
- this.clientConfigs = input.clientConfigs;
14
+ this.authorizations = new OAuthAuthorizationService({
15
+ clientConfigs: input.clientConfigs,
16
+ isCustomClientConfigAllowed: input.isCustomClientConfigAllowed,
17
+ });
16
18
  this.connections = input.connections;
17
19
  this.states = input.states;
18
20
  this.stateMaxAgeMs = input.stateMaxAgeMs ?? 15 * 60 * 1000;
@@ -20,44 +22,13 @@ export class OAuthFlowService {
20
22
  this.isCustomClientConfigAllowed = input.isCustomClientConfigAllowed ?? (() => false);
21
23
  }
22
24
  async startAuthorization(input) {
23
- const { service, connectionName } = input;
24
- this.connections.assertProviderAvailable(service);
25
- const auth = this.clientConfigs.getOAuthDefinition(service);
26
- const config = input.clientConfig
27
- ? this.resolveCustomClientConfig(service, input.clientConfig)
28
- : await this.clientConfigs.getConfig(service);
29
- if (!config) {
30
- throw new OAuthFlowError("oauth_client_config_required", `Configure an OAuth client for ${service} first.`);
31
- }
32
- const state = crypto.randomUUID();
33
- const pkceCodeVerifier = auth.pkce ? createPkceCodeVerifier() : undefined;
34
- await this.states.set({
35
- service,
36
- connectionName,
37
- state,
38
- createdAt: new Date().toISOString(),
39
- pkceCodeVerifier,
40
- clientConfig: input.clientConfig ? config : undefined,
41
- });
42
- const authorizationUrl = new URL(this.clientConfigs.resolveEndpointUrl(service, auth.authorizationUrl, config));
43
- for (const [key, value] of Object.entries(auth.authorizationParams ?? {})) {
44
- authorizationUrl.searchParams.set(key, value);
45
- }
46
- setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.clientId, "client_id", config.clientId);
47
- setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.redirectUri, "redirect_uri", this.clientConfigs.expectedRedirectUri(service));
48
- setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.responseType, "response_type", "code");
49
- setAuthorizationParam(authorizationUrl, auth.authorizationRequestFields?.state, "state", state);
50
- const effectiveScopes = this.clientConfigs.getEffectiveScopes(service, config);
51
- if (effectiveScopes.length > 0 && auth.authorizationRequestFields?.scope !== false) {
52
- authorizationUrl.searchParams.set(auth.authorizationRequestFields?.scope ?? "scope", effectiveScopes.join(auth.scopeSeparator ?? " "));
53
- }
54
- if (pkceCodeVerifier) {
55
- authorizationUrl.searchParams.set("code_challenge", createPkceCodeChallenge(pkceCodeVerifier));
56
- authorizationUrl.searchParams.set("code_challenge_method", auth.pkce?.method ?? "S256");
57
- }
25
+ this.connections.assertProviderAvailable(input.service);
26
+ this.assertCustomClientConfigCanBeStored(input);
27
+ const prepared = await this.authorizations.prepareAuthorization(input);
28
+ await this.states.set(prepared.pending);
58
29
  return {
59
- authorizationUrl: authorizationUrl.toString(),
60
- state,
30
+ authorizationUrl: prepared.authorizationUrl,
31
+ state: prepared.pending.state,
61
32
  };
62
33
  }
63
34
  async completeAuthorization(input) {
@@ -68,89 +39,26 @@ export class OAuthFlowService {
68
39
  if (isExpiredOAuthState(pending, this.stateMaxAgeMs)) {
69
40
  throw new OAuthFlowError("invalid_oauth_state", "OAuth state is missing or expired.");
70
41
  }
71
- const auth = this.clientConfigs.getOAuthDefinition(pending.service);
72
- const config = pending.clientConfig ?? (await this.clientConfigs.getConfig(pending.service));
73
- if (!config) {
74
- throw new OAuthFlowError("oauth_client_config_required", `Configure an OAuth client for ${pending.service} first.`);
75
- }
76
- let tokenResponse = await requestAuthorizationCodeToken({
77
- code: input.code,
78
- state: pending.state,
79
- clientId: config.clientId,
80
- clientSecret: config.clientSecret,
81
- redirectUri: this.clientConfigs.expectedRedirectUri(pending.service),
82
- responseEnvelope: auth.tokenResponseEnvelope,
83
- tokenRequestFields: auth.tokenRequestFields,
84
- tokenEndpointAuthMethod: auth.tokenEndpointAuthMethod,
85
- tokenRequestFormat: auth.tokenRequestFormat,
86
- tokenUrl: this.clientConfigs.resolveEndpointUrl(pending.service, auth.tokenUrl, config),
87
- extraFields: createTokenExtraFields(pending),
88
- createError: (message) => new OAuthFlowError("oauth_token_exchange_failed", message),
89
- });
90
- if (pending.service == "slack") {
91
- // Slack returns a separately rotated user grant in `authed_user`.
92
- // Move it out of non-secret metadata before storing the credential.
93
- tokenResponse = normalizeSlackAuthorizationCredential(tokenResponse);
94
- }
95
- const oauthCredential = {
96
- ...tokenResponse,
97
- metadata: {
98
- ...tokenResponse.metadata,
99
- oauthClientId: config.clientId,
100
- oauthClientExtra: config.extra,
101
- oauthClientSecretExtra: config.secretExtra,
102
- oauthClientConfig: pending.clientConfig ? config : undefined,
103
- },
104
- };
105
- await this.connections.setOAuthCredential(pending.service, oauthCredential, pending.connectionName);
42
+ const exchanged = await this.authorizations.exchangeAuthorizationCode({ pending, code: input.code });
43
+ await this.connections.setOAuthCredential(exchanged.service, exchanged.credential, exchanged.connectionName);
106
44
  return {
107
- service: pending.service,
45
+ service: exchanged.service,
108
46
  connected: true,
109
47
  };
110
48
  }
111
- resolveCustomClientConfig(service, input) {
112
- if (!this.isCustomClientConfigAllowed(service)) {
113
- throw new OAuthFlowError("oauth_custom_app_not_allowed", `Custom OAuth apps are not enabled for ${service} on this runtime.`);
49
+ assertCustomClientConfigCanBeStored(input) {
50
+ if (!input.clientConfig) {
51
+ return;
52
+ }
53
+ if (!this.isCustomClientConfigAllowed(input.service)) {
54
+ throw new OAuthFlowError("oauth_custom_app_not_allowed", `Custom OAuth apps are not enabled for ${input.service} on this runtime.`);
114
55
  }
115
56
  if (!this.secretCodec?.encrypted) {
116
57
  throw new OAuthFlowError("oauth_custom_app_encryption_required", "Configure OOMOL_CONNECT_ENCRYPTION_KEY before using a custom OAuth app.");
117
58
  }
118
- return this.clientConfigs.normalizeConfig(service, input);
119
59
  }
120
60
  }
121
- function setAuthorizationParam(url, fieldName, defaultFieldName, value) {
122
- if (fieldName !== false) {
123
- url.searchParams.set(fieldName ?? defaultFieldName, value);
124
- }
125
- }
126
- function createTokenExtraFields(state) {
127
- if (!state.pkceCodeVerifier) {
128
- return undefined;
129
- }
130
- return {
131
- code_verifier: state.pkceCodeVerifier,
132
- };
133
- }
134
61
  function isExpiredOAuthState(state, maxAgeMs) {
135
62
  const createdAt = Date.parse(state.createdAt);
136
63
  return !Number.isFinite(createdAt) || Date.now() - createdAt > maxAgeMs;
137
64
  }
138
- function createPkceCodeVerifier() {
139
- return encodeBase64Url(randomBytes(48));
140
- }
141
- function createPkceCodeChallenge(codeVerifier) {
142
- return encodeBase64Url(createHash("sha256").update(codeVerifier).digest());
143
- }
144
- function encodeBase64Url(value) {
145
- return Buffer.from(value).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
146
- }
147
- /**
148
- * Error with a stable code suitable for HTTP responses.
149
- */
150
- export class OAuthFlowError extends Error {
151
- code;
152
- constructor(code, message) {
153
- super(message);
154
- this.code = code;
155
- }
156
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liushuangls/open-connector-runtime",
3
- "version": "1.4.0-sider.1",
3
+ "version": "1.4.0-sider.2",
4
4
  "description": "OpenConnector providers, actions, OAuth primitives, and execution runtime for Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/core/types.d.ts",
46
46
  "import": "./dist/core/types.js"
47
47
  },
48
+ "./oauth/oauth-authorization-service": {
49
+ "types": "./dist/oauth/oauth-authorization-service.d.ts",
50
+ "import": "./dist/oauth/oauth-authorization-service.js"
51
+ },
48
52
  "./oauth/oauth-client-config-service": {
49
53
  "types": "./dist/oauth/oauth-client-config-service.d.ts",
50
54
  "import": "./dist/oauth/oauth-client-config-service.js"