Skip to content

Commit acdf861

Browse files
sojanvargheseSojan Varghese
andauthored
Require explicit workspace for consumer scope (#51)
* Updated documentation * Defined SCOPES constant and updated Scope type * Updated NeetoJWT constructor to use SCOPES enum and simplified options * Updated getLoginUri to use SCOPES enum and CONSUMER_AUTH_HOST * Updated tests to use SCOPES enum and assert login URL paths * Made options parameter optional in NeetoJWT constructor * Updated test helper logic to decode tokens and safely restore environment variables * Refactored default export of NeetoJWT class * Revert: Refactored default export of NeetoJWT class --------- Co-authored-by: Sojan Varghese <sojanvarghese@Sojans-MacBook-Air.local>
1 parent c832921 commit acdf861

6 files changed

Lines changed: 105 additions & 73 deletions

File tree

js/README.md

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,17 @@ console.log("Login URL:", loginUrl);
5353
### 3. Logging in a consumer (instead of a workspace user)
5454

5555
NeetoAuth distinguishes between **users** (members of a workspace) and
56-
**consumers** (end-customers of a Neeto product, e.g. a NeetoEngage upvoter).
57-
To mint a JWT for a consumer, pass `scope: "consumer"`. All consumers live
58-
under the `app` workspace.
56+
**consumers** (end-customers of a Neeto product, e.g. a NeetoEngage upvoter). To
57+
mint a JWT for a consumer, pass `scope: "consumer"`. Consumer login URLs are
58+
served from the global `app` host, but the JWT still needs a workspace claim, so
59+
set `workspace` explicitly or via `NEETO_JWT_WORKSPACE`.
5960

6061
```js
6162
import NeetoJWT from "neeto-jwt";
6263

6364
const neetoJWT = new NeetoJWT({
6465
email: "consumer@example.com",
66+
workspace: "spinkart",
6567
privateKey: "<your-private-key>",
6668
scope: "consumer",
6769
});
@@ -72,35 +74,33 @@ const loginUrl = neetoJWT.generateLoginUrl(
7274
// => https://app.neetoauth.com/consumers/auth/jwt?...
7375
```
7476

75-
When `scope: "consumer"` is set and `workspace` is omitted, the client defaults
76-
to `"app"` (the only workspace consumers belong to in NeetoAuth) instead of
77-
reading `NEETO_JWT_WORKSPACE`. This avoids accidentally pointing the consumer
78-
flow at a tenant-specific workspace if you've already configured the env var
79-
for the user-scope flow.
77+
When `scope: "consumer"` is set, the generated login URL still points to
78+
`https://app.neetoauth.com/consumers/auth/jwt`, but the `workspace` claim is
79+
resolved the same way as user scope: from `options.workspace` first, then from
80+
`NEETO_JWT_WORKSPACE`.
8081

8182
#### Identity is asserted, not pre-required
8283

8384
Unlike user-scope JWT (where the email must already be invited to the
8485
workspace), consumer-scope JWT lets NeetoAuth **auto-create** the consumer if
85-
the email is unknown — mirroring the existing self-serve OTP and Google
86-
consumer signup flows. First-time consumers are bounced to a one-time profile
87-
completion screen (name + country + time zone) and then redirected to the
88-
`redirectUri` you passed in. Returning consumers skip the profile step
89-
entirely and land directly on `redirectUri` with an active session.
86+
the email is unknown — mirroring the existing self-serve OTP and Google consumer
87+
signup flows. First-time consumers are bounced to a one-time profile completion
88+
screen (name + country + time zone) and then redirected to the `redirectUri` you
89+
passed in. Returning consumers skip the profile step entirely and land directly
90+
on `redirectUri` with an active session.
9091

9192
#### Redirect URI
9293

9394
For consumer scope, the `redirectUri` is passed through verbatim to NeetoAuth
94-
and used as the post-login destination. The redirect URI does **not** need to
95-
be a Neeto subdomain — any URL the partner controls works.
95+
and used as the post-login destination. The redirect URI does **not** need to be
96+
a Neeto subdomain — any URL the partner controls works.
9697

9798
### Options
9899

99100
- `email` (string, required): The user's email address.
100-
- `workspace` (string, optional): The Neeto workspace. For user scope, defaults
101-
to the `NEETO_JWT_WORKSPACE` environment variable. For consumer scope, defaults
102-
to `"app"` (env var ignored) since all consumers live under that workspace.
103-
Pass an explicit value only if you need to override (e.g. staging tests).
101+
- `workspace` (string, required unless NEETO_JWT_WORKSPACE is set): The Neeto
102+
workspace claim to embed in the JWT. Defaults to the `NEETO_JWT_WORKSPACE`
103+
environment variable for both user and consumer scope.
104104
- `privateKey` (string, optional): The private key used to sign the JWT.
105105
Defaults to the NEETO_JWT_PRIVATE_KEY environment variable.
106106
- `scope` (string, optional): `"user"` (default) or `"consumer"`. Determines
@@ -116,7 +116,8 @@ be a Neeto subdomain — any URL the partner controls works.
116116

117117
### Environment Variables
118118

119-
- `NEETO_JWT_WORKSPACE`: Sets the default workspace.
119+
- `NEETO_JWT_WORKSPACE`: Sets the default workspace claim when `workspace` is
120+
not passed explicitly.
120121
- `NEETO_JWT_PRIVATE_KEY`: Sets the default private key.
121122
- `NEETO_JWT_ENV`: Sets the environment for the top level domain, and protocol.
122123
Can be "development", "staging", or "production". Defaults to production.

js/src/constants.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const TLD: Record<string, string> = {
66

77
export const USER_LOGIN_PATH = "/users/auth/jwt";
88
export const CONSUMER_LOGIN_PATH = "/consumers/auth/jwt";
9-
export const CONSUMER_WORKSPACE = "app";
9+
export const CONSUMER_AUTH_HOST = "app";
1010

1111
export const NEETO_URL_COMPONENT_REGEX = /neeto(\w+)/;
1212
export const NEETO_URL_PREFIX_REGEX = /^(https?:\/\/)?(www\.)?[\w-]+\./;
@@ -37,3 +37,8 @@ export const CLIENT_APPS = {
3737
playdash: "Playdash",
3838
tower: "Tower",
3939
};
40+
41+
export const SCOPES = {
42+
user: "user",
43+
consumer: "consumer",
44+
} as const;

js/src/index.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import jwt from "jsonwebtoken";
2-
import { CONSUMER_WORKSPACE } from "./constants.js";
2+
import { SCOPES } from "./constants.js";
33
import type { Scope } from "./types.js";
44
import {
55
getClientAppName,
@@ -21,21 +21,21 @@ class NeetoJWT {
2121
private privateKey: string;
2222
private scope: Scope;
2323

24-
constructor(options: Options) {
25-
const { email, privateKey = process.env.NEETO_JWT_PRIVATE_KEY } =
26-
options || {};
27-
const scope: Scope = options?.scope ?? "user";
28-
const workspace =
29-
options?.workspace ??
30-
(scope === "consumer"
31-
? CONSUMER_WORKSPACE
32-
: process.env.NEETO_JWT_WORKSPACE);
33-
24+
constructor(
25+
{
26+
email,
27+
scope = SCOPES.user,
28+
workspace = process.env.NEETO_JWT_WORKSPACE,
29+
privateKey = process.env.NEETO_JWT_PRIVATE_KEY,
30+
}: Options = {} as Options
31+
) {
3432
if (!email) throw new Error("Email is required.");
3533
if (!workspace) throw new Error("Workspace is required.");
3634
if (!privateKey) throw new Error("Private key is required.");
37-
if (scope !== "user" && scope !== "consumer") {
38-
throw new Error("Scope must be either 'user' or 'consumer'.");
35+
if (!Object.values(SCOPES).includes(scope)) {
36+
throw new Error(
37+
`Scope must be one of: ${Object.values(SCOPES).join(", ")}`
38+
);
3939
}
4040

4141
this.email = email;
@@ -59,13 +59,11 @@ class NeetoJWT {
5959
try {
6060
const token = jwt.sign(payload, this.privateKey, { algorithm: "ES256" });
6161
return token;
62-
} catch (error) {
62+
} catch {
6363
throw new Error(
6464
`Your key is invalid. We use asymmetric encryption for SSO login.\nPlease fill out https://neeto-jwt.neetodesk.com/forms/jwt-login-in-neeto.\nWe will generate a public-private key pair and share the private key with you. This key will assist you with SSO login.`
6565
);
6666
}
67-
68-
return null;
6967
};
7068

7169
generateLoginUrl = (redirectUri: string) => {
@@ -76,7 +74,7 @@ class NeetoJWT {
7674
// for arbitrary partner domains — pass the URI through verbatim and let
7775
// URLSearchParams handle encoding.
7876
const redirect_uri =
79-
this.scope === "consumer"
77+
this.scope === SCOPES.consumer
8078
? redirectUri
8179
: getRedirectUri(redirectUri);
8280

js/src/types.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export type Scope = "user" | "consumer";
1+
import { SCOPES } from "./constants.js";
2+
3+
type ValueOf<T> = T[keyof T];
4+
5+
export type Scope = ValueOf<typeof SCOPES>;

js/src/utils.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
CLIENT_APPS,
33
CONSUMER_LOGIN_PATH,
4-
CONSUMER_WORKSPACE,
4+
CONSUMER_AUTH_HOST,
5+
SCOPES,
56
NEETO_URL_COMPONENT_REGEX,
67
NEETO_URL_PREFIX_REGEX,
78
TLD,
@@ -18,13 +19,13 @@ export type SearchParams = {
1819
export const getLoginUri = (
1920
workspace: string,
2021
searchParams: SearchParams,
21-
scope: Scope = "user"
22+
scope: Scope = SCOPES.user
2223
) => {
2324
const protocol =
2425
process.env.NEETO_JWT_ENV === "development" ? "http" : "https";
2526
const params = new URLSearchParams(searchParams).toString();
26-
const isConsumer = scope === "consumer";
27-
const host = isConsumer ? CONSUMER_WORKSPACE : workspace;
27+
const isConsumer = scope === SCOPES.consumer;
28+
const host = isConsumer ? CONSUMER_AUTH_HOST : workspace;
2829
const path = isConsumer ? CONSUMER_LOGIN_PATH : USER_LOGIN_PATH;
2930

3031
return `${protocol}://${host}${getTopLevelDomain()}${path}?${params}`;

js/test/index.test.ts

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { describe, it, expect } from "vitest";
22
import jwt from "jsonwebtoken";
33
import NeetoJWT from "../src";
44
import { generateES256KeyPair } from "./utils";
5+
import {
6+
CONSUMER_LOGIN_PATH,
7+
SCOPES,
8+
USER_LOGIN_PATH,
9+
} from "../src/constants.js";
510

611
const { privateKey, publicKey } = generateES256KeyPair();
712

@@ -42,21 +47,22 @@ describe("NeetoJWT", () => {
4247
const decoded = jwt.verify(token, publicKey, { algorithms: ["ES256"] });
4348
expect(decoded.email).toBe(email);
4449
expect(decoded.workspace).toBe(workspace);
45-
expect(decoded.scope).toBe("user");
50+
expect(decoded.scope).toBe(SCOPES.user);
4651
expect(decoded.iat).toBeDefined();
4752
expect(decoded.exp).toBeDefined();
4853
});
4954

5055
it("should embed scope in the JWT payload for consumer scope", () => {
5156
const neetoJWT = new NeetoJWT({
5257
email,
58+
workspace,
5359
privateKey,
54-
scope: "consumer",
60+
scope: SCOPES.consumer,
5561
});
5662
const token = neetoJWT.generateJWT();
5763
const decoded = jwt.verify(token, publicKey, { algorithms: ["ES256"] });
58-
expect(decoded.scope).toBe("consumer");
59-
expect(decoded.workspace).toBe("app");
64+
expect(decoded.scope).toBe(SCOPES.consumer);
65+
expect(decoded.workspace).toBe(workspace);
6066
});
6167

6268
it("should generate a login URL", () => {
@@ -80,36 +86,34 @@ describe("NeetoJWT", () => {
8086
});
8187

8288
it("should use environment variables for workspace and privateKey if not provided", () => {
83-
process.env.NEETO_JWT_WORKSPACE = "spinkart";
89+
process.env.NEETO_JWT_WORKSPACE = workspace;
8490
process.env.NEETO_JWT_PRIVATE_KEY = privateKey;
8591

8692
const neetoJWT = new NeetoJWT({ email });
8793
const token = neetoJWT.generateJWT();
8894
expect(token).toBeDefined();
8995

90-
const decoded = jwt.verify(token, publicKey, {
91-
algorithms: ["ES256"],
92-
});
96+
const decoded = jwt.verify(token, publicKey, { algorithms: ["ES256"] });
9397
expect(decoded.workspace).toBe(process.env.NEETO_JWT_WORKSPACE);
9498
});
9599

96100
it("should default to user scope and produce a /users/auth/jwt URL", () => {
97101
const neetoJWT = new NeetoJWT({ email, workspace, privateKey });
98102
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
99-
expect(loginUrl).toContain("/users/auth/jwt");
100-
expect(loginUrl).not.toContain("/consumers/auth/jwt");
103+
expect(loginUrl).toContain(USER_LOGIN_PATH);
104+
expect(loginUrl).not.toContain(CONSUMER_LOGIN_PATH);
101105
});
102106

103107
it("should produce a /consumers/auth/jwt URL when scope is 'consumer'", () => {
104108
const neetoJWT = new NeetoJWT({
105109
email,
106110
workspace: "app",
107111
privateKey,
108-
scope: "consumer",
112+
scope: SCOPES.consumer,
109113
});
110114
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
111-
expect(loginUrl).toContain("/consumers/auth/jwt");
112-
expect(loginUrl).not.toContain("/users/auth/jwt");
115+
expect(loginUrl).toContain(CONSUMER_LOGIN_PATH);
116+
expect(loginUrl).not.toContain(USER_LOGIN_PATH);
113117
expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt");
114118
});
115119

@@ -118,10 +122,10 @@ describe("NeetoJWT", () => {
118122
email,
119123
workspace,
120124
privateKey,
121-
scope: "user",
125+
scope: SCOPES.user,
122126
});
123127
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
124-
expect(loginUrl).toContain("/users/auth/jwt");
128+
expect(loginUrl).toContain(USER_LOGIN_PATH);
125129
});
126130

127131
it("should throw if scope is anything other than 'user' or 'consumer'", () => {
@@ -134,21 +138,45 @@ describe("NeetoJWT", () => {
134138
// @ts-expect-error: invalid scope passed deliberately to assert runtime guard.
135139
scope: "admin",
136140
})
137-
).toThrow("Scope must be either 'user' or 'consumer'.");
141+
).toThrow(`Scope must be one of: ${Object.values(SCOPES).join(", ")}`);
138142
});
139143

140-
it("should default consumer-scope workspace to 'app' when omitted, ignoring NEETO_JWT_WORKSPACE", () => {
144+
it("should always use the global app auth host for consumer scope, even when workspace comes from NEETO_JWT_WORKSPACE", () => {
141145
const previous = process.env.NEETO_JWT_WORKSPACE;
142146
process.env.NEETO_JWT_WORKSPACE = "tenant1";
143147
try {
144-
const neetoJWT = new NeetoJWT({ email, privateKey, scope: "consumer" });
148+
const neetoJWT = new NeetoJWT({
149+
email,
150+
privateKey,
151+
scope: SCOPES.consumer,
152+
});
145153
const loginUrl = neetoJWT.generateLoginUrl(
146154
"http://partner.example.com/post-login"
147155
);
148-
expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt");
149-
expect(loginUrl).not.toContain("tenant1");
156+
expect(loginUrl).toContain(
157+
"https://app.neetoauth.com/consumers/auth/jwt"
158+
);
159+
160+
const token = new URL(loginUrl).searchParams.get("jwt") as string;
161+
const payload = jwt.decode(token);
162+
expect(payload.workspace).toBe("tenant1");
163+
} finally {
164+
if (previous === undefined) delete process.env.NEETO_JWT_WORKSPACE;
165+
else process.env.NEETO_JWT_WORKSPACE = previous;
166+
}
167+
});
168+
169+
it("should throw when consumer scope is used without workspace or NEETO_JWT_WORKSPACE", () => {
170+
const previous = process.env.NEETO_JWT_WORKSPACE;
171+
delete process.env.NEETO_JWT_WORKSPACE;
172+
173+
try {
174+
expect(
175+
() => new NeetoJWT({ email, privateKey, scope: SCOPES.consumer })
176+
).toThrow("Workspace is required.");
150177
} finally {
151-
process.env.NEETO_JWT_WORKSPACE = previous;
178+
if (previous === undefined) delete process.env.NEETO_JWT_WORKSPACE;
179+
else process.env.NEETO_JWT_WORKSPACE = previous;
152180
}
153181
});
154182

@@ -157,17 +185,12 @@ describe("NeetoJWT", () => {
157185
email,
158186
privateKey,
159187
workspace: "spinkart",
160-
scope: "consumer",
188+
scope: SCOPES.consumer,
161189
});
162190
const loginUrl = neetoJWT.generateLoginUrl("http://partner.example.com/cb");
163-
expect(loginUrl).toContain(
164-
"https://app.neetoauth.com/consumers/auth/jwt"
165-
);
166-
191+
expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt");
167192
const token = new URL(loginUrl).searchParams.get("jwt") as string;
168-
const payload = JSON.parse(
169-
Buffer.from(token.split(".")[1], "base64").toString()
170-
);
193+
const payload = jwt.decode(token);
171194
expect(payload.workspace).toBe("spinkart");
172195
});
173196

@@ -176,7 +199,7 @@ describe("NeetoJWT", () => {
176199
email,
177200
workspace: "app",
178201
privateKey,
179-
scope: "consumer",
202+
scope: SCOPES.consumer,
180203
});
181204
const loginUrl = neetoJWT.generateLoginUrl(
182205
"http://partner.example.com/path with space?q=1"

0 commit comments

Comments
 (0)