@vercel/connect 0.0.4-alpha.2 → 0.0.4-alpha.6

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
@@ -253,6 +253,27 @@ export default slackRoute({
253
253
 
254
254
  The `webhookVerifier` is set to `vercelOidc()`, which authenticates inbound webhooks using the Vercel OIDC token issued for the calling project.
255
255
 
256
+ ### `connectOAuth(options?)`
257
+
258
+ ```ts
259
+ function connectOAuth(options?: ConnectOAuthOptions): AuthFn<Request>;
260
+ ```
261
+
262
+ Returns an Ash route auth callback for Vercel Connect OAuth gateway access tokens. It verifies bearer JWTs issued by `https://connect.vercel.com`, requires the gateway access-token claim `typ: "at"`, and by default accepts Connect OAuth audiences shaped `teamId:VERCEL_PROJECT_ID:VERCEL_ENV`; the team id segment is ignored because project ids are globally unique.
263
+
264
+ ```ts
265
+ import { httpRoute } from "experimental-ash/channels/http";
266
+ import { connectOAuth } from "@vercel/connect/ash";
267
+
268
+ export default httpRoute({
269
+ auth: connectOAuth({
270
+ connectors: ["slack/mybot"],
271
+ }),
272
+ });
273
+ ```
274
+
275
+ Pass `audiences` to accept explicit `aud` values, or pass `projectId` and `environment` to build the Connect OAuth audience manually. `connectors` accepts both `scl_...` connector ids and connector UIDs by checking each value against the `clientId` and `clientUid` claims; `tenantIds`, `installationIds`, `subjects`, and `claims` can further narrow accepted gateway tokens.
276
+
256
277
  ---
257
278
 
258
279
  ## `@vercel/connect/betterauth`
@@ -265,7 +286,7 @@ Better Auth provider for Vercel Connect's OAuth2 endpoints. Importing this subpa
265
286
  function connect(options: BetterAuthConnectOptions): GenericOAuthConfig;
266
287
  ```
267
288
 
268
- Returns a `GenericOAuthConfig` entry that drops into `genericOAuth({ config: [...] })` from `better-auth/plugins/generic-oauth`. Vercel Connect's OAuth client authentication is non-standard — the "client secret" is a Vercel OIDC token that has to be fetched per-request — so the helper overrides `getToken` and `getUserInfo` to inject the right `Authorization` headers.
289
+ Returns a `GenericOAuthConfig` entry that drops into `genericOAuth({ config: [...] })` from `better-auth/plugins/generic-oauth`. Vercel Connect's OAuth client authentication is non-standard — the "client secret" is a Vercel OIDC token that has to be fetched per-request — so the helper overrides `getToken` to inject the token endpoint `Authorization` header.
269
290
 
270
291
  ```ts
271
292
  import { betterAuth } from "better-auth";
@@ -309,7 +330,7 @@ Auth.js (NextAuth core) provider for Vercel Connect's OAuth2 endpoints. Importin
309
330
  function connect(options: AuthJsConnectOptions): OAuth2Config<ConnectProfile>;
310
331
  ```
311
332
 
312
- Returns an `OAuth2Config` provider entry for `AuthConfig.providers`. Vercel Connect's token endpoint expects the Vercel OIDC token in place of a static client secret, so the helper declares `token_endpoint_auth_method: "none"` and injects the real `Authorization` headers via Auth.js's `customFetch` symbol on both the token and userinfo endpoints.
333
+ Returns an `OAuth2Config` provider entry for `AuthConfig.providers`. Vercel Connect's token endpoint expects the Vercel OIDC token in place of a static client secret, so the helper declares `token_endpoint_auth_method: "none"` and injects the token endpoint `Authorization` header via Auth.js's `customFetch` symbol.
313
334
 
314
335
  ```ts
315
336
  import type { AuthConfig } from "@auth/core";
@@ -0,0 +1,67 @@
1
+ import { type AuthFn, type VerifyOidcConfig } from "experimental-ash/channels/auth";
2
+ export declare const CONNECT_OAUTH_ISSUER = "https://connect.vercel.com";
3
+ export type ConnectOAuthEnvironment = "production" | "preview" | "development";
4
+ export type ConnectOAuthAudienceEnvironment = ConnectOAuthEnvironment | "*";
5
+ export interface ConnectOAuthOptions {
6
+ /**
7
+ * Exact `aud` claim values to accept. When omitted, the helper
8
+ * accepts Connect OAuth audiences shaped
9
+ * `teamId:projectId:environment` whose project id and environment
10
+ * match the current Vercel deployment.
11
+ */
12
+ readonly audiences?: readonly string[];
13
+ /**
14
+ * Vercel project id used when `audiences` is omitted. Defaults to
15
+ * `VERCEL_PROJECT_ID`.
16
+ */
17
+ readonly projectId?: string;
18
+ /**
19
+ * Vercel deployment environment used when `audiences` is omitted.
20
+ * Defaults to `VERCEL_TARGET_ENV`, then `VERCEL_ENV`. Use `"*"` to
21
+ * accept production, preview, and development audiences for the
22
+ * selected project.
23
+ */
24
+ readonly environment?: ConnectOAuthAudienceEnvironment;
25
+ /**
26
+ * Optional gateway session id (`sub`) matchers. Patterns use Ash's
27
+ * IAM-style `*` wildcard matching.
28
+ */
29
+ readonly subjects?: readonly string[];
30
+ /**
31
+ * Optional Connect connector ids / UIDs to accept. Each value is
32
+ * matched against both the `clientId` and `clientUid` claims.
33
+ */
34
+ readonly connectors?: readonly string[];
35
+ /** Optional upstream tenant ids (`tenantId` claim) to accept. */
36
+ readonly tenantIds?: readonly string[];
37
+ /**
38
+ * Optional connector installation ids (`installationId` claim) to
39
+ * accept.
40
+ */
41
+ readonly installationIds?: readonly string[];
42
+ /**
43
+ * Additional exact-match string claims to require. `typ` is always
44
+ * constrained to `"at"` for Connect OAuth gateway access tokens.
45
+ */
46
+ readonly claims?: VerifyOidcConfig["claims"];
47
+ /**
48
+ * Override the OIDC discovery URL. Defaults to
49
+ * `https://connect.vercel.com/.well-known/openid-configuration`.
50
+ */
51
+ readonly discoveryUrl?: string;
52
+ /** Clock skew in seconds. Defaults to Ash's OIDC verifier default. */
53
+ readonly clockSkewSeconds?: number;
54
+ }
55
+ /**
56
+ * Returns an Ash route auth callback for Vercel Connect OAuth gateway
57
+ * access tokens.
58
+ *
59
+ * The accepted token must be a bearer JWT issued by
60
+ * `https://connect.vercel.com`, have the gateway access-token marker
61
+ * `typ: "at"`, and target one of the configured audiences. By default
62
+ * the audience is a Connect OAuth audience for the current Vercel
63
+ * project:
64
+ *
65
+ * `teamId:VERCEL_PROJECT_ID:VERCEL_ENV`
66
+ */
67
+ export declare function connectOAuth(opts?: ConnectOAuthOptions): AuthFn<Request>;
@@ -0,0 +1,165 @@
1
+ import { extractBearerToken, verifyOidc, } from "experimental-ash/channels/auth";
2
+ export const CONNECT_OAUTH_ISSUER = "https://connect.vercel.com";
3
+ /**
4
+ * Returns an Ash route auth callback for Vercel Connect OAuth gateway
5
+ * access tokens.
6
+ *
7
+ * The accepted token must be a bearer JWT issued by
8
+ * `https://connect.vercel.com`, have the gateway access-token marker
9
+ * `typ: "at"`, and target one of the configured audiences. By default
10
+ * the audience is a Connect OAuth audience for the current Vercel
11
+ * project:
12
+ *
13
+ * `teamId:VERCEL_PROJECT_ID:VERCEL_ENV`
14
+ */
15
+ export function connectOAuth(opts = {}) {
16
+ const audiencePolicy = resolveAudiencePolicy(opts);
17
+ const connectorPolicy = resolveConnectorPolicy(opts);
18
+ const claims = buildClaimMatchers(opts);
19
+ return async (request) => {
20
+ const token = extractBearerToken(request.headers.get("authorization"));
21
+ if (token === null) {
22
+ return null;
23
+ }
24
+ const payload = decodeJwtPayload(token);
25
+ if (payload === null) {
26
+ return null;
27
+ }
28
+ const audiences = resolveRequestAudiences(payload, audiencePolicy);
29
+ if (audiences === null ||
30
+ !isConnectorAccepted(payload, connectorPolicy)) {
31
+ return null;
32
+ }
33
+ const result = await verifyOidc(token, {
34
+ audiences,
35
+ claims,
36
+ clockSkewSeconds: opts.clockSkewSeconds,
37
+ discoveryUrl: opts.discoveryUrl,
38
+ issuer: CONNECT_OAUTH_ISSUER,
39
+ subjects: opts.subjects,
40
+ });
41
+ return result.ok ? result.sessionAuth : null;
42
+ };
43
+ }
44
+ function resolveAudiencePolicy(opts) {
45
+ if (opts.audiences !== undefined) {
46
+ if (opts.audiences.length === 0) {
47
+ throw new Error("connectOAuth: audiences must not be empty.");
48
+ }
49
+ return { kind: "exact", audiences: opts.audiences };
50
+ }
51
+ const projectId = opts.projectId ?? readNonEmptyEnv("VERCEL_PROJECT_ID");
52
+ const environment = opts.environment ?? inferVercelEnvironment();
53
+ if (projectId === undefined) {
54
+ throw new Error("connectOAuth: could not infer projectId from VERCEL_PROJECT_ID; pass audiences or projectId explicitly.");
55
+ }
56
+ if (environment === undefined) {
57
+ throw new Error("connectOAuth: could not infer environment from VERCEL_TARGET_ENV or VERCEL_ENV; pass audiences or environment explicitly.");
58
+ }
59
+ const environments = environment === "*"
60
+ ? ["production", "preview", "development"]
61
+ : [environment];
62
+ return { kind: "project", environments, projectId };
63
+ }
64
+ function resolveRequestAudiences(payload, policy) {
65
+ if (policy.kind === "exact") {
66
+ return policy.audiences;
67
+ }
68
+ const audiences = extractAudiences(payload);
69
+ const accepted = audiences.filter((audience) => {
70
+ const parsed = parseConnectOAuthAudience(audience);
71
+ return (parsed !== null &&
72
+ parsed.projectId === policy.projectId &&
73
+ policy.environments.includes(parsed.environment));
74
+ });
75
+ return accepted.length === 0 ? null : accepted;
76
+ }
77
+ function resolveConnectorPolicy(opts) {
78
+ if (opts.connectors === undefined) {
79
+ return { kind: "any" };
80
+ }
81
+ if (opts.connectors.length === 0) {
82
+ throw new Error("connectOAuth: connectors must not be empty.");
83
+ }
84
+ return { kind: "connectors", connectors: opts.connectors };
85
+ }
86
+ function isConnectorAccepted(payload, policy) {
87
+ if (policy.kind === "any") {
88
+ return true;
89
+ }
90
+ const clientId = stringClaim(payload, "clientId");
91
+ if (clientId !== undefined && policy.connectors.includes(clientId)) {
92
+ return true;
93
+ }
94
+ const clientUid = stringClaim(payload, "clientUid");
95
+ return clientUid !== undefined && policy.connectors.includes(clientUid);
96
+ }
97
+ function inferVercelEnvironment() {
98
+ const value = readNonEmptyEnv("VERCEL_TARGET_ENV") ?? readNonEmptyEnv("VERCEL_ENV");
99
+ if (value === undefined)
100
+ return undefined;
101
+ assertEnvironment(value);
102
+ return value;
103
+ }
104
+ function buildClaimMatchers(opts) {
105
+ return {
106
+ ...opts.claims,
107
+ ...(opts.tenantIds === undefined ? {} : { tenantId: opts.tenantIds }),
108
+ ...(opts.installationIds === undefined
109
+ ? {}
110
+ : { installationId: opts.installationIds }),
111
+ typ: ["at"],
112
+ };
113
+ }
114
+ function readNonEmptyEnv(name) {
115
+ const value = typeof process === "undefined" ? undefined : process.env?.[name]?.trim();
116
+ return value === undefined || value.length === 0 ? undefined : value;
117
+ }
118
+ function extractAudiences(payload) {
119
+ const aud = payload.aud;
120
+ if (typeof aud === "string") {
121
+ return [aud];
122
+ }
123
+ if (Array.isArray(aud)) {
124
+ return aud.filter((value) => typeof value === "string");
125
+ }
126
+ return [];
127
+ }
128
+ function stringClaim(payload, claim) {
129
+ const value = payload[claim];
130
+ return typeof value === "string" && value.length > 0 ? value : undefined;
131
+ }
132
+ function decodeJwtPayload(token) {
133
+ try {
134
+ const payload = token.split(".")[1];
135
+ if (payload === undefined)
136
+ return null;
137
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
138
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
139
+ const decoded = JSON.parse(atob(padded));
140
+ return typeof decoded === "object" && decoded !== null ? decoded : null;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ function parseConnectOAuthAudience(audience) {
147
+ const [teamId, projectId, environment, extra] = audience.split(":");
148
+ if (!teamId || !projectId || !environment || extra !== undefined) {
149
+ return null;
150
+ }
151
+ if (environment !== "production" &&
152
+ environment !== "preview" &&
153
+ environment !== "development") {
154
+ return null;
155
+ }
156
+ return { projectId, environment };
157
+ }
158
+ function assertEnvironment(value) {
159
+ if (value !== "production" &&
160
+ value !== "preview" &&
161
+ value !== "development" &&
162
+ value !== "*") {
163
+ throw new Error(`connectOAuth: invalid environment ${JSON.stringify(value)}; expected "production", "preview", "development", or "*".`);
164
+ }
165
+ }
@@ -7,4 +7,5 @@
7
7
  * everything from `@vercel/connect/ash`.
8
8
  */
9
9
  export { connect, type AshAuthorizationInput, type AshAuthorizationOptions, type ConnectAuthorizationPhase, type ConnectAuthorizationState, } from "./connection-authorization.js";
10
+ export { CONNECT_OAUTH_ISSUER, connectOAuth, type ConnectOAuthAudienceEnvironment, type ConnectOAuthEnvironment, type ConnectOAuthOptions, } from "./connect-oauth.js";
10
11
  export { connectSlackCredentials } from "./slack-credentials.js";
package/dist/ash/index.js CHANGED
@@ -7,4 +7,5 @@
7
7
  * everything from `@vercel/connect/ash`.
8
8
  */
9
9
  export { connect, } from "./connection-authorization.js";
10
+ export { CONNECT_OAUTH_ISSUER, connectOAuth, } from "./connect-oauth.js";
10
11
  export { connectSlackCredentials } from "./slack-credentials.js";
@@ -5,8 +5,8 @@
5
5
  * in `AuthConfig.providers`. Vercel Connect's OAuth client
6
6
  * authentication is non-standard — the "client secret" is a
7
7
  * per-request Vercel OIDC token — so we declare
8
- * `token_endpoint_auth_method: "none"` and inject the real
9
- * Authorization headers via the `customFetch` symbol.
8
+ * `token_endpoint_auth_method: "none"` and inject the token endpoint
9
+ * Authorization header via the `customFetch` symbol.
10
10
  *
11
11
  * ```ts
12
12
  * import type { AuthConfig } from "@auth/core";
@@ -26,9 +26,9 @@
26
26
  import { customFetch } from "@auth/core";
27
27
  import { getVercelOidcToken } from "@vercel/oidc";
28
28
  import { tryGetVercelTeamId, withTeamId } from "../internal/team-id.js";
29
- const CONNECT_AUTHORIZATION_URL = "https://vercel.com/api/connex/oauth/authorize";
30
- const CONNECT_TOKEN_URL = "https://vercel.com/api/connex/oauth/token";
31
- const CONNECT_USERINFO_URL = "https://vercel.com/api/connex/oauth/userinfo";
29
+ const CONNECT_AUTHORIZATION_URL = "https://connect.vercel.com/oauth/authorize";
30
+ const CONNECT_TOKEN_URL = "https://connect.vercel.com/oauth/token";
31
+ const CONNECT_USERINFO_URL = "https://connect.vercel.com/oauth/userinfo";
32
32
  /**
33
33
  * Builds an Auth.js `OAuth2Config` for Vercel Connect. Push the
34
34
  * result into `AuthConfig.providers`.
@@ -43,9 +43,8 @@ export function connect(options) {
43
43
  clientId: options.connector,
44
44
  // The token endpoint expects client_secret_basic, but the secret
45
45
  // is a Vercel OIDC token that has to be fetched per-request. We
46
- // rely on the customFetch hook below to inject the real
47
- // Authorization header — Auth.js skips building one because we
48
- // declared "none" here.
46
+ // rely on the customFetch hook below to inject the token endpoint
47
+ // Authorization header.
49
48
  client: { token_endpoint_auth_method: "none" },
50
49
  authorization: {
51
50
  // Best-effort: scope the Vercel Connect consent UI to the
@@ -79,16 +78,6 @@ export function connect(options) {
79
78
  headers.set("authorization", `Basic ${btoa(credentials)}`);
80
79
  return fetch(input, { ...init, headers });
81
80
  }
82
- if (url.startsWith(CONNECT_USERINFO_URL)) {
83
- const headers = new Headers(init?.headers);
84
- const accessToken = (headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "");
85
- const vercelToken = await fetchVercelToken();
86
- const triple = [options.connector, vercelToken, accessToken]
87
- .map(encodeURIComponent)
88
- .join(":");
89
- headers.set("authorization", `Bearer ${btoa(triple)}`);
90
- return fetch(input, { ...init, headers });
91
- }
92
81
  return fetch(input, init);
93
82
  },
94
83
  };
@@ -10,7 +10,7 @@ export async function startAuthorization(connector, params, options) {
10
10
  validateWebhookUrl(options.webhook);
11
11
  }
12
12
  const vercelToken = options?.vercelToken ?? (await getVercelOidcToken());
13
- const endpoint = `https://api.vercel.com/v1/connex/authorize/${encodeURIComponent(connector)}`;
13
+ const endpoint = `https://api.vercel.com/v1/connect/authorize/${encodeURIComponent(connector)}`;
14
14
  const body = {
15
15
  ...params,
16
16
  ...(options?.callbackUrl !== undefined && {
@@ -6,8 +6,7 @@
6
6
  * `better-auth/plugins/generic-oauth`. Vercel Connect's OAuth client
7
7
  * authentication is non-standard — the "client secret" is a Vercel
8
8
  * OIDC token that has to be fetched per-request — so we override
9
- * `getToken` and `getUserInfo` to inject the right Authorization
10
- * headers instead of relying on Better Auth's built-in flow.
9
+ * `getToken` to inject the token endpoint Authorization header.
11
10
  *
12
11
  * ```ts
13
12
  * import { betterAuth } from "better-auth";
@@ -31,9 +30,9 @@
31
30
  import { getVercelOidcToken } from "@vercel/oidc";
32
31
  import { getOAuth2Tokens } from "better-auth/oauth2";
33
32
  import { tryGetVercelTeamId, withTeamId } from "../internal/team-id.js";
34
- const CONNECT_AUTHORIZATION_URL = "https://vercel.com/api/connex/oauth/authorize";
35
- const CONNECT_TOKEN_URL = "https://vercel.com/api/connex/oauth/token";
36
- const CONNECT_USERINFO_URL = "https://vercel.com/api/connex/oauth/userinfo";
33
+ const CONNECT_AUTHORIZATION_URL = "https://connect.vercel.com/oauth/authorize";
34
+ const CONNECT_TOKEN_URL = "https://connect.vercel.com/oauth/token";
35
+ const CONNECT_USERINFO_URL = "https://connect.vercel.com/oauth/userinfo";
37
36
  /**
38
37
  * Builds a `GenericOAuthConfig` entry for Vercel Connect. Pass the
39
38
  * result into `genericOAuth({ config: [...] })`.
@@ -83,13 +82,8 @@ export function connect(options) {
83
82
  if (!tokens.accessToken) {
84
83
  throw new Error("Missing access token for Vercel Connect userinfo request");
85
84
  }
86
- const vercelToken = await fetchVercelToken();
87
- const triple = [options.connector, vercelToken, tokens.accessToken]
88
- .map(encodeURIComponent)
89
- .join(":");
90
- const bearerToken = btoa(triple);
91
85
  const response = await fetch(CONNECT_USERINFO_URL, {
92
- headers: { Authorization: `Bearer ${bearerToken}` },
86
+ headers: { Authorization: `Bearer ${tokens.accessToken}` },
93
87
  });
94
88
  if (!response.ok) {
95
89
  throw new Error(`Failed to fetch Vercel Connect user info: ${response.status} ${response.statusText}: ${await response.text()}`);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Best-effort Vercel team-id extraction for the OAuth adapters.
3
3
  *
4
- * Vercel Connect's `/api/connex/oauth/authorize` endpoint accepts an
4
+ * Vercel Connect's `/oauth/authorize` endpoint accepts an
5
5
  * optional `?teamId=...` query parameter that scopes the consent UI
6
6
  * to a specific team. The team id lives inside the Vercel OIDC
7
7
  * token's `owner_id` claim, so we decode the JWT payload (no
package/dist/token.js CHANGED
@@ -34,7 +34,7 @@ export async function getTokenResponse(connector, params, options) {
34
34
  cache.delete(cacheKey);
35
35
  }
36
36
  const vercelToken = options?.vercelToken ?? (await getVercelOidcToken());
37
- const endpoint = `https://api.vercel.com/v1/connex/token/${encodeURIComponent(connector)}`;
37
+ const endpoint = `https://api.vercel.com/v1/connect/token/${encodeURIComponent(connector)}`;
38
38
  const response = await fetch(endpoint, {
39
39
  method: "POST",
40
40
  headers: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/connect",
3
- "version": "0.0.4-alpha.2",
3
+ "version": "0.0.4-alpha.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {