@voltro/plugin-auth-workos 0.33.0 → 0.34.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/dist/index.d.ts CHANGED
@@ -7,24 +7,58 @@ declare interface ApiCallOptions {
7
7
  readonly fetchImpl?: typeof fetch;
8
8
  }
9
9
 
10
- /** Exchange an authorization code for the authenticated WorkOS user. */
10
+ /**
11
+ * Exchange an authorization code for the authenticated WorkOS user — AFTER
12
+ * checking the CSRF `state`, and carrying the PKCE `code_verifier`.
13
+ *
14
+ * **The state check lives in here rather than beside the caller's `if (!code)`
15
+ * deliberately.** A generated `state` that nothing verifies is worse than no
16
+ * state at all: it makes a code review, a screenshot of the authorize URL and a
17
+ * penetration test all read as "CSRF is handled". Making the check part of the
18
+ * only function that can redeem a code means an integration cannot arrive at a
19
+ * session without having performed it — the loop is closed by the type
20
+ * signature, not by a note in the docs.
21
+ *
22
+ * Both values are compared in constant time. `state` is not a high-value secret,
23
+ * but it IS a secret compared against attacker-supplied input, and a `===` on
24
+ * that shape is the habit this repo does not keep.
25
+ */
11
26
  export declare const workosAuthenticateWithCode: (o: WorkosExchangeOptions) => Promise<WorkosProfile>;
12
27
 
13
28
  /** Verify a Magic Auth one-time code → the authenticated WorkOS user. */
14
29
  export declare const workosAuthenticateWithMagicAuth: (o: WorkosMagicAuthVerifyOptions) => Promise<WorkosProfile>;
15
30
 
16
- /** Build the WorkOS hosted-login URL to redirect the browser to. */
17
- export declare const workosAuthorizationUrl: (o: WorkosAuthorizationUrlOptions) => string;
31
+ /**
32
+ * Begin a WorkOS hosted-login (AuthKit) flow: mint `state` + a PKCE
33
+ * `code_verifier`, and build the authorize URL carrying `state` and the S256
34
+ * `code_challenge`.
35
+ *
36
+ * **`state` is not optional and is not caller-supplied.** It used to be both —
37
+ * `if (o.state) params.set('state', o.state)` — which made the CSRF defence
38
+ * something an integration had to know to ask for, and made "the login works"
39
+ * indistinguishable from "the login is forgeable". An attacker who can get a
40
+ * victim's browser to hit a callback with an authorization code THEY obtained
41
+ * logs the victim into the ATTACKER's account; `state` is the only thing that
42
+ * stops it, so it is minted here, always, from `randomBytes`.
43
+ *
44
+ * **PKCE (S256) rides along.** The authorization code travels through the
45
+ * browser's address bar and the referrer chain; without a verifier, anyone who
46
+ * captures it before the app redeems it can redeem it themselves. WorkOS
47
+ * accepts `code_challenge`/`code_challenge_method` on authorize and
48
+ * `code_verifier` on authenticate, so the loop closes with no extra round trip.
49
+ *
50
+ * An app that used to carry a post-login return path INSIDE `state` puts it in
51
+ * its own cookie next to these two instead — mixing an application payload into
52
+ * a CSRF nonce is what made the nonce guessable in the first place.
53
+ */
54
+ export declare const workosBeginLogin: (o: WorkosBeginLoginOptions) => WorkosLoginHandoff;
18
55
 
19
- export declare interface WorkosAuthorizationUrlOptions {
56
+ export declare interface WorkosBeginLoginOptions {
20
57
  /** WorkOS Client ID (`client_01…`). */
21
58
  readonly clientId: string;
22
59
  /** The callback URL WorkOS redirects to with `?code=…&state=…`. Must be
23
60
  * registered in the WorkOS dashboard. */
24
61
  readonly redirectUri: string;
25
- /** Opaque state echoed back to the callback — use it for CSRF defence
26
- * and to carry a post-login return path. */
27
- readonly state?: string;
28
62
  /** `'authkit'` (default) = WorkOS' hosted AuthKit UI; or a specific
29
63
  * connection/provider id for direct SSO. */
30
64
  readonly provider?: string;
@@ -32,6 +66,15 @@ export declare interface WorkosAuthorizationUrlOptions {
32
66
  readonly loginHint?: string;
33
67
  /** Override the API origin (tests). Default `https://api.workos.com`. */
34
68
  readonly apiBase?: string;
69
+ /**
70
+ * Inject the two random values instead of minting them. TESTS ONLY — a
71
+ * deterministic `state` is a CSRF token an attacker can also predict, and a
72
+ * deterministic `codeVerifier` is no PKCE at all.
73
+ */
74
+ readonly randomness?: {
75
+ readonly state: string;
76
+ readonly codeVerifier: string;
77
+ };
35
78
  }
36
79
 
37
80
  /** Claims we expect from a WorkOS-issued AuthKit access token. The
@@ -86,6 +129,14 @@ export declare interface WorkosExchangeOptions {
86
129
  readonly apiKey: string;
87
130
  /** The `code` from the callback query string. */
88
131
  readonly code: string;
132
+ /** The `state` from the callback query string — what the browser presented. */
133
+ readonly state: string;
134
+ /** The `state` {@link workosBeginLogin} minted for this browser, read back
135
+ * out of wherever the app stashed it. */
136
+ readonly expectedState: string;
137
+ /** The PKCE `codeVerifier` {@link workosBeginLogin} minted, read back out of
138
+ * wherever the app stashed it. */
139
+ readonly codeVerifier: string;
89
140
  readonly apiBase?: string;
90
141
  /** Injectable fetch for tests. */
91
142
  readonly fetchImpl?: typeof fetch;
@@ -109,6 +160,25 @@ export declare const workosListUserMemberships: (o: ApiCallOptions & {
109
160
  readonly userId: string;
110
161
  }) => Promise<ReadonlyArray<WorkosMembership>>;
111
162
 
163
+ /**
164
+ * What a login handoff produces: the URL to send the browser to, plus the two
165
+ * secrets the CALLBACK has to be able to check.
166
+ *
167
+ * Both `state` and `codeVerifier` must be stashed somewhere the same browser
168
+ * will present back — a short-lived `HttpOnly` cookie is the usual answer — and
169
+ * handed to {@link workosAuthenticateWithCode}. There is no server-side store
170
+ * here on purpose: this package is transport-thin, and where the app keeps a
171
+ * 10-minute nonce is the app's decision.
172
+ */
173
+ export declare interface WorkosLoginHandoff {
174
+ /** The WorkOS hosted-login URL to redirect the browser to. */
175
+ readonly url: string;
176
+ /** The CSRF nonce. Compare against the callback's `?state=` before exchanging. */
177
+ readonly state: string;
178
+ /** The PKCE verifier. Send it with the code exchange. */
179
+ readonly codeVerifier: string;
180
+ }
181
+
112
182
  export declare interface WorkosMagicAuthSendOptions {
113
183
  /** WorkOS API key (`sk_…`) — sent as a Bearer token. Server-only. */
114
184
  readonly apiKey: string;
@@ -162,6 +232,17 @@ export declare interface WorkosProfile {
162
232
  */
163
233
  export declare const workosSendMagicAuthCode: (o: WorkosMagicAuthSendOptions) => Promise<void>;
164
234
 
235
+ /**
236
+ * The callback's `state` did not match the one minted for this browser.
237
+ *
238
+ * A distinct class because it is a distinct event: every other
239
+ * `WorkosExchangeError` means WorkOS said no, this one means the request did
240
+ * not come from a login THIS server started. Worth alerting on separately.
241
+ */
242
+ export declare class WorkosStateMismatchError extends WorkosExchangeError {
243
+ constructor(message: string);
244
+ }
245
+
165
246
  export declare const workosStrategy: (options: WorkosStrategyOptions) => AuthStrategy;
166
247
 
167
248
  export declare interface WorkosStrategyOptions {
package/dist/index.js CHANGED
@@ -1,40 +1,53 @@
1
1
  import { jwtBearerStrategy as e } from "@voltro/protocol/jwt";
2
+ import { createHash as t, randomBytes as n } from "node:crypto";
3
+ import { timingSafeStringEqual as r } from "@voltro/protocol/session";
2
4
  //#region src/strategy.ts
3
- var t = "https://api.workos.com", n = "wos-session", r = (r) => {
4
- if (!r.clientId) throw Error("workosStrategy: `clientId` is required");
5
- let i = r.scopesFromClaims;
5
+ var i = "https://api.workos.com", a = "wos-session", o = (t) => {
6
+ if (!t.clientId) throw Error("workosStrategy: `clientId` is required");
7
+ let n = t.scopesFromClaims;
6
8
  return e({
7
9
  id: "workos",
8
- jwksUrl: r.jwksUrl ?? `${t}/sso/jwks/${r.clientId}`,
9
- issuer: r.issuer ?? t,
10
- audience: r.clientId,
10
+ jwksUrl: t.jwksUrl ?? `${i}/sso/jwks/${t.clientId}`,
11
+ issuer: t.issuer ?? i,
12
+ audience: t.clientId,
11
13
  algorithms: ["RS256", "ES256"],
12
- cookieName: r.cookieName === void 0 ? n : r.cookieName,
13
- tenantIdFromClaims: (e) => r.tenantIdFromClaims?.(e) ?? (typeof e.org_id == "string" ? e.org_id : void 0) ?? r.defaultTenantId ?? null,
14
- ...i === void 0 ? {} : { scopesFromClaims: (e) => i(e) }
14
+ cookieName: t.cookieName === void 0 ? a : t.cookieName,
15
+ tenantIdFromClaims: (e) => t.tenantIdFromClaims?.(e) ?? (typeof e.org_id == "string" ? e.org_id : void 0) ?? t.defaultTenantId ?? null,
16
+ ...n === void 0 ? {} : { scopesFromClaims: (e) => n(e) }
15
17
  });
16
- }, i = "https://api.workos.com", a = (e) => {
17
- if (!e.clientId) throw Error("workosAuthorizationUrl: `clientId` is required");
18
- if (!e.redirectUri) throw Error("workosAuthorizationUrl: `redirectUri` is required");
19
- let t = new URLSearchParams({
18
+ }, s = "https://api.workos.com", c = (e) => e.toString("base64url"), l = () => c(n(32)), u = (e) => c(t("sha256").update(e, "ascii").digest()), d = (e) => {
19
+ if (!e.clientId) throw Error("workosBeginLogin: `clientId` is required");
20
+ if (!e.redirectUri) throw Error("workosBeginLogin: `redirectUri` is required");
21
+ let t = e.randomness?.state ?? l(), n = e.randomness?.codeVerifier ?? l(), r = new URLSearchParams({
20
22
  client_id: e.clientId,
21
23
  redirect_uri: e.redirectUri,
22
24
  response_type: "code",
23
- provider: e.provider ?? "authkit"
25
+ provider: e.provider ?? "authkit",
26
+ state: t,
27
+ code_challenge: u(n),
28
+ code_challenge_method: "S256"
24
29
  });
25
- return e.state && t.set("state", e.state), e.loginHint && t.set("login_hint", e.loginHint), `${e.apiBase ?? i}/user_management/authorize?${t.toString()}`;
26
- }, o = class extends Error {
30
+ return e.loginHint && r.set("login_hint", e.loginHint), {
31
+ url: `${e.apiBase ?? s}/user_management/authorize?${r.toString()}`,
32
+ state: t,
33
+ codeVerifier: n
34
+ };
35
+ }, f = class extends Error {
27
36
  status;
28
37
  constructor(e, t) {
29
38
  super(t), this.status = e, this.name = "WorkosExchangeError";
30
39
  }
31
- }, s = async (e) => {
40
+ }, p = class extends f {
41
+ constructor(e) {
42
+ super(400, e), this.name = "WorkosStateMismatchError";
43
+ }
44
+ }, m = async (e) => {
32
45
  if (!e.ok) {
33
46
  let t = await e.text().catch(() => "");
34
- throw new o(e.status, `WorkOS authenticate failed (${e.status}): ${t.slice(0, 200)}`);
47
+ throw new f(e.status, `WorkOS authenticate failed (${e.status}): ${t.slice(0, 200)}`);
35
48
  }
36
49
  let t = await e.json(), n = t.user;
37
- if (!n?.id || !n.email) throw new o(e.status, "WorkOS authenticate response missing user id/email");
50
+ if (!n?.id || !n.email) throw new f(e.status, "WorkOS authenticate response missing user id/email");
38
51
  return {
39
52
  workosUserId: n.id,
40
53
  email: n.email,
@@ -42,21 +55,25 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
42
55
  lastName: n.last_name ?? null,
43
56
  organizationId: t.organization_id ?? null
44
57
  };
45
- }, c = async (e) => {
46
- if (!e.code) throw new o(400, "workosAuthenticateWithCode: missing `code`");
47
- return s(await (e.fetchImpl ?? fetch)(`${e.apiBase ?? i}/user_management/authenticate`, {
58
+ }, h = async (e) => {
59
+ if (!e.code) throw new f(400, "workosAuthenticateWithCode: missing `code`");
60
+ if (!e.expectedState) throw new p("workosAuthenticateWithCode: no `expectedState` — the login was never started by this server, or the state cookie expired. Refusing to exchange the code.");
61
+ if (!e.state || !r(e.state, e.expectedState)) throw new p("workosAuthenticateWithCode: `state` does not match the value minted for this browser (possible CSRF).");
62
+ if (!e.codeVerifier) throw new f(400, "workosAuthenticateWithCode: missing `codeVerifier` (PKCE)");
63
+ return m(await (e.fetchImpl ?? fetch)(`${e.apiBase ?? s}/user_management/authenticate`, {
48
64
  method: "POST",
49
65
  headers: { "content-type": "application/json" },
50
66
  body: JSON.stringify({
51
67
  client_id: e.clientId,
52
68
  client_secret: e.apiKey,
53
69
  grant_type: "authorization_code",
54
- code: e.code
70
+ code: e.code,
71
+ code_verifier: e.codeVerifier
55
72
  })
56
73
  }));
57
- }, l = async (e) => {
58
- if (!e.email) throw new o(400, "workosSendMagicAuthCode: missing `email`");
59
- let t = await (e.fetchImpl ?? fetch)(`${e.apiBase ?? i}/user_management/magic_auth`, {
74
+ }, g = async (e) => {
75
+ if (!e.email) throw new f(400, "workosSendMagicAuthCode: missing `email`");
76
+ let t = await (e.fetchImpl ?? fetch)(`${e.apiBase ?? s}/user_management/magic_auth`, {
60
77
  method: "POST",
61
78
  headers: {
62
79
  "content-type": "application/json",
@@ -66,12 +83,12 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
66
83
  });
67
84
  if (!t.ok) {
68
85
  let e = await t.text().catch(() => "");
69
- throw new o(t.status, `WorkOS magic-auth send failed (${t.status}): ${e.slice(0, 200)}`);
86
+ throw new f(t.status, `WorkOS magic-auth send failed (${t.status}): ${e.slice(0, 200)}`);
70
87
  }
71
- }, u = async (e) => {
72
- if (!e.code) throw new o(400, "workosAuthenticateWithMagicAuth: missing `code`");
73
- if (!e.email) throw new o(400, "workosAuthenticateWithMagicAuth: missing `email`");
74
- return s(await (e.fetchImpl ?? fetch)(`${e.apiBase ?? i}/user_management/authenticate`, {
88
+ }, _ = async (e) => {
89
+ if (!e.code) throw new f(400, "workosAuthenticateWithMagicAuth: missing `code`");
90
+ if (!e.email) throw new f(400, "workosAuthenticateWithMagicAuth: missing `email`");
91
+ return m(await (e.fetchImpl ?? fetch)(`${e.apiBase ?? s}/user_management/authenticate`, {
75
92
  method: "POST",
76
93
  headers: { "content-type": "application/json" },
77
94
  body: JSON.stringify({
@@ -82,8 +99,8 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
82
99
  email: e.email
83
100
  })
84
101
  }));
85
- }, d = "https://api.workos.com", f = async (e, t, n, r) => {
86
- let i = await (e.fetchImpl ?? fetch)(`${e.apiBase ?? d}${n}`, {
102
+ }, v = "https://api.workos.com", y = async (e, t, n, r) => {
103
+ let i = await (e.fetchImpl ?? fetch)(`${e.apiBase ?? v}${n}`, {
87
104
  method: t,
88
105
  headers: {
89
106
  authorization: `Bearer ${e.apiKey}`,
@@ -93,18 +110,18 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
93
110
  });
94
111
  if (!i.ok) {
95
112
  let e = await i.text().catch(() => "");
96
- throw new o(i.status, `WorkOS ${t} ${n} failed (${i.status}): ${e.slice(0, 200)}`);
113
+ throw new f(i.status, `WorkOS ${t} ${n} failed (${i.status}): ${e.slice(0, 200)}`);
97
114
  }
98
115
  if (i.status !== 204) return await i.json();
99
- }, p = (e) => {
100
- if (!e.id) throw new o(502, "WorkOS organization response missing id");
116
+ }, b = (e) => {
117
+ if (!e.id) throw new f(502, "WorkOS organization response missing id");
101
118
  return {
102
119
  id: e.id,
103
120
  name: e.name ?? ""
104
121
  };
105
- }, m = async (e) => {
106
- if (!e.name) throw new o(400, "workosCreateOrganization: missing `name`");
107
- let t = await (e.fetchImpl ?? fetch)(`${e.apiBase ?? d}/organizations`, {
122
+ }, x = async (e) => {
123
+ if (!e.name) throw new f(400, "workosCreateOrganization: missing `name`");
124
+ let t = await (e.fetchImpl ?? fetch)(`${e.apiBase ?? v}/organizations`, {
108
125
  method: "POST",
109
126
  headers: {
110
127
  authorization: `Bearer ${e.apiKey}`,
@@ -115,11 +132,11 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
115
132
  });
116
133
  if (!t.ok) {
117
134
  let e = await t.text().catch(() => "");
118
- throw new o(t.status, `WorkOS create organization failed (${t.status}): ${e.slice(0, 200)}`);
135
+ throw new f(t.status, `WorkOS create organization failed (${t.status}): ${e.slice(0, 200)}`);
119
136
  }
120
- return p(await t.json());
121
- }, h = async (e) => p(await f(e, "GET", `/organizations/${encodeURIComponent(e.organizationId)}`)), g = async (e) => p(await f(e, "PUT", `/organizations/${encodeURIComponent(e.organizationId)}`, { name: e.name })), _ = (e) => {
122
- if (!e.id || !e.user_id || !e.organization_id) throw new o(502, "WorkOS membership response missing id/user_id/organization_id");
137
+ return b(await t.json());
138
+ }, S = async (e) => b(await y(e, "GET", `/organizations/${encodeURIComponent(e.organizationId)}`)), C = async (e) => b(await y(e, "PUT", `/organizations/${encodeURIComponent(e.organizationId)}`, { name: e.name })), w = (e) => {
139
+ if (!e.id || !e.user_id || !e.organization_id) throw new f(502, "WorkOS membership response missing id/user_id/organization_id");
123
140
  return {
124
141
  id: e.id,
125
142
  userId: e.user_id,
@@ -127,11 +144,11 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
127
144
  roleSlug: e.role?.slug ?? null,
128
145
  status: e.status ?? "active"
129
146
  };
130
- }, v = async (e) => _(await f(e, "POST", "/user_management/organization_memberships", {
147
+ }, T = async (e) => w(await y(e, "POST", "/user_management/organization_memberships", {
131
148
  user_id: e.userId,
132
149
  organization_id: e.organizationId,
133
150
  ...e.roleSlug ? { role_slug: e.roleSlug } : {}
134
- })), y = async (e) => {
151
+ })), E = async (e) => {
135
152
  let t = [], n;
136
153
  for (let r = 0; r < 100; r++) {
137
154
  let r = new URLSearchParams({
@@ -139,24 +156,24 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
139
156
  limit: "100"
140
157
  });
141
158
  n && r.set("after", n);
142
- let i = await f(e, "GET", `/user_management/organization_memberships?${r.toString()}`);
143
- for (let e of i.data ?? []) t.push(_(e));
159
+ let i = await y(e, "GET", `/user_management/organization_memberships?${r.toString()}`);
160
+ for (let e of i.data ?? []) t.push(w(e));
144
161
  let a = i.list_metadata?.after;
145
162
  if (!a) break;
146
163
  n = a;
147
164
  }
148
165
  return t;
149
- }, b = async (e) => {
150
- await f(e, "DELETE", `/user_management/organization_memberships/${encodeURIComponent(e.membershipId)}`);
151
- }, x = async (e) => {
152
- if (!e.email) throw new o(400, "workosCreateInvitation: missing `email`");
153
- let t = await f(e, "POST", "/user_management/invitations", {
166
+ }, D = async (e) => {
167
+ await y(e, "DELETE", `/user_management/organization_memberships/${encodeURIComponent(e.membershipId)}`);
168
+ }, O = async (e) => {
169
+ if (!e.email) throw new f(400, "workosCreateInvitation: missing `email`");
170
+ let t = await y(e, "POST", "/user_management/invitations", {
154
171
  email: e.email,
155
172
  organization_id: e.organizationId,
156
173
  ...e.roleSlug ? { role_slug: e.roleSlug } : {},
157
174
  ...e.inviterUserId ? { inviter_user_id: e.inviterUserId } : {}
158
175
  });
159
- if (!t.id || !t.email) throw new o(502, "WorkOS invitation response missing id/email");
176
+ if (!t.id || !t.email) throw new f(502, "WorkOS invitation response missing id/email");
160
177
  return {
161
178
  id: t.id,
162
179
  email: t.email,
@@ -165,4 +182,4 @@ var t = "https://api.workos.com", n = "wos-session", r = (r) => {
165
182
  };
166
183
  };
167
184
  //#endregion
168
- export { o as WorkosExchangeError, c as workosAuthenticateWithCode, u as workosAuthenticateWithMagicAuth, a as workosAuthorizationUrl, x as workosCreateInvitation, v as workosCreateMembership, m as workosCreateOrganization, b as workosDeleteMembership, h as workosGetOrganization, y as workosListUserMemberships, l as workosSendMagicAuthCode, r as workosStrategy, g as workosUpdateOrganization };
185
+ export { f as WorkosExchangeError, p as WorkosStateMismatchError, h as workosAuthenticateWithCode, _ as workosAuthenticateWithMagicAuth, d as workosBeginLogin, O as workosCreateInvitation, T as workosCreateMembership, x as workosCreateOrganization, D as workosDeleteMembership, S as workosGetOrganization, E as workosListUserMemberships, g as workosSendMagicAuthCode, o as workosStrategy, C as workosUpdateOrganization };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-auth-workos",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "WorkOS-backed AuthStrategy for the Voltro framework. Verifies WorkOS AuthKit/SSO JWTs via JWKS and maps `org_id`→tenant. Composes via @voltro/protocol AuthStrategy alongside other IdP plugins.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -33,7 +33,7 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "dependencies": {
36
- "@voltro/protocol": "0.33.0"
36
+ "@voltro/protocol": "0.34.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "effect": "^3.22.0"