@spawn-llc/auth 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ **Security — read this before upgrading an internal tool.**
6
+
7
+ - **The email-domain gate now runs in the headless flows, not only on session read.** Previously
8
+ `signUp` would create a real user in the shared "Spawn" WorkOS directory (and email them) for any
9
+ domain, and `signInWithPassword` would seal a session cookie before anything checked policy. Both
10
+ now deny up front, so no credential is minted and no directory write happens. `verifyEmail`
11
+ re-checks after redeeming a token, and `requestPasswordReset` sends no mail off-domain while still
12
+ reporting `ok` (no account enumeration).
13
+ - **Added `AUTH_REQUIRE_EMAIL_DOMAINS=true` + `assertEmailDomainGate()`.** Every Spawn app shares one
14
+ WorkOS user pool, so an internal console whose `ALLOWED_EMAIL_DOMAINS` went missing would silently
15
+ admit every customer of every customer-facing product. Apps that must never be world-readable now
16
+ declare it: `isAllowedEmail` fails **closed** when a gate is required but unset, and
17
+ `assertEmailDomainGate()` turns the misconfiguration into a failed boot instead of an open door.
18
+ - **`isAllowedEmail` rejects malformed input.** `"@allowed.com"` (empty local part) previously read
19
+ as allowed, because splitting on `@` yielded the allowed domain. A gate must never answer "allowed"
20
+ for something that is not an address.
21
+
22
+ Non-breaking for customer products: with no `ALLOWED_EMAIL_DOMAINS` set, behaviour is unchanged.
23
+
24
+ **Also**
25
+
26
+ - First tests (27). They cover the pending-token thread, unverified sign-in routing, the guarantee
27
+ that no raw WorkOS string reaches a screen, and every domain-gate boundary.
28
+ - CI now runs on push and PR — previously nothing validated a commit until a release tag was cut.
29
+ - `publish.yml` runs the tests and installs with `--frozen-lockfile`.
30
+ - Working git hooks. The `prepare` script pointed at a `.githooks/` directory that did not exist, so
31
+ no hook had ever run.
32
+ - Documented why the OAuth callback gate is enforced on read rather than at the callback: the
33
+ address is only known after the code exchange, and authkit's `onSuccess` cannot veto a login.
34
+
35
+ ## 0.1.1
36
+
37
+ Republished from a clean tree so the artifact is reproducible from source. No code change from what
38
+ 0.1.0 actually shipped.
39
+
40
+ - Untracked the committed `.tgz` and ignored build/debug artifacts.
41
+
42
+ ## 0.1.0 — deprecated
43
+
44
+ Do not use. Published from a dirty working tree, so the tarball contains code that exists in no
45
+ commit and cannot be rebuilt from the repository. Superseded by 0.1.1.
46
+
47
+ Initial release: headless WorkOS over one shared "Spawn" project — `IdentityGateway`, the edge-safe
48
+ policy layer, the Next.js session seam, `authProxy`, `handleCallback`, and the headless flows.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spawn LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -16,12 +16,25 @@ function allowedEmailDomains() {
16
16
  const raw = process.env.ALLOWED_EMAIL_DOMAINS;
17
17
  return (raw ? raw.split(",") : []).map((d) => d.trim().toLowerCase().replace(/^@/, "")).filter(Boolean);
18
18
  }
19
+ function requiresEmailDomainGate() {
20
+ return process.env.AUTH_REQUIRE_EMAIL_DOMAINS === "true";
21
+ }
22
+ function assertEmailDomainGate() {
23
+ if (requiresEmailDomainGate() && allowedEmailDomains().length === 0) {
24
+ throw new Error(
25
+ "AUTH_REQUIRE_EMAIL_DOMAINS=true but ALLOWED_EMAIL_DOMAINS is empty. This app must not run ungated: every Spawn app shares one WorkOS user pool, so an empty gate admits everyone."
26
+ );
27
+ }
28
+ }
19
29
  function isAllowedEmail(email) {
20
30
  const domains = allowedEmailDomains();
21
- if (domains.length === 0) return true;
31
+ if (domains.length === 0) return !requiresEmailDomainGate();
22
32
  if (!email) return false;
23
- const domain = email.trim().toLowerCase().split("@")[1];
24
- return domain ? domains.includes(domain) : false;
33
+ const parts = email.trim().toLowerCase().split("@");
34
+ if (parts.length !== 2) return false;
35
+ const [local, domain] = parts;
36
+ if (!local || !domain) return false;
37
+ return domains.includes(domain);
25
38
  }
26
39
  function workosClientId() {
27
40
  const id = process.env.WORKOS_CLIENT_ID || process.env.NEXT_PUBLIC_WORKOS_CLIENT_ID;
@@ -29,4 +42,4 @@ function workosClientId() {
29
42
  return id;
30
43
  }
31
44
 
32
- export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId };
45
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, assertEmailDomainGate, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, requiresEmailDomainGate, workosClientId };
package/dist/config.d.ts CHANGED
@@ -20,8 +20,23 @@ declare function isWorkosConfigured(): boolean;
20
20
  * `spawnpartners.com`. Returns true (allowed) when no gate is configured.
21
21
  */
22
22
  declare function allowedEmailDomains(): string[];
23
+ /**
24
+ * Internal tools opt in with `AUTH_REQUIRE_EMAIL_DOMAINS=true`, declaring "I am gated".
25
+ *
26
+ * Why this exists: the whole suite shares ONE WorkOS client, so every app's user pool is every
27
+ * other app's user pool. An internal console that relies on the env default would, the moment
28
+ * `ALLOWED_EMAIL_DOMAINS` went missing or got typo'd, silently admit every customer of every
29
+ * customer-facing product — with no error and no visible symptom. Declaring the requirement turns
30
+ * that silent full-access failure into a loud boot failure.
31
+ */
32
+ declare function requiresEmailDomainGate(): boolean;
33
+ /**
34
+ * Throw unless the domain gate is actually configured. Call at boot (module scope) in any app that
35
+ * must never be world-readable, so a misconfiguration fails the deploy instead of opening the door.
36
+ */
37
+ declare function assertEmailDomainGate(): void;
23
38
  declare function isAllowedEmail(email: string | null | undefined): boolean;
24
39
  /** The WorkOS client id, from either the server or the public var. */
25
40
  declare function workosClientId(): string;
26
41
 
27
- export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId };
42
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, assertEmailDomainGate, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, requiresEmailDomainGate, workosClientId };
package/dist/config.js CHANGED
@@ -1 +1 @@
1
- export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId } from './chunk-SISIAS4C.js';
1
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, assertEmailDomainGate, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, requiresEmailDomainGate, workosClientId } from './chunk-ZIYU4K3F.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId } from './chunk-SISIAS4C.js';
1
+ export { DEFAULT_PUBLIC_PATHS, allowedEmailDomains, isAllowedEmail, isPublicPath, isWorkosConfigured, publicPaths, workosClientId } from './chunk-ZIYU4K3F.js';
2
2
 
3
3
  // src/identity/workos-gateway.ts
4
4
  var ROLES = ["owner", "admin", "member"];
@@ -32,6 +32,18 @@ declare function userDisplayName(user: User): string;
32
32
  *
33
33
  * Pass `{ enforce: true }` only if you want middleware-level gating to WorkOS's hosted UI instead
34
34
  * of your own screens (not the Spawn default).
35
+ *
36
+ * ## There is deliberately no "redirect to my own /login" middleware mode
37
+ *
38
+ * It looks like the obvious third option, and it is a trap. The default mode does NOT protect
39
+ * anything — it only refreshes. So an app that has routes reachable *only* because middleware
40
+ * gates them (typically API route handlers with no in-handler check) becomes wide open the moment
41
+ * it adopts this proxy. Middleware gating reads as protection while being the easiest thing to
42
+ * silently lose to a matcher edit.
43
+ *
44
+ * Gate in the route instead: `requireUser()` in authed layouts, and an explicit `requireUser()` at
45
+ * the top of every API handler that returns data. That survives matcher changes, is greppable, and
46
+ * fails closed. Before adopting this proxy, audit for handlers whose only protection is the matcher.
35
47
  */
36
48
  declare function authProxy(options?: {
37
49
  enforce?: boolean;
@@ -45,6 +57,21 @@ declare function authProxy(options?: {
45
57
  *
46
58
  * Exchanges the WorkOS code, seals the Spawn session cookie, and forwards on. This is used for the
47
59
  * social-login round-trip (`startOAuth`) — headless email/password uses `saveSession` directly.
60
+ *
61
+ * ## Where the email-domain gate runs for social login
62
+ *
63
+ * NOT here, deliberately. The password flows in `flows.ts` gate before calling WorkOS, so no
64
+ * credential is ever minted for an off-domain address. Social login cannot do that: the address is
65
+ * only known after the code exchange, and authkit's `onSuccess` hook is typed
66
+ * `(data) => void | Promise<void>` — it can observe the result but cannot redirect or veto it.
67
+ *
68
+ * So for OAuth the gate is enforced on READ, by `currentUser()`/`session()`/`requireUser()` in
69
+ * `session.ts`, which return null for a disallowed domain. An off-domain social login therefore
70
+ * receives a session cookie that grants nothing — every read treats it as signed out and the app
71
+ * bounces to `/login`.
72
+ *
73
+ * The consequence worth knowing: for OAuth, those readers are load-bearing. An app that reaches
74
+ * past them to `withAuth()` directly would see a user the gate intends to reject. Use the seam.
48
75
  */
49
76
  declare function handleCallback(options?: {
50
77
  returnPathname?: string;
@@ -1,4 +1,4 @@
1
- import { isWorkosConfigured, isAllowedEmail, publicPaths, workosClientId } from '../chunk-SISIAS4C.js';
1
+ import { isWorkosConfigured, isAllowedEmail, publicPaths, workosClientId } from '../chunk-ZIYU4K3F.js';
2
2
  import { redirect } from 'next/navigation';
3
3
  import { authkitMiddleware, handleAuth, getWorkOS, saveSession } from '@workos-inc/authkit-nextjs';
4
4
  import { headers, cookies } from 'next/headers';
@@ -94,9 +94,20 @@ function errorCode(err) {
94
94
  const code = err.code;
95
95
  return typeof code === "string" ? code : void 0;
96
96
  }
97
+ function requirementCodes(err) {
98
+ const msg = err?.message;
99
+ if (typeof msg !== "string") return [];
100
+ return msg.split("\n").slice(1).map((line) => line.trim()).filter((line) => /^[a-z0-9_]+$/.test(line));
101
+ }
97
102
  function message(err, fallback) {
98
103
  const code = errorCode(err);
99
104
  if (code && FRIENDLY[code]) return FRIENDLY[code];
105
+ const requirements = requirementCodes(err);
106
+ for (const requirement of requirements) {
107
+ if (FRIENDLY[requirement]) return FRIENDLY[requirement];
108
+ }
109
+ if (requirements.some((r) => r.includes("password"))) return FRIENDLY.password_strength_error;
110
+ if (requirements.some((r) => r.includes("email"))) return "That email doesn't look right.";
100
111
  const errors = err?.errors;
101
112
  if (Array.isArray(errors)) {
102
113
  const field = errors.find(
@@ -113,6 +124,10 @@ function pendingVerificationToken(err) {
113
124
  if (e.code !== "email_verification_required") return void 0;
114
125
  return typeof e.pendingAuthenticationToken === "string" ? e.pendingAuthenticationToken : void 0;
115
126
  }
127
+ function domainRejection(email) {
128
+ if (isAllowedEmail(email)) return null;
129
+ return { status: "error", error: "That email address isn't allowed to sign in here." };
130
+ }
116
131
  var PENDING_COOKIE = "spawn_pending_verification";
117
132
  var PENDING_TTL_SECONDS = 15 * 60;
118
133
  async function rememberPendingToken(token) {
@@ -131,6 +146,8 @@ async function clearPendingToken() {
131
146
  (await cookies()).delete(PENDING_COOKIE);
132
147
  }
133
148
  async function signInWithPassword(input) {
149
+ const rejected = domainRejection(input.email);
150
+ if (rejected) return rejected;
134
151
  try {
135
152
  const res = await getWorkOS().userManagement.authenticateWithPassword({
136
153
  clientId: workosClientId(),
@@ -149,6 +166,8 @@ async function signInWithPassword(input) {
149
166
  }
150
167
  }
151
168
  async function signUp(input) {
169
+ const rejected = domainRejection(input.email);
170
+ if (rejected) return rejected;
152
171
  const workos = getWorkOS();
153
172
  try {
154
173
  const user = await workos.userManagement.createUser({
@@ -193,6 +212,11 @@ async function verifyEmail(input) {
193
212
  code: input.code,
194
213
  pendingAuthenticationToken: pendingToken
195
214
  });
215
+ const rejected = domainRejection(res.user.email);
216
+ if (rejected) {
217
+ await clearPendingToken();
218
+ return rejected;
219
+ }
196
220
  await saveSession(res, await origin());
197
221
  await clearPendingToken();
198
222
  return { status: "ok" };
@@ -209,6 +233,7 @@ async function verifyEmail(input) {
209
233
  }
210
234
  }
211
235
  async function requestPasswordReset(input) {
236
+ if (!isAllowedEmail(input.email)) return { status: "ok" };
212
237
  try {
213
238
  await getWorkOS().userManagement.createPasswordReset({ email: input.email });
214
239
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spawn-llc/auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Spawn shared identity — headless auth for the whole Spawn suite. One WorkOS project (named \"Spawn\") backs every app; this is the only place that touches it. WorkOS is an invisible engine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,18 +29,10 @@
29
29
  "files": [
30
30
  "dist",
31
31
  "README.md",
32
- "IDENTITY.md"
32
+ "IDENTITY.md",
33
+ "CHANGELOG.md",
34
+ "LICENSE"
33
35
  ],
34
- "scripts": {
35
- "prepare": "git config core.hooksPath .githooks || true",
36
- "build": "rm -rf dist && tsup",
37
- "typecheck": "tsc --noEmit",
38
- "prepublishOnly": "pnpm run typecheck && pnpm run build",
39
- "release": "pnpm run release:patch",
40
- "release:patch": "pnpm version patch -m \"v%s\" && git push --follow-tags",
41
- "release:minor": "pnpm version minor -m \"v%s\" && git push --follow-tags",
42
- "release:major": "pnpm version major -m \"v%s\" && git push --follow-tags"
43
- },
44
36
  "dependencies": {
45
37
  "@workos-inc/authkit-nextjs": "^4.2.0",
46
38
  "@workos-inc/node": "^10.8.0"
@@ -57,6 +49,17 @@
57
49
  "@types/react": "^19.0.0",
58
50
  "next": "^16.2.10",
59
51
  "tsup": "^8.5.1",
60
- "typescript": "^5.7.0"
52
+ "typescript": "^5.7.0",
53
+ "vitest": "^4.1.10"
54
+ },
55
+ "scripts": {
56
+ "build": "rm -rf dist && tsup",
57
+ "typecheck": "tsc --noEmit",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "release": "pnpm run release:patch",
61
+ "release:patch": "pnpm version patch -m \"v%s\" && git push --follow-tags",
62
+ "release:minor": "pnpm version minor -m \"v%s\" && git push --follow-tags",
63
+ "release:major": "pnpm version major -m \"v%s\" && git push --follow-tags"
61
64
  }
62
- }
65
+ }