Skip to content
Merged
41 changes: 21 additions & 20 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,17 @@ console.log("Login URL:", loginUrl);
### 3. Logging in a consumer (instead of a workspace user)

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

```js
import NeetoJWT from "neeto-jwt";

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

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

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

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

#### Redirect URI

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

### Options

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

### Environment Variables

- `NEETO_JWT_WORKSPACE`: Sets the default workspace.
- `NEETO_JWT_WORKSPACE`: Sets the default workspace claim when `workspace` is
not passed explicitly.
- `NEETO_JWT_PRIVATE_KEY`: Sets the default private key.
- `NEETO_JWT_ENV`: Sets the environment for the top level domain, and protocol.
Can be "development", "staging", or "production". Defaults to production.
Expand Down
7 changes: 6 additions & 1 deletion js/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const TLD: Record<string, string> = {

export const USER_LOGIN_PATH = "/users/auth/jwt";
export const CONSUMER_LOGIN_PATH = "/consumers/auth/jwt";
export const CONSUMER_WORKSPACE = "app";
export const CONSUMER_AUTH_HOST = "app";

export const NEETO_URL_COMPONENT_REGEX = /neeto(\w+)/;
export const NEETO_URL_PREFIX_REGEX = /^(https?:\/\/)?(www\.)?[\w-]+\./;
Expand Down Expand Up @@ -37,3 +37,8 @@ export const CLIENT_APPS = {
playdash: "Playdash",
tower: "Tower",
};

export const SCOPES = {
user: "user",
consumer: "consumer",
} as const;
32 changes: 15 additions & 17 deletions js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import jwt from "jsonwebtoken";
import { CONSUMER_WORKSPACE } from "./constants.js";
import { SCOPES } from "./constants.js";
import type { Scope } from "./types.js";
import {
getClientAppName,
Expand All @@ -21,21 +21,21 @@ class NeetoJWT {
private privateKey: string;
private scope: Scope;

constructor(options: Options) {
const { email, privateKey = process.env.NEETO_JWT_PRIVATE_KEY } =
options || {};
const scope: Scope = options?.scope ?? "user";
const workspace =
options?.workspace ??
(scope === "consumer"
? CONSUMER_WORKSPACE
: process.env.NEETO_JWT_WORKSPACE);

constructor(
{
email,
scope = SCOPES.user,
workspace = process.env.NEETO_JWT_WORKSPACE,
privateKey = process.env.NEETO_JWT_PRIVATE_KEY,
}: Options = {} as Options
) {
if (!email) throw new Error("Email is required.");
if (!workspace) throw new Error("Workspace is required.");
if (!privateKey) throw new Error("Private key is required.");
if (scope !== "user" && scope !== "consumer") {
throw new Error("Scope must be either 'user' or 'consumer'.");
if (!Object.values(SCOPES).includes(scope)) {
throw new Error(
`Scope must be one of: ${Object.values(SCOPES).join(", ")}`
);
}

this.email = email;
Expand All @@ -59,13 +59,11 @@ class NeetoJWT {
try {
const token = jwt.sign(payload, this.privateKey, { algorithm: "ES256" });
return token;
} catch (error) {
} catch {
throw new Error(
`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.`
);
}

return null;
};

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

Expand Down
6 changes: 5 additions & 1 deletion js/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export type Scope = "user" | "consumer";
import { SCOPES } from "./constants.js";

type ValueOf<T> = T[keyof T];

export type Scope = ValueOf<typeof SCOPES>;
9 changes: 5 additions & 4 deletions js/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
CLIENT_APPS,
CONSUMER_LOGIN_PATH,
CONSUMER_WORKSPACE,
CONSUMER_AUTH_HOST,
SCOPES,
NEETO_URL_COMPONENT_REGEX,
NEETO_URL_PREFIX_REGEX,
TLD,
Expand All @@ -18,13 +19,13 @@ export type SearchParams = {
export const getLoginUri = (
workspace: string,
searchParams: SearchParams,
scope: Scope = "user"
scope: Scope = SCOPES.user
) => {
const protocol =
process.env.NEETO_JWT_ENV === "development" ? "http" : "https";
const params = new URLSearchParams(searchParams).toString();
const isConsumer = scope === "consumer";
const host = isConsumer ? CONSUMER_WORKSPACE : workspace;
const isConsumer = scope === SCOPES.consumer;
const host = isConsumer ? CONSUMER_AUTH_HOST : workspace;
const path = isConsumer ? CONSUMER_LOGIN_PATH : USER_LOGIN_PATH;

return `${protocol}://${host}${getTopLevelDomain()}${path}?${params}`;
Expand Down
83 changes: 53 additions & 30 deletions js/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { describe, it, expect } from "vitest";
import jwt from "jsonwebtoken";
import NeetoJWT from "../src";
import { generateES256KeyPair } from "./utils";
import {
CONSUMER_LOGIN_PATH,
SCOPES,
USER_LOGIN_PATH,
} from "../src/constants.js";

const { privateKey, publicKey } = generateES256KeyPair();

Expand Down Expand Up @@ -42,21 +47,22 @@ describe("NeetoJWT", () => {
const decoded = jwt.verify(token, publicKey, { algorithms: ["ES256"] });
expect(decoded.email).toBe(email);
expect(decoded.workspace).toBe(workspace);
expect(decoded.scope).toBe("user");
expect(decoded.scope).toBe(SCOPES.user);
expect(decoded.iat).toBeDefined();
expect(decoded.exp).toBeDefined();
});

it("should embed scope in the JWT payload for consumer scope", () => {
const neetoJWT = new NeetoJWT({
email,
workspace,
privateKey,
scope: "consumer",
scope: SCOPES.consumer,
});
const token = neetoJWT.generateJWT();
const decoded = jwt.verify(token, publicKey, { algorithms: ["ES256"] });
expect(decoded.scope).toBe("consumer");
expect(decoded.workspace).toBe("app");
expect(decoded.scope).toBe(SCOPES.consumer);
expect(decoded.workspace).toBe(workspace);
});

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

it("should use environment variables for workspace and privateKey if not provided", () => {
process.env.NEETO_JWT_WORKSPACE = "spinkart";
process.env.NEETO_JWT_WORKSPACE = workspace;
process.env.NEETO_JWT_PRIVATE_KEY = privateKey;

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

const decoded = jwt.verify(token, publicKey, {
algorithms: ["ES256"],
});
const decoded = jwt.verify(token, publicKey, { algorithms: ["ES256"] });
expect(decoded.workspace).toBe(process.env.NEETO_JWT_WORKSPACE);
});

it("should default to user scope and produce a /users/auth/jwt URL", () => {
const neetoJWT = new NeetoJWT({ email, workspace, privateKey });
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
expect(loginUrl).toContain("/users/auth/jwt");
expect(loginUrl).not.toContain("/consumers/auth/jwt");
expect(loginUrl).toContain(USER_LOGIN_PATH);
expect(loginUrl).not.toContain(CONSUMER_LOGIN_PATH);
});

it("should produce a /consumers/auth/jwt URL when scope is 'consumer'", () => {
const neetoJWT = new NeetoJWT({
email,
workspace: "app",
privateKey,
scope: "consumer",
scope: SCOPES.consumer,
});
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
expect(loginUrl).toContain("/consumers/auth/jwt");
expect(loginUrl).not.toContain("/users/auth/jwt");
expect(loginUrl).toContain(CONSUMER_LOGIN_PATH);
expect(loginUrl).not.toContain(USER_LOGIN_PATH);
expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt");
});

Expand All @@ -118,10 +122,10 @@ describe("NeetoJWT", () => {
email,
workspace,
privateKey,
scope: "user",
scope: SCOPES.user,
});
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
expect(loginUrl).toContain("/users/auth/jwt");
expect(loginUrl).toContain(USER_LOGIN_PATH);
});

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

it("should default consumer-scope workspace to 'app' when omitted, ignoring NEETO_JWT_WORKSPACE", () => {
it("should always use the global app auth host for consumer scope, even when workspace comes from NEETO_JWT_WORKSPACE", () => {
const previous = process.env.NEETO_JWT_WORKSPACE;
process.env.NEETO_JWT_WORKSPACE = "tenant1";
try {
const neetoJWT = new NeetoJWT({ email, privateKey, scope: "consumer" });
const neetoJWT = new NeetoJWT({
email,
privateKey,
scope: SCOPES.consumer,
});
const loginUrl = neetoJWT.generateLoginUrl(
"http://partner.example.com/post-login"
);
expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt");
expect(loginUrl).not.toContain("tenant1");
expect(loginUrl).toContain(
"https://app.neetoauth.com/consumers/auth/jwt"
);

const token = new URL(loginUrl).searchParams.get("jwt") as string;
Comment thread
sojanvarghese marked this conversation as resolved.
const payload = jwt.decode(token);
expect(payload.workspace).toBe("tenant1");
} finally {
if (previous === undefined) delete process.env.NEETO_JWT_WORKSPACE;
else process.env.NEETO_JWT_WORKSPACE = previous;
}
});

it("should throw when consumer scope is used without workspace or NEETO_JWT_WORKSPACE", () => {
const previous = process.env.NEETO_JWT_WORKSPACE;
Comment thread
sojanvarghese marked this conversation as resolved.
delete process.env.NEETO_JWT_WORKSPACE;

try {
expect(
() => new NeetoJWT({ email, privateKey, scope: SCOPES.consumer })
).toThrow("Workspace is required.");
} finally {
process.env.NEETO_JWT_WORKSPACE = previous;
if (previous === undefined) delete process.env.NEETO_JWT_WORKSPACE;
else process.env.NEETO_JWT_WORKSPACE = previous;
}
});

Expand All @@ -157,17 +185,12 @@ describe("NeetoJWT", () => {
email,
privateKey,
workspace: "spinkart",
scope: "consumer",
scope: SCOPES.consumer,
});
const loginUrl = neetoJWT.generateLoginUrl("http://partner.example.com/cb");
expect(loginUrl).toContain(
"https://app.neetoauth.com/consumers/auth/jwt"
);

expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt");
const token = new URL(loginUrl).searchParams.get("jwt") as string;
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64").toString()
);
const payload = jwt.decode(token);
expect(payload.workspace).toBe("spinkart");
});

Expand All @@ -176,7 +199,7 @@ describe("NeetoJWT", () => {
email,
workspace: "app",
privateKey,
scope: "consumer",
scope: SCOPES.consumer,
});
const loginUrl = neetoJWT.generateLoginUrl(
"http://partner.example.com/path with space?q=1"
Expand Down