@coreiam/auth 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,7 +51,11 @@ class CoreIAM {
51
51
  const cookie = incomingHeaders.get('cookie');
52
52
  if (cookie) {
53
53
  headers.set('cookie', cookie);
54
- const jwtMatch = cookie.match(/jwt=([^;]+)/);
54
+ // FIX: anchored to a real cookie-name boundary (start of string, or right
55
+ // after "; "), so this can't match "jwt=" as a substring of some other
56
+ // cookie's name or value. Not currently exploitable (no other cookie in
57
+ // this system contains "jwt="), but cheap to close off.
58
+ const jwtMatch = cookie.match(/(?:^|;\s*)jwt=([^;]+)/);
55
59
  if (jwtMatch) {
56
60
  headers.set('Authorization', `Bearer ${jwtMatch[1]}`);
57
61
  }
@@ -4,7 +4,7 @@ export interface AuthResult {
4
4
  status: number;
5
5
  data: any;
6
6
  }
7
- export declare function getSession(): Promise<CoreIAMIdentity | null>;
7
+ export declare const getSession: () => Promise<CoreIAMIdentity | null>;
8
8
  export declare function loginActionSdk(identifier: string, password: string): Promise<AuthResult>;
9
9
  export declare function registerActionSdk(userData: any): Promise<AuthResult>;
10
10
  export declare function logoutActionSdk(): Promise<AuthResult>;
@@ -1,14 +1,31 @@
1
1
  "use strict";
2
- // src/nextjs/actions.ts
3
2
  'use server';
4
3
  Object.defineProperty(exports, "__esModule", { value: true });
5
- exports.getSession = getSession;
4
+ exports.getSession = void 0;
6
5
  exports.loginActionSdk = loginActionSdk;
7
6
  exports.registerActionSdk = registerActionSdk;
8
7
  exports.logoutActionSdk = logoutActionSdk;
8
+ const react_1 = require("react");
9
9
  const headers_1 = require("next/headers");
10
10
  const core_1 = require("../core");
11
- const iam = new core_1.CoreIAM();
11
+ // FIX: lazy singleton instead of `const iam = new CoreIAM();` at module
12
+ // scope. The constructor throws if COREIAM_API_KEY/COREIAM_API_URL are
13
+ // missing — fine on the server, where those env vars exist — but a
14
+ // module-level throw happens the instant the module is evaluated, before
15
+ // any function in it is even called. If this module's 'use server'
16
+ // boundary is ever not recognized correctly by the bundler (see the note
17
+ // on the browser error below), the real module gets included in the client
18
+ // bundle instead of being swapped for action-reference stubs, and that
19
+ // throw fires in the browser instead, where those env vars are never
20
+ // defined. Making this lazy doesn't fix a lost 'use server' boundary by
21
+ // itself, but it does mean import alone can never crash — only an actual
22
+ // invocation can, which is strictly better either way.
23
+ let _iam = null;
24
+ function getIam() {
25
+ if (!_iam)
26
+ _iam = new core_1.CoreIAM();
27
+ return _iam;
28
+ }
12
29
  async function syncCookiesToNext(response) {
13
30
  const cookieStore = await (0, headers_1.cookies)();
14
31
  const setCookies = response.headers.getSetCookie();
@@ -54,20 +71,40 @@ async function syncCookiesToNext(response) {
54
71
  }
55
72
  }
56
73
  }
57
- async function getSession() {
74
+ // FIX: wrapped in React's cache(). This is the generic fix for "why are we
75
+ // calling /profile so much" — any number of calls to getSession() within the
76
+ // same request/render (direct calls, or nested inside a consumer's own
77
+ // currentUser()-style wrapper) now collapse into a single network round trip
78
+ // to CoreIAM automatically, for every current and future call site.
79
+ //
80
+ // Deliberately NOT adding a refresh-retry fallback here (an earlier draft in
81
+ // this thread did). Next.js only allows cookies().set()/cookies().delete()
82
+ // inside a Server Action or Route Handler — never during a Server Component
83
+ // render — and getSession() is called from both (e.g. from a plain
84
+ // RootLayout render, and from consumers' currentUser()-style wrappers used
85
+ // throughout Server Components). A refresh path that can only sometimes
86
+ // persist its new cookies isn't safe: on the render-context call it would
87
+ // still redeem the refresh token server-side (rotating/burning it) but never
88
+ // deliver the new one to the browser, stranding the client on a now-dead
89
+ // refresh token. Refresh belongs solely in middleware.ts, which is the one
90
+ // place in the request lifecycle actually allowed to mutate response
91
+ // cookies. If middleware already refreshed upstream, this succeeds; if
92
+ // middleware's refresh also failed, returning null here is correct, not a
93
+ // bug to work around.
94
+ exports.getSession = (0, react_1.cache)(async () => {
58
95
  const reqHeaders = await (0, headers_1.headers)();
59
- const response = await iam.proxy('/auth/profile', { method: 'GET' }, reqHeaders);
96
+ const response = await getIam().proxy('/auth/profile', { method: 'GET' }, reqHeaders);
60
97
  if (!response.ok)
61
98
  return null;
62
99
  const coreIamIdentity = await response.json();
63
100
  if (!coreIamIdentity?.sub)
64
101
  return null;
65
102
  return coreIamIdentity;
66
- }
103
+ });
67
104
  async function loginActionSdk(identifier, password) {
68
105
  const reqHeaders = await (0, headers_1.headers)();
69
106
  try {
70
- const response = await iam.loginWithCsrfProtection(identifier, password, reqHeaders);
107
+ const response = await getIam().loginWithCsrfProtection(identifier, password, reqHeaders);
71
108
  await syncCookiesToNext(response);
72
109
  return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
73
110
  }
@@ -78,7 +115,7 @@ async function loginActionSdk(identifier, password) {
78
115
  async function registerActionSdk(userData) {
79
116
  const reqHeaders = await (0, headers_1.headers)();
80
117
  try {
81
- const response = await iam.registerWithCsrfProtection(userData, reqHeaders);
118
+ const response = await getIam().registerWithCsrfProtection(userData, reqHeaders);
82
119
  await syncCookiesToNext(response);
83
120
  return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
84
121
  }
@@ -89,7 +126,7 @@ async function registerActionSdk(userData) {
89
126
  async function logoutActionSdk() {
90
127
  const reqHeaders = await (0, headers_1.headers)();
91
128
  try {
92
- const response = await iam.logout(reqHeaders);
129
+ const response = await getIam().logout(reqHeaders);
93
130
  await syncCookiesToNext(response);
94
131
  return { ok: response.ok, status: response.status, data: await response.json().catch(() => ({})) };
95
132
  }
@@ -4,6 +4,51 @@ exports.coreIamMiddleware = coreIamMiddleware;
4
4
  // src/nextjs/middleware.ts
5
5
  const server_1 = require("next/server");
6
6
  const jwt_decode_1 = require("jwt-decode");
7
+ // Module-level: persists across requests as long as this runs in a single,
8
+ // long-running Node process (true for a VPS deploy). This lets concurrent
9
+ // middleware invocations on THIS process share one in-flight refresh
10
+ // instead of each independently redeeming the same (single-use) refresh
11
+ // token and knocking each other out with 401 Invalid refresh token.
12
+ //
13
+ // This lock is per-process by nature — no amount of cleverness here makes
14
+ // it coordinate across multiple Node processes/instances, since it's just
15
+ // in-memory state. That's fine: it's an optimization (avoids the latency
16
+ // and CoreIAM load of firing off N calls that are mostly doomed to fail
17
+ // anyway), not the thing that makes rotation actually correct under
18
+ // concurrency. That guarantee now lives where all callers ultimately
19
+ // converge regardless of how many Qalam or CoreIAM processes exist: a
20
+ // conditional (WHERE isRevoked: false) update inside a transaction in
21
+ // auth.service.ts's refreshToken(), enforced by Postgres itself. If this
22
+ // lock is ever bypassed or scaled beyond one process, refreshes just get a
23
+ // little less efficient (a few requests eagerly hit CoreIAM instead of
24
+ // waiting on a local promise) — they don't get less correct.
25
+ let refreshInFlight = null;
26
+ async function performRefresh(refreshTokenCookie, jwtCookie, gatewayUrl) {
27
+ try {
28
+ const refreshRes = await fetch(`${gatewayUrl}/auth/refresh-token`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'X-API-Key': process.env.COREIAM_API_KEY,
32
+ 'cookie': `refreshToken=${refreshTokenCookie?.value || ''}; jwt=${jwtCookie?.value || ''}`,
33
+ },
34
+ });
35
+ if (!refreshRes.ok)
36
+ return null;
37
+ const setCookies = refreshRes.headers.getSetCookie();
38
+ let newJwt = '';
39
+ let newRefreshToken = '';
40
+ setCookies.forEach(cookie => {
41
+ if (cookie.startsWith('jwt='))
42
+ newJwt = cookie.split(';')[0].split('=')[1];
43
+ if (cookie.startsWith('refreshToken='))
44
+ newRefreshToken = cookie.split(';')[0].split('=')[1];
45
+ });
46
+ return newJwt ? { jwt: newJwt, refreshToken: newRefreshToken } : null;
47
+ }
48
+ catch (error) {
49
+ return null;
50
+ }
51
+ }
7
52
  /**
8
53
  * Handles CoreIAM token refresh and header rewriting.
9
54
  * Returns the NextResponse to return, and whether the user is authenticated.
@@ -32,44 +77,38 @@ async function coreIamMiddleware(request) {
32
77
  else if (refreshTokenCookie) {
33
78
  needsRefresh = true;
34
79
  }
35
- // 1. ATTEMPT SILENT REFRESH
80
+ // 1. ATTEMPT SILENT REFRESH (single-flight)
36
81
  if (needsRefresh && gatewayUrl) {
37
- try {
38
- const refreshRes = await fetch(`${gatewayUrl}/auth/refresh-token`, {
39
- method: 'POST',
40
- headers: {
41
- 'X-API-Key': process.env.COREIAM_API_KEY,
42
- 'cookie': `refreshToken=${refreshTokenCookie?.value || ''}; jwt=${jwtCookie?.value || ''}`,
43
- },
82
+ if (!refreshInFlight) {
83
+ refreshInFlight = performRefresh(refreshTokenCookie, jwtCookie, gatewayUrl).finally(() => {
84
+ refreshInFlight = null;
44
85
  });
45
- if (refreshRes.ok) {
46
- const setCookies = refreshRes.headers.getSetCookie();
47
- if (setCookies && setCookies.length > 0) {
48
- let newJwt = '';
49
- let newRefreshToken = '';
50
- setCookies.forEach(cookie => {
51
- if (cookie.startsWith('jwt='))
52
- newJwt = cookie.split(';')[0].split('=')[1];
53
- if (cookie.startsWith('refreshToken='))
54
- newRefreshToken = cookie.split(';')[0].split('=')[1];
55
- });
56
- if (newJwt) {
57
- const requestHeaders = new Headers(request.headers);
58
- requestHeaders.set('cookie', `jwt=${newJwt}; refreshToken=${newRefreshToken}`);
59
- requestHeaders.set('Authorization', `Bearer ${newJwt}`);
60
- const response = server_1.NextResponse.next({
61
- request: { headers: requestHeaders },
62
- });
63
- setCookies.forEach(cookie => {
64
- response.headers.append('set-cookie', cookie);
65
- });
66
- return { response, isAuthenticated: true };
67
- }
68
- }
69
- }
70
86
  }
71
- catch (error) {
72
- // Refresh failed, fall through to clear cookies
87
+ const result = await refreshInFlight;
88
+ if (result) {
89
+ const requestHeaders = new Headers(request.headers);
90
+ requestHeaders.set('cookie', `jwt=${result.jwt}; refreshToken=${result.refreshToken}`);
91
+ requestHeaders.set('Authorization', `Bearer ${result.jwt}`);
92
+ const response = server_1.NextResponse.next({
93
+ request: { headers: requestHeaders },
94
+ });
95
+ // FIX: flags now match cookie.util.ts exactly (httpOnly, sameSite:
96
+ // 'strict', secure in production only, correct maxAge per cookie),
97
+ // instead of the placeholder flags used before that file was reviewed.
98
+ const isProd = process.env.NODE_ENV === 'production';
99
+ response.cookies.set('jwt', result.jwt, {
100
+ httpOnly: true,
101
+ secure: isProd,
102
+ sameSite: 'strict',
103
+ maxAge: 24 * 60 * 60, // seconds — matches the 24h maxAge in cookie.util.ts
104
+ });
105
+ response.cookies.set('refreshToken', result.refreshToken, {
106
+ httpOnly: true,
107
+ secure: isProd,
108
+ sameSite: 'strict',
109
+ maxAge: 7 * 24 * 60 * 60, // seconds — matches the 7d maxAge in cookie.util.ts
110
+ });
111
+ return { response, isAuthenticated: true };
73
112
  }
74
113
  // 2. IF REFRESH FAILED, CLEAR COOKIES
75
114
  const response = server_1.NextResponse.next();
@@ -1,10 +1,39 @@
1
1
  import React from 'react';
2
+ import type { CoreIAMIdentity } from '../core';
3
+ export interface CoreIAMRedirects {
4
+ /** Where to send the user after a successful login, if no callbackUrl is passed to login(). */
5
+ afterLogin?: string;
6
+ /** Where to send the user after logout. */
7
+ afterLogout?: string;
8
+ }
9
+ interface LoginResult {
10
+ success?: boolean;
11
+ error?: string;
12
+ }
2
13
  interface AuthContextType {
3
- user: any | null;
14
+ /**
15
+ * Generic CoreIAM identity (sub/email/roles/tenantId) — NOT an app-shaped
16
+ * user. This SDK has no knowledge of any consumer's local user model
17
+ * (roles, profile fields, etc). Consumers that need an enriched user
18
+ * object should build their own layer on top of this, keyed off `sub`.
19
+ */
20
+ identity: CoreIAMIdentity | null;
4
21
  isLoading: boolean;
22
+ isAuthenticated: boolean;
23
+ login: (values: {
24
+ email: string;
25
+ password: string;
26
+ }, callbackUrl?: string) => Promise<LoginResult>;
27
+ logout: () => Promise<void>;
28
+ register: (userData: Record<string, any>) => Promise<LoginResult>;
29
+ refetchIdentity: () => Promise<void>;
5
30
  }
6
- export declare function CoreIAMProvider({ children }: {
31
+ export declare function CoreIAMProvider({ children, initialIdentity, redirects, }: {
7
32
  children: React.ReactNode;
33
+ /** Pass the result of a server-side getSession() call to skip the client fetch on first paint. */
34
+ initialIdentity?: CoreIAMIdentity | null;
35
+ /** Where to redirect after login/logout. Omit for no redirect (caller handles navigation itself). */
36
+ redirects?: CoreIAMRedirects;
8
37
  }): import("react/jsx-runtime").JSX.Element;
9
38
  export declare function useAuth(): AuthContextType;
10
39
  export {};
@@ -1,25 +1,94 @@
1
1
  "use strict";
2
- // src/nextjs/provider.tsx
3
2
  'use client';
4
3
  Object.defineProperty(exports, "__esModule", { value: true });
5
4
  exports.CoreIAMProvider = CoreIAMProvider;
6
5
  exports.useAuth = useAuth;
7
6
  const jsx_runtime_1 = require("react/jsx-runtime");
8
7
  const react_1 = require("react");
8
+ const navigation_1 = require("next/navigation");
9
+ const actions_1 = require("./actions");
9
10
  const AuthContext = (0, react_1.createContext)({
10
- user: null,
11
+ identity: null,
11
12
  isLoading: true,
13
+ isAuthenticated: false,
14
+ login: async () => ({ error: 'CoreIAMProvider is not mounted' }),
15
+ logout: async () => { },
16
+ register: async () => ({ error: 'CoreIAMProvider is not mounted' }),
17
+ refetchIdentity: async () => { },
12
18
  });
13
- function CoreIAMProvider({ children }) {
14
- const [user, setUser] = (0, react_1.useState)(null);
15
- const [isLoading, setIsLoading] = (0, react_1.useState)(true);
19
+ function CoreIAMProvider({ children, initialIdentity = null, redirects = {}, }) {
20
+ const [identity, setIdentity] = (0, react_1.useState)(initialIdentity);
21
+ const [isLoading, setIsLoading] = (0, react_1.useState)(initialIdentity === null);
22
+ const router = (0, navigation_1.useRouter)();
23
+ const refetchIdentity = async () => {
24
+ try {
25
+ const result = await (0, actions_1.getSession)();
26
+ setIdentity(result);
27
+ }
28
+ catch {
29
+ setIdentity(null);
30
+ }
31
+ finally {
32
+ setIsLoading(false);
33
+ }
34
+ };
16
35
  (0, react_1.useEffect)(() => {
17
- fetch('/api/auth/session')
18
- .then(res => res.ok ? res.json() : null)
19
- .then(data => setUser(data))
20
- .finally(() => setIsLoading(false));
36
+ if (initialIdentity === null) {
37
+ refetchIdentity();
38
+ }
39
+ else {
40
+ setIsLoading(false);
41
+ }
42
+ // Intentionally only on mount — initialIdentity is a one-time SSR seed,
43
+ // not something we want to re-run this effect for if it changes identity.
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
21
45
  }, []);
22
- return ((0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: { user, isLoading }, children: children }));
46
+ const login = async (values, callbackUrl) => {
47
+ setIsLoading(true);
48
+ try {
49
+ const result = await (0, actions_1.loginActionSdk)(values.email, values.password);
50
+ if (!result.ok) {
51
+ return { error: result.data?.message || result.data?.error || 'Invalid credentials' };
52
+ }
53
+ await refetchIdentity();
54
+ const target = callbackUrl || redirects.afterLogin;
55
+ if (target)
56
+ router.push(target);
57
+ return { success: true };
58
+ }
59
+ catch {
60
+ return { error: 'Something went wrong' };
61
+ }
62
+ finally {
63
+ setIsLoading(false);
64
+ }
65
+ };
66
+ const logout = async () => {
67
+ try {
68
+ await (0, actions_1.logoutActionSdk)();
69
+ }
70
+ finally {
71
+ setIdentity(null);
72
+ if (redirects.afterLogout)
73
+ router.push(redirects.afterLogout);
74
+ }
75
+ };
76
+ const register = async (userData) => {
77
+ setIsLoading(true);
78
+ try {
79
+ const result = await (0, actions_1.registerActionSdk)(userData);
80
+ if (!result.ok) {
81
+ return { error: result.data?.message || result.data?.error || 'Registration failed' };
82
+ }
83
+ // Registration on the backend doesn't auto-issue a session — log in
84
+ // immediately after, same as the flow this replaces did.
85
+ return await login({ email: userData.email, password: userData.password });
86
+ }
87
+ finally {
88
+ setIsLoading(false);
89
+ }
90
+ };
91
+ return ((0, jsx_runtime_1.jsx)(AuthContext.Provider, { value: { identity, isLoading, isAuthenticated: !!identity, login, logout, register, refetchIdentity }, children: children }));
23
92
  }
24
93
  function useAuth() {
25
94
  return (0, react_1.useContext)(AuthContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreiam/auth",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "main": "./dist/core/index.js",
5
5
  "types": "./dist/core/index.d.ts",
6
6
  "exports": {