@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 +48 -0
- package/LICENSE +21 -0
- package/dist/{chunk-SISIAS4C.js → chunk-ZIYU4K3F.js} +17 -4
- package/dist/config.d.ts +16 -1
- package/dist/config.js +1 -1
- package/dist/index.js +1 -1
- package/dist/nextjs/index.d.ts +27 -0
- package/dist/nextjs/index.js +26 -1
- package/package.json +17 -14
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
|
|
31
|
+
if (domains.length === 0) return !requiresEmailDomainGate();
|
|
22
32
|
if (!email) return false;
|
|
23
|
-
const
|
|
24
|
-
|
|
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-
|
|
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-
|
|
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"];
|
package/dist/nextjs/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/nextjs/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isWorkosConfigured, isAllowedEmail, publicPaths, workosClientId } from '../chunk-
|
|
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.
|
|
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
|
+
}
|