Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,57 @@ const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
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.

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

const neetoJWT = new NeetoJWT({
email: "consumer@example.com",
workspace: "app",
privateKey: "<your-private-key>",
scope: "consumer",
});

const loginUrl = neetoJWT.generateLoginUrl(
"https://your-partner-app.example.com/post-login"
);
// => https://app.neetoauth.com/consumers/auth/jwt?...
```

#### 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.

#### 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.

### Options

- `email` (string, required): The user's email address.
- `workspace` (string, optional): The Neeto workspace. Defaults to the
NEETO_JWT_WORKSPACE environment variable.
NEETO_JWT_WORKSPACE environment variable. For consumer scope, set this to
`"app"`.
- `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
whether the generated URL targets `/users/auth/jwt` or `/consumers/auth/jwt`,
and whether NeetoAuth requires the email to already exist (`user`) or
auto-creates it on first sight (`consumer`).

### Methods

Expand Down
3 changes: 3 additions & 0 deletions js/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export const TLD: Record<string, string> = {
production: ".neetoauth.com",
};

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

export const NEETO_URL_COMPONENT_REGEX = /neeto(\w+)/;
export const NEETO_URL_PREFIX_REGEX = /^(https?:\/\/)?(www\.)?[\w-]+\./;

Expand Down
21 changes: 19 additions & 2 deletions js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import jwt from "jsonwebtoken";
import { Scope } from "./types.js";
Comment thread
VarunSriram99 marked this conversation as resolved.
Outdated
import {
getClientAppName,
getLoginUri,
Expand All @@ -10,27 +11,34 @@ interface Options {
email: string;
workspace?: string;
privateKey?: string;
scope?: Scope;
}

class NeetoJWT {
private email: string;
private workspace: string;
private privateKey: string;
private scope: Scope;

constructor(options: Options) {
const {
email,
workspace = process.env.NEETO_JWT_WORKSPACE,
privateKey = process.env.NEETO_JWT_PRIVATE_KEY,
scope = "user",
} = 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") {
Comment thread
VarunSriram99 marked this conversation as resolved.
throw new Error("Scope must be either 'user' or 'consumer'.");
}

this.email = email;
this.workspace = workspace;
this.privateKey = privateKey;
this.scope = scope;
}

generateJWT = () => {
Expand Down Expand Up @@ -59,13 +67,22 @@ class NeetoJWT {
generateLoginUrl = (redirectUri: string) => {
if (!redirectUri) throw new Error("Redirect URI is required");

// User scope assumes the redirectUri points at a Neeto sub-app, so the
// shared NeetoAuth flow strips the leading subdomain. Consumer scope is
// for arbitrary partner domains — we pass the URI through verbatim so
// NeetoAuth can redirect back to the partner correctly.
const redirect_uri =
this.scope === "consumer"
? encodeURI(redirectUri)
Comment thread
VarunSriram99 marked this conversation as resolved.
Outdated
: getRedirectUri(redirectUri);

const searchParams: SearchParams = {
jwt: this.generateJWT(),
redirect_uri: getRedirectUri(redirectUri),
redirect_uri,
client_app_name: getClientAppName(redirectUri),
};

return getLoginUri(this.workspace, searchParams);
return getLoginUri(this.workspace, searchParams, this.scope);
};
}

Expand Down
1 change: 1 addition & 0 deletions js/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Scope = "user" | "consumer";
12 changes: 10 additions & 2 deletions js/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import {
CLIENT_APPS,
CONSUMER_LOGIN_PATH,
NEETO_URL_COMPONENT_REGEX,
NEETO_URL_PREFIX_REGEX,
TLD,
USER_LOGIN_PATH,
} from "./constants.js";
import { Scope } from "./types.js";

export type SearchParams = {
jwt: string;
redirect_uri: string;
client_app_name: string;
};

export const getLoginUri = (workspace: string, searchParams: SearchParams) => {
export const getLoginUri = (
workspace: string,
searchParams: SearchParams,
scope: Scope = "user"
) => {
const protocol =
process.env.NEETO_JWT_ENV === "development" ? "http" : "https";
const params = new URLSearchParams(searchParams).toString();
const path = scope === "consumer" ? CONSUMER_LOGIN_PATH : USER_LOGIN_PATH;

return `${protocol}://${workspace}${getTopLevelDomain()}/users/auth/jwt?${params}`;
return `${protocol}://${workspace}${getTopLevelDomain()}${path}?${params}`;
};

export const getTopLevelDomain = () => {
Expand Down
44 changes: 44 additions & 0 deletions js/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,48 @@ describe("NeetoJWT", () => {
});
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");
});

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

it("should explicitly accept 'user' scope and produce the user URL", () => {
const neetoJWT = new NeetoJWT({
email,
workspace,
privateKey,
scope: "user",
});
const loginUrl = neetoJWT.generateLoginUrl(redirectUri);
expect(loginUrl).toContain("/users/auth/jwt");
});

it("should throw if scope is anything other than 'user' or 'consumer'", () => {
expect(
() =>
new NeetoJWT({
email,
workspace,
privateKey,
// @ts-expect-error: invalid scope passed deliberately to assert runtime guard.
scope: "admin",
})
).toThrow("Scope must be either 'user' or 'consumer'.");
});
});