diff --git a/js/README.md b/js/README.md index 285c7bf..3a700e1 100644 --- a/js/README.md +++ b/js/README.md @@ -50,13 +50,63 @@ 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", + privateKey: "", + scope: "consumer", +}); + +const loginUrl = neetoJWT.generateLoginUrl( + "https://your-partner-app.example.com/post-login" +); +// => 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. + +#### 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. +- `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). - `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 diff --git a/js/src/constants.ts b/js/src/constants.ts index 8ec28d3..da78f63 100644 --- a/js/src/constants.ts +++ b/js/src/constants.ts @@ -4,6 +4,9 @@ export const TLD: Record = { 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-]+\./; diff --git a/js/src/index.ts b/js/src/index.ts index 23d9bd5..e311434 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,4 +1,5 @@ import jwt from "jsonwebtoken"; +import type { Scope } from "./types.js"; import { getClientAppName, getLoginUri, @@ -10,27 +11,38 @@ interface Options { email: string; workspace?: string; privateKey?: string; + scope?: Scope; } +const CONSUMER_WORKSPACE = "app"; + 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, - } = 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); 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'."); + } this.email = email; this.workspace = workspace; this.privateKey = privateKey; + this.scope = scope; } generateJWT = () => { @@ -59,13 +71,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 — pass the URI through verbatim and let + // URLSearchParams handle encoding. + const redirect_uri = + this.scope === "consumer" + ? redirectUri + : 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); }; } diff --git a/js/src/types.d.ts b/js/src/types.d.ts new file mode 100644 index 0000000..eea01e7 --- /dev/null +++ b/js/src/types.d.ts @@ -0,0 +1 @@ +export type Scope = "user" | "consumer"; diff --git a/js/src/utils.ts b/js/src/utils.ts index 06340cb..b5993d5 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -1,9 +1,12 @@ import { CLIENT_APPS, + CONSUMER_LOGIN_PATH, NEETO_URL_COMPONENT_REGEX, NEETO_URL_PREFIX_REGEX, TLD, + USER_LOGIN_PATH, } from "./constants.js"; +import type { Scope } from "./types.js"; export type SearchParams = { jwt: string; @@ -11,12 +14,17 @@ export type SearchParams = { 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 = () => { diff --git a/js/test/index.test.ts b/js/test/index.test.ts index 4e791a8..c9af816 100644 --- a/js/test/index.test.ts +++ b/js/test/index.test.ts @@ -79,4 +79,94 @@ 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'."); + }); + + it("should default consumer-scope workspace to 'app' when omitted, ignoring 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 loginUrl = neetoJWT.generateLoginUrl( + "http://partner.example.com/post-login" + ); + expect(loginUrl).toContain("https://app.neetoauth.com/consumers/auth/jwt"); + expect(loginUrl).not.toContain("tenant1"); + } finally { + process.env.NEETO_JWT_WORKSPACE = previous; + } + }); + + it("should honour an explicit consumer-scope workspace override", () => { + const neetoJWT = new NeetoJWT({ + email, + privateKey, + workspace: "staging-app", + scope: "consumer", + }); + const loginUrl = neetoJWT.generateLoginUrl("http://partner.example.com/cb"); + expect(loginUrl).toContain( + "https://staging-app.neetoauth.com/consumers/auth/jwt" + ); + }); + + it("should not double-encode the consumer redirect URI", () => { + const neetoJWT = new NeetoJWT({ + email, + workspace: "app", + privateKey, + scope: "consumer", + }); + const loginUrl = neetoJWT.generateLoginUrl( + "http://partner.example.com/path with space?q=1" + ); + // URLSearchParams encodes a space as `+`, never as `%2520`. + expect(loginUrl).not.toContain("%2520"); + const params = new URL(loginUrl).searchParams; + expect(params.get("redirect_uri")).toBe( + "http://partner.example.com/path with space?q=1" + ); + }); });