diff --git a/js/README.md b/js/README.md index 3a700e1..31e92c7 100644 --- a/js/README.md +++ b/js/README.md @@ -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: "", scope: "consumer", }); @@ -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 @@ -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. diff --git a/js/src/constants.ts b/js/src/constants.ts index 7f669b6..28ecbd1 100644 --- a/js/src/constants.ts +++ b/js/src/constants.ts @@ -6,7 +6,7 @@ export const TLD: Record = { 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-]+\./; @@ -37,3 +37,8 @@ export const CLIENT_APPS = { playdash: "Playdash", tower: "Tower", }; + +export const SCOPES = { + user: "user", + consumer: "consumer", +} as const; diff --git a/js/src/index.ts b/js/src/index.ts index 950d894..5e023aa 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -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, @@ -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; @@ -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) => { @@ -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); diff --git a/js/src/types.d.ts b/js/src/types.d.ts index eea01e7..4cd51db 100644 --- a/js/src/types.d.ts +++ b/js/src/types.d.ts @@ -1 +1,5 @@ -export type Scope = "user" | "consumer"; +import { SCOPES } from "./constants.js"; + +type ValueOf = T[keyof T]; + +export type Scope = ValueOf; diff --git a/js/src/utils.ts b/js/src/utils.ts index 8e640fd..007dcdc 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -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, @@ -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}`; diff --git a/js/test/index.test.ts b/js/test/index.test.ts index 868ac31..a491590 100644 --- a/js/test/index.test.ts +++ b/js/test/index.test.ts @@ -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(); @@ -42,7 +47,7 @@ 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(); }); @@ -50,13 +55,14 @@ describe("NeetoJWT", () => { 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", () => { @@ -80,24 +86,22 @@ 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'", () => { @@ -105,11 +109,11 @@ describe("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"); }); @@ -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'", () => { @@ -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; + 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; + 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; } }); @@ -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"); }); @@ -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"