@henosia/app-next 1.0.2

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.
@@ -0,0 +1,152 @@
1
+ /*! Copyright (c) 2026 Henosia ApS. Licensed under the Henosia Commercial Source License v1.0. See LICENSE */
2
+ import { HENOSIA_AUTH_SIGN_IN_PATH_NAME, HENOSIA_ORGANIZATION_CTX_COOKIE } from "../shared.mjs";
3
+ import { HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, cookiePrefix, henosiaAuthConfig, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken } from "./server.mjs";
4
+ import { getSessionCookie } from "better-auth/cookies";
5
+ import { NextResponse } from "next/server.js";
6
+ //#region src/auth/utils.ts
7
+ function delayWithCancel(delayMs) {
8
+ let timer;
9
+ let reject = null;
10
+ const promise = new Promise((resolve, _reject) => {
11
+ reject = _reject;
12
+ timer = setTimeout(() => resolve(), delayMs);
13
+ });
14
+ const cancel = () => {
15
+ clearTimeout(timer);
16
+ queueMicrotask(() => {
17
+ reject();
18
+ reject = null;
19
+ });
20
+ };
21
+ return {
22
+ promise,
23
+ cancel
24
+ };
25
+ }
26
+ //#endregion
27
+ //#region src/auth/middleware.ts
28
+ /**
29
+ * Creates a Next.js middleware function that performs the Henosia Auth pre-check.
30
+ *
31
+ * The returned function:
32
+ * - Returns a redirect to the sign-in page when no valid session cookie is present.
33
+ * - Verifies the Henosia Auth JWT, transferring any refreshed cookies onto the response.
34
+ * - Optionally exchanges the Henosia Auth token for a Supabase session if `NEXT_PUBLIC_SUPABASE_URL` is set.
35
+ * - Returns the resulting `NextResponse` (a `NextResponse.next({ request })` when authenticated, a redirect or
36
+ * 401 JSON response otherwise) so the caller can compose additional logic on top.
37
+ *
38
+ * **Important**: this middleware performs an auth *pre-check*. Authorization MUST still be performed at the
39
+ * relevant pages and route handlers using {@link verifyHenosiaAuthToken}.
40
+ *
41
+ * @example Composing with custom logic
42
+ * ```ts
43
+ * import { type NextRequest, NextResponse } from 'next/server'
44
+ * import { createHenosiaAuthMiddleware } from '@henosia/app-next/middleware'
45
+ *
46
+ * const henosiaAuth = createHenosiaAuthMiddleware()
47
+ *
48
+ * export async function middleware(request: NextRequest) {
49
+ * // Add app-specific logic here ...
50
+ * return await henosiaAuth(request)
51
+ * }
52
+ *
53
+ * ```
54
+ */
55
+ function createHenosiaAuthMiddleware(options = {}) {
56
+ const unauthenticatedPathNames = new Set([...HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, ...options.additionalUnauthenticatedPathNames ?? []]);
57
+ const unauthenticatedPathNamePrefixes = [...HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, ...options.additionalUnauthenticatedPathNamePrefixes ?? []];
58
+ if (unauthenticatedPathNamePrefixes.find((p) => p === "/" || !p.trim() || !p.endsWith("/"))) throw new Error("[Henosia Auth] Invalid path name prefixes in additionalUnauthenticatedPathNamePrefixes. Values must be non-empty sub paths ending with slash");
59
+ if (unauthenticatedPathNames.has("/")) throw new Error(`[Henosia Auth] Invalid path name '/' in additionalUnauthenticatedPathNames. Values must be non-root paths`);
60
+ return async function henosiaAuthMiddleware(request) {
61
+ const response = NextResponse.next({ request });
62
+ const { pathname } = request.nextUrl;
63
+ if (pathname === "/sign-in") removeCachedAccountData(response);
64
+ if (pathname === "/api/auth/get-session") {
65
+ try {
66
+ const { transferBetterAuthCookiesToNext } = await verifyHenosiaAuthToken(request);
67
+ transferBetterAuthCookiesToNext(request, response);
68
+ } catch (e) {
69
+ if (!isUnauthorizedException(e)) {
70
+ console.error("[Middleware]", e);
71
+ throw e;
72
+ }
73
+ }
74
+ return response;
75
+ }
76
+ if (unauthenticatedPathNames.has(pathname) || !!unauthenticatedPathNamePrefixes.find((p) => pathname.startsWith(p))) return response;
77
+ if (!getSessionCookie(request, { cookiePrefix })) return NextResponse.redirect(new URL(HENOSIA_AUTH_SIGN_IN_PATH_NAME, request.url));
78
+ try {
79
+ const { accessTokenResponse, payload, transferBetterAuthCookiesToNext } = await verifyHenosiaAuthToken(request);
80
+ transferBetterAuthCookiesToNext(request, response);
81
+ const cachedOrg = JSON.stringify(request.cookies.get("henosia.org")?.value ?? null);
82
+ const org = JSON.stringify(payload["https://henosia.com/organization"]);
83
+ if (cachedOrg !== org) response.cookies.set(HENOSIA_ORGANIZATION_CTX_COOKIE, org);
84
+ if (process.env.NEXT_PUBLIC_SUPABASE_URL) await exchangeHenosiaTokenForSupabaseSession(request, response, accessTokenResponse.idToken);
85
+ } catch (e) {
86
+ if (isUnauthorizedException(e)) {
87
+ if (!isPageRequest(request)) return NextResponse.json({ error: "Your session is invalid or expired. Please reload the page to sign in again." }, { status: e.statusCode ?? 401 });
88
+ const redirectResponse = NextResponse.redirect(new URL(HENOSIA_AUTH_SIGN_IN_PATH_NAME, request.url));
89
+ removeCachedAccountData(redirectResponse);
90
+ return redirectResponse;
91
+ } else {
92
+ console.error("[Middleware]", e);
93
+ throw e;
94
+ }
95
+ }
96
+ return response;
97
+ };
98
+ }
99
+ /**
100
+ * Lazy-load `@supabase/ssr` so apps that don't use Supabase don't have to install it.
101
+ */
102
+ async function exchangeHenosiaTokenForSupabaseSession(request, response, idToken) {
103
+ let createServerClient;
104
+ try {
105
+ ({createServerClient} = await import("@supabase/ssr"));
106
+ } catch (e) {
107
+ console.error("[Henosia Auth] NEXT_PUBLIC_SUPABASE_URL is set but `@supabase/ssr` is not installed. Install it as a peer dependency to enable Supabase session exchange.", e);
108
+ return;
109
+ }
110
+ const supabase = createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, { cookies: {
111
+ getAll() {
112
+ return request.cookies.getAll();
113
+ },
114
+ setAll(cookiesToSet) {
115
+ cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
116
+ try {
117
+ cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
118
+ } catch {}
119
+ }
120
+ } });
121
+ const { promise: supabaseTimeout, cancel } = delayWithCancel(5e3);
122
+ await Promise.race([supabaseTimeout.then(() => {
123
+ console.error(`[Supabase] Timed out getting a valid Supabase session: ${process.env.NEXT_PUBLIC_SUPABASE_URL} may be paused or down.`);
124
+ }), (async () => {
125
+ try {
126
+ const { data } = await supabase.auth.getSession();
127
+ if (!data.session) await supabase.auth.signInWithIdToken({
128
+ provider: henosiaAuthConfig.henosiaAuthSupabaseProvider.get(),
129
+ token: idToken
130
+ });
131
+ } catch (error) {
132
+ console.error("[Supabase] Unable to get valid Supabase session", error);
133
+ } finally {
134
+ cancel();
135
+ }
136
+ })()]);
137
+ }
138
+ /**
139
+ * Removes potentially invalid cached account data from cookies before the next better-auth sign in attempt
140
+ */
141
+ function removeCachedAccountData(response) {
142
+ const secure = henosiaAuthConfig.baseURL.get().startsWith("https");
143
+ const name = `${secure ? "__Secure-" : ""}${cookiePrefix}.account_data`;
144
+ response.cookies.delete({
145
+ name,
146
+ secure,
147
+ httpOnly: true,
148
+ domain: new URL(henosiaAuthConfig.baseURL.get()).hostname
149
+ });
150
+ }
151
+ //#endregion
152
+ export { createHenosiaAuthMiddleware };
@@ -0,0 +1,108 @@
1
+
2
+ import { HenosiaOrganizationContext } from "../shared.mjs";
3
+ import { Auth } from "better-auth";
4
+ import { NextRequest, NextResponse } from "next/server.js";
5
+
6
+ //#region src/auth/server.d.ts
7
+ /**
8
+ * URL pathname values that should not check for auth in middleware, e.g. the sign-in page.
9
+ * Additional pathname values can be added for public paths, but ONLY if they should not be intercepted by the
10
+ * middleware auth pre-check and redirect.
11
+ */
12
+ declare const HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES: Set<string>;
13
+ /**
14
+ * URL pathname prefixes that should not be blocked by auth in middleware, e.g. the better-auth API routes used to sign in.
15
+ * Additional pathname prefixes can be added for public paths, but ONLY if they should not be intercepted by the
16
+ * middleware auth pre-check and redirect. Include the relevant `/` ending character to prevent unexpected partial
17
+ * route matches.
18
+ */
19
+ declare const HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES: string[];
20
+ type EnvLiveGetString = {
21
+ get(): string;
22
+ };
23
+ interface HenosiaAuthConfig {
24
+ /** Name of the Henosia Auth OAuth2 provider */
25
+ provider: 'henosia';
26
+ /** The consuming app's own deployment or preview browser URL. Used as the better-auth base url */
27
+ baseURL: EnvLiveGetString;
28
+ /** The consuming app's own deployment or preview auth secret. Used as the better-auth secret */
29
+ secret: EnvLiveGetString;
30
+ /** OAuth client ID from HenosiaAuthClientService */
31
+ clientId: EnvLiveGetString;
32
+ /** OAuth client secret from HenosiaAuthClientService */
33
+ clientSecret: EnvLiveGetString;
34
+ /** The Henosia auth and platform service base URL, under which the OAuth client is registered */
35
+ henosiaAuthPlatformServiceBaseUrl: EnvLiveGetString;
36
+ /** The provider identifier that Henosia Auth is registered with as a OIDC-based Custom Provider in Supabase Auth */
37
+ henosiaAuthSupabaseProvider: EnvLiveGetString;
38
+ /**
39
+ * The project id that the JWT project claim should match in HenosiaAuthTokenClaims.
40
+ * Ensures that signed-in Henosia users cannot access projects that they have not been granted access to,
41
+ * for example that User A from Org A cannot access User B from Org B's project.
42
+ * */
43
+ projectId: EnvLiveGetString;
44
+ }
45
+ /**
46
+ * Environment-provided configuration for Henosia Auth.
47
+ * When the consuming app runs in the Henosia preview, the env vars are provided by the runtime environment and should not be
48
+ * hard-coded or present in the `.env.local` file. The Henosia Publish feature automatically sets production versions of
49
+ * the values on the deployed app. For self-publish flows or local development, see the `Environment settings`
50
+ * in the Henosia builder navbar for the relevant values. The preview values cannot be used for production deployments.
51
+ */
52
+ declare const henosiaAuthConfig: HenosiaAuthConfig;
53
+ interface HenosiaAuthTokenClaims {
54
+ /** The project id that the claim is associated with. A `null` value indicates no project access was granted. */
55
+ 'https://henosia.com/project': string | null;
56
+ /** Whether the claim is associated with Henosia's preview browser in the builder */
57
+ 'https://henosia.com/preview': boolean;
58
+ /** The organization that the app belongs to */
59
+ 'https://henosia.com/organization': HenosiaOrganizationContext;
60
+ }
61
+ /**
62
+ * Gets whether the specified request is for a page (a top level page or iframe page)
63
+ */
64
+ declare function isPageRequest(request: Request): boolean;
65
+ /**
66
+ * Gets whether the specified request is allowed to spend the single-use OIDC refresh token to obtain a new
67
+ * access token.
68
+ *
69
+ */
70
+ declare function isPageOrRefreshTokenRequest(request: Request): boolean;
71
+ declare const cookiePrefix: `henosia-auth-${string}`;
72
+ /**
73
+ * The Henosia Auth instance.
74
+ *
75
+ * Typed as the minimal {@link Auth} type from better-auth (which is parameterised on the default
76
+ * {@link BetterAuthOptions}) rather than the deeply-inferred concrete type returned by {@link betterAuth}.
77
+ * The deeply-inferred graph references non-portable internals from `better-auth` and `zod` that cannot be
78
+ * named in published declarations (TS2883).
79
+ */
80
+ declare const auth: Auth;
81
+ /**
82
+ * Verifies the Henosia Auth JWT on the current request, or throws in case it's missing or invalid.
83
+ * This method must be called before allowing access to any protected pages or routes,
84
+ * and before accessing data in server side page methods or actions.
85
+ * @param request the current request which provides auth headers
86
+ * @return the better-auth `getAccessToken` response, the verified token `payload` (a {@link HenosiaAuthTokenClaims}),
87
+ * the `Set-Cookie` headers produced while obtaining/refreshing the token, and a
88
+ * `transferBetterAuthCookiesToNext` helper that forwards those cookies onto a Next.js request/response pair
89
+ * @throws APIError when the current credentials are missing or invalid
90
+ */
91
+ declare function verifyHenosiaAuthToken(request: Request): Promise<{
92
+ accessTokenResponse: {
93
+ accessToken: string;
94
+ accessTokenExpiresAt: Date | undefined;
95
+ scopes: string[];
96
+ idToken: string | undefined;
97
+ };
98
+ payload: HenosiaAuthTokenClaims;
99
+ setCookieHeaders: string[];
100
+ transferBetterAuthCookiesToNext: (nextRequest: NextRequest, response: NextResponse) => void;
101
+ }>;
102
+ /**
103
+ * Determines whether the specified exception should start the sign-in flow to authenticate the user
104
+ * @param error the error thrown by `verifyHenosiaAuthToken`
105
+ */
106
+ declare function isUnauthorizedException(error: unknown): boolean;
107
+ //#endregion
108
+ export { HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, HenosiaAuthConfig, HenosiaAuthTokenClaims, auth, cookiePrefix, henosiaAuthConfig, isPageOrRefreshTokenRequest, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken };
@@ -0,0 +1,214 @@
1
+ /*! Copyright (c) 2026 Henosia ApS. Licensed under the Henosia Commercial Source License v1.0. See LICENSE */
2
+ import { HENOSIA_AUTH_GET_SESSION_PATH_NAME, HENOSIA_AUTH_SIGN_IN_PATH_NAME } from "../shared.mjs";
3
+ import { APIError, betterAuth } from "better-auth";
4
+ import { createAuthMiddleware } from "better-auth/api";
5
+ import { bearer, genericOAuth, jwt } from "better-auth/plugins";
6
+ import { nextCookies } from "better-auth/next-js";
7
+ import { verifyAccessToken } from "better-auth/oauth2";
8
+ import { parseSetCookieHeader } from "better-auth/cookies";
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import { headers } from "next/headers.js";
11
+ //#region src/auth/server.ts
12
+ /**
13
+ * URL pathname values that should not check for auth in middleware, e.g. the sign-in page.
14
+ * Additional pathname values can be added for public paths, but ONLY if they should not be intercepted by the
15
+ * middleware auth pre-check and redirect.
16
+ */
17
+ const HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES = new Set([HENOSIA_AUTH_SIGN_IN_PATH_NAME]);
18
+ if (HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES.has("/")) throw new Error(`[Henosia Auth] Invalid path name '/'. Values must be non-root paths`);
19
+ /**
20
+ * URL pathname prefixes that should not be blocked by auth in middleware, e.g. the better-auth API routes used to sign in.
21
+ * Additional pathname prefixes can be added for public paths, but ONLY if they should not be intercepted by the
22
+ * middleware auth pre-check and redirect. Include the relevant `/` ending character to prevent unexpected partial
23
+ * route matches.
24
+ */
25
+ const HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES = ["/api/auth/", "/.well-known/"];
26
+ if (HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES.find((p) => p === "/" || !p.trim() || !p.endsWith("/"))) throw new Error("[Henosia Auth] Invalid path name prefixes. Values must be non-empty sub paths ending with slash");
27
+ const requiredEnvProxy = new Proxy(process.env, { get(target, property, receiver) {
28
+ const value = Reflect.get(target, property, receiver);
29
+ if (!value || typeof value !== "string") throw new Error(`[Henosia Auth] Missing required env var: ${property.toString()}`);
30
+ return value;
31
+ } });
32
+ function getRequiredEnvValue(getter, fallback) {
33
+ try {
34
+ return { get: () => getter(requiredEnvProxy) };
35
+ } catch (e) {
36
+ if (fallback) return { get: () => fallback };
37
+ console.log(e.message);
38
+ throw e;
39
+ }
40
+ }
41
+ /**
42
+ * Environment-provided configuration for Henosia Auth.
43
+ * When the consuming app runs in the Henosia preview, the env vars are provided by the runtime environment and should not be
44
+ * hard-coded or present in the `.env.local` file. The Henosia Publish feature automatically sets production versions of
45
+ * the values on the deployed app. For self-publish flows or local development, see the `Environment settings`
46
+ * in the Henosia builder navbar for the relevant values. The preview values cannot be used for production deployments.
47
+ */
48
+ const henosiaAuthConfig = {
49
+ provider: "henosia",
50
+ baseURL: getRequiredEnvValue((env) => env.BETTER_AUTH_URL),
51
+ secret: getRequiredEnvValue((env) => env.BETTER_AUTH_SECRET),
52
+ henosiaAuthPlatformServiceBaseUrl: getRequiredEnvValue((env) => env.HENOSIA_AUTH_SERVICE_BASE_URL),
53
+ henosiaAuthSupabaseProvider: getRequiredEnvValue((env) => env.HENOSIA_AUTH_SUPABASE_PROVIDER, "custom:henosia-auth:platform"),
54
+ clientSecret: getRequiredEnvValue((env) => env.HENOSIA_AUTH_CLIENT_SECRET),
55
+ clientId: getRequiredEnvValue((env) => env.HENOSIA_AUTH_CLIENT_ID),
56
+ projectId: getRequiredEnvValue((env) => env.HENOSIA_AUTH_PROJECT_ID)
57
+ };
58
+ /**
59
+ * Gets whether the specified request is for a page (a top level page or iframe page)
60
+ */
61
+ function isPageRequest(request) {
62
+ const dest = request.headers.get("Sec-Fetch-Dest");
63
+ if (dest === "document" || dest === "iframe" || dest === "fencedframe") return true;
64
+ const isRsc = request.headers.get("RSC") === "1";
65
+ const isPrefetch = request.headers.get("Next-Router-Prefetch") === "1";
66
+ return isRsc && !isPrefetch;
67
+ }
68
+ /**
69
+ * Gets whether the specified request is allowed to spend the single-use OIDC refresh token to obtain a new
70
+ * access token.
71
+ *
72
+ */
73
+ function isPageOrRefreshTokenRequest(request) {
74
+ if (isPageRequest(request)) return true;
75
+ return new URL(request.url).pathname === HENOSIA_AUTH_GET_SESSION_PATH_NAME;
76
+ }
77
+ const currentRequestStorage = new AsyncLocalStorage();
78
+ let henosiaOAuthProviderInitialized = false;
79
+ const cookiePrefix = `henosia-auth-${henosiaAuthConfig.projectId.get()}`;
80
+ const authInstance = betterAuth({
81
+ baseURL: henosiaAuthConfig.baseURL.get(),
82
+ secret: henosiaAuthConfig.secret.get(),
83
+ advanced: { cookiePrefix },
84
+ plugins: [
85
+ bearer(),
86
+ jwt(),
87
+ genericOAuth({ config: [{
88
+ providerId: henosiaAuthConfig.provider,
89
+ discoveryUrl: `${henosiaAuthConfig.henosiaAuthPlatformServiceBaseUrl.get()}/.well-known/openid-configuration`,
90
+ clientId: henosiaAuthConfig.clientId.get(),
91
+ clientSecret: henosiaAuthConfig.clientSecret.get(),
92
+ scopes: [
93
+ "openid",
94
+ "profile",
95
+ "email",
96
+ "offline_access"
97
+ ],
98
+ pkce: true,
99
+ mapProfileToUser: (profile) => {
100
+ if (!profile.name) profile.name = profile.email;
101
+ return profile;
102
+ }
103
+ }] }),
104
+ nextCookies()
105
+ ],
106
+ hooks: { before: createAuthMiddleware(async (ctx) => {
107
+ if (henosiaOAuthProviderInitialized) return;
108
+ for (const provider of ctx.context.socialProviders) {
109
+ const { refreshAccessToken } = provider;
110
+ if (!refreshAccessToken || provider.id !== henosiaAuthConfig.provider) continue;
111
+ provider.refreshAccessToken = async (refreshToken) => {
112
+ const request = currentRequestStorage.getStore();
113
+ if (!request) throw new APIError("INTERNAL_SERVER_ERROR", { message: `Expected a current request during refreshAccessToken` });
114
+ if (!isPageOrRefreshTokenRequest(request)) throw new APIError("UNAUTHORIZED", {
115
+ statusCode: 401,
116
+ message: "Fetching a refresh access token is only allowed for page or refresh token requests"
117
+ });
118
+ return await refreshAccessToken(refreshToken);
119
+ };
120
+ henosiaOAuthProviderInitialized = true;
121
+ break;
122
+ }
123
+ }) },
124
+ session: { cookieCache: {
125
+ enabled: true,
126
+ version: "1",
127
+ maxAge: 720 * 60 * 60,
128
+ strategy: "jwe",
129
+ refreshCache: true
130
+ } },
131
+ account: {
132
+ storeStateStrategy: "cookie",
133
+ storeAccountCookie: true
134
+ },
135
+ databaseHooks: { user: { create: { before: async (data) => {
136
+ const stableId = data.sub;
137
+ if (!stableId) throw new APIError("INTERNAL_SERVER_ERROR", { message: `Expected '.sub' to be defined but got '${stableId}' for '${data.email}'` });
138
+ return { data: {
139
+ ...data,
140
+ id: stableId
141
+ } };
142
+ } } } },
143
+ trustedOrigins: [henosiaAuthConfig.henosiaAuthPlatformServiceBaseUrl.get()]
144
+ });
145
+ /**
146
+ * The Henosia Auth instance.
147
+ *
148
+ * Typed as the minimal {@link Auth} type from better-auth (which is parameterised on the default
149
+ * {@link BetterAuthOptions}) rather than the deeply-inferred concrete type returned by {@link betterAuth}.
150
+ * The deeply-inferred graph references non-portable internals from `better-auth` and `zod` that cannot be
151
+ * named in published declarations (TS2883).
152
+ */
153
+ const auth = authInstance;
154
+ /**
155
+ * Verifies the Henosia Auth JWT on the current request, or throws in case it's missing or invalid.
156
+ * This method must be called before allowing access to any protected pages or routes,
157
+ * and before accessing data in server side page methods or actions.
158
+ * @param request the current request which provides auth headers
159
+ * @return the better-auth `getAccessToken` response, the verified token `payload` (a {@link HenosiaAuthTokenClaims}),
160
+ * the `Set-Cookie` headers produced while obtaining/refreshing the token, and a
161
+ * `transferBetterAuthCookiesToNext` helper that forwards those cookies onto a Next.js request/response pair
162
+ * @throws APIError when the current credentials are missing or invalid
163
+ */
164
+ async function verifyHenosiaAuthToken(request) {
165
+ currentRequestStorage.enterWith(request);
166
+ const { response: accessTokenResponse, headers: accessTokenResponseHeaders } = await authInstance.api.getAccessToken({
167
+ body: { providerId: henosiaAuthConfig.provider },
168
+ headers: await headers(),
169
+ returnHeaders: true
170
+ });
171
+ if (!accessTokenResponse.idToken) throw new APIError("UNAUTHORIZED", { message: "No id_token returned for openid scope" });
172
+ const payload = await verifyAccessToken(accessTokenResponse.idToken, {
173
+ jwksUrl: `${henosiaAuthConfig.henosiaAuthPlatformServiceBaseUrl.get()}/api/auth/jwks`,
174
+ verifyOptions: {
175
+ audience: henosiaAuthConfig.clientId.get(),
176
+ issuer: `${henosiaAuthConfig.henosiaAuthPlatformServiceBaseUrl.get()}/api/auth`
177
+ }
178
+ });
179
+ const { "https://henosia.com/project": projectId } = payload;
180
+ if (!projectId) throw new APIError("UNAUTHORIZED", { message: "token does not grant access" });
181
+ if (!henosiaAuthConfig.projectId) throw new APIError("INTERNAL_SERVER_ERROR", { message: "HENOSIA_AUTH_PROJECT_ID env var has not been set" });
182
+ if (henosiaAuthConfig.projectId.get() !== projectId) throw new APIError("UNAUTHORIZED", { message: "token has invalid projectId" });
183
+ const setCookieHeaders = accessTokenResponseHeaders.getSetCookie();
184
+ const transferBetterAuthCookiesToNext = (nextRequest, response) => {
185
+ for (const cookie of setCookieHeaders) parseSetCookieHeader(cookie).forEach((attributes, name) => {
186
+ const { value, ...betterOptions } = attributes;
187
+ const options = {
188
+ ...betterOptions,
189
+ maxAge: betterOptions["max-age"],
190
+ httpOnly: betterOptions.httponly,
191
+ sameSite: betterOptions.samesite
192
+ };
193
+ nextRequest.cookies.set(name, value);
194
+ response.cookies.set(name, value, options);
195
+ });
196
+ };
197
+ return {
198
+ accessTokenResponse,
199
+ payload,
200
+ setCookieHeaders,
201
+ transferBetterAuthCookiesToNext
202
+ };
203
+ }
204
+ const unauthorizedExceptionCodes = new Set(["ACCOUNT_NOT_FOUND", "FAILED_TO_GET_ACCESS_TOKEN"]);
205
+ /**
206
+ * Determines whether the specified exception should start the sign-in flow to authenticate the user
207
+ * @param error the error thrown by `verifyHenosiaAuthToken`
208
+ */
209
+ function isUnauthorizedException(error) {
210
+ const apiError = error ?? {};
211
+ return unauthorizedExceptionCodes.has(apiError.body?.code ?? "") || apiError.status === "UNAUTHORIZED" || apiError.statusCode === 401;
212
+ }
213
+ //#endregion
214
+ export { HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, auth, cookiePrefix, henosiaAuthConfig, isPageOrRefreshTokenRequest, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken };
@@ -0,0 +1,4 @@
1
+
2
+ import { HENOSIA_AUTH_GET_SESSION_PATH_NAME, HENOSIA_AUTH_SIGN_IN_PATH_NAME, HENOSIA_ORGANIZATION_CTX_COOKIE, HenosiaOrganizationContext } from "./shared.mjs";
3
+ import { HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, HenosiaAuthConfig, HenosiaAuthTokenClaims, auth, cookiePrefix, henosiaAuthConfig, isPageOrRefreshTokenRequest, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken } from "./auth/server.mjs";
4
+ export { HENOSIA_AUTH_GET_SESSION_PATH_NAME, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, HENOSIA_AUTH_SIGN_IN_PATH_NAME, HENOSIA_ORGANIZATION_CTX_COOKIE, type HenosiaAuthConfig, type HenosiaAuthTokenClaims, type HenosiaOrganizationContext, auth, cookiePrefix, henosiaAuthConfig, isPageOrRefreshTokenRequest, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken };
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ /*! Copyright (c) 2026 Henosia ApS. Licensed under the Henosia Commercial Source License v1.0. See LICENSE */
2
+ import { HENOSIA_AUTH_GET_SESSION_PATH_NAME, HENOSIA_AUTH_SIGN_IN_PATH_NAME, HENOSIA_ORGANIZATION_CTX_COOKIE } from "./shared.mjs";
3
+ import { HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, auth, cookiePrefix, henosiaAuthConfig, isPageOrRefreshTokenRequest, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken } from "./auth/server.mjs";
4
+ export { HENOSIA_AUTH_GET_SESSION_PATH_NAME, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAMES, HENOSIA_AUTH_MIDDLEWARE_UNAUTHENTICATED_PATH_NAME_PREFIXES, HENOSIA_AUTH_SIGN_IN_PATH_NAME, HENOSIA_ORGANIZATION_CTX_COOKIE, auth, cookiePrefix, henosiaAuthConfig, isPageOrRefreshTokenRequest, isPageRequest, isUnauthorizedException, verifyHenosiaAuthToken };
@@ -0,0 +1,96 @@
1
+
2
+ import { HenosiaOrganizationContext } from "../shared.mjs";
3
+
4
+ //#region src/platform/app-switcher.d.ts
5
+ /**
6
+ * A single app surfaced by the Henosia Platform app-switcher endpoint.
7
+ */
8
+ interface AppSwitcherApp {
9
+ id: string;
10
+ name: string;
11
+ group: string;
12
+ url: string;
13
+ }
14
+ /**
15
+ * A group of {@link AppSwitcherApp}s under a common heading.
16
+ */
17
+ interface AppSwitcherAppGroup {
18
+ group: string;
19
+ apps: AppSwitcherApp[];
20
+ }
21
+ /**
22
+ * Successful response shape returned by `/api/henosia-platform/v1/app-switcher`.
23
+ */
24
+ interface AppSwitcherSuccessResponse {
25
+ groupedApps: AppSwitcherAppGroup[];
26
+ organization: {
27
+ id: string;
28
+ name: string;
29
+ };
30
+ }
31
+ /**
32
+ * Error response shape returned by `/api/henosia-platform/v1/app-switcher`.
33
+ */
34
+ interface AppSwitcherErrorResponse {
35
+ error: string;
36
+ }
37
+ /**
38
+ * Options for {@link useAppSwitcher}.
39
+ */
40
+ interface UseAppSwitcherOptions {
41
+ /**
42
+ * Whether the underlying query should run. Defaults to `true`.
43
+ *
44
+ * Set this to a state value (e.g. the open state of a popover) to defer the network request until the
45
+ * UI surfacing the app switcher is actually being shown.
46
+ */
47
+ enabled?: boolean;
48
+ }
49
+ /**
50
+ * Result of {@link useAppSwitcher}.
51
+ */
52
+ interface UseAppSwitcherResult {
53
+ /**
54
+ * The current organization context, derived from the {@link HENOSIA_ORGANIZATION_CTX_COOKIE} cookie set by the
55
+ * Henosia Auth middleware. `null` until the cookie has been read (or if no organization is in context).
56
+ */
57
+ organization: HenosiaOrganizationContext;
58
+ /**
59
+ * The grouped apps available to the current user. Empty until the query has loaded.
60
+ */
61
+ groupedApps: AppSwitcherAppGroup[];
62
+ /**
63
+ * The error from the most recent failed query, if any.
64
+ */
65
+ error: Error | null;
66
+ /**
67
+ * Whether the query is currently loading.
68
+ */
69
+ isLoading: boolean;
70
+ }
71
+ /**
72
+ * React hook backing the Henosia app-switcher UI.
73
+ *
74
+ * Reads the current organization context from the {@link HENOSIA_ORGANIZATION_CTX_COOKIE} cookie and queries
75
+ * the Henosia Platform `app-switcher` endpoint mounted at `/api/henosia-platform/v1/app-switcher` (provided by
76
+ * `@henosia/app-next/api/platform`).
77
+ *
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * 'use client';
82
+ * import { useState } from 'react';
83
+ * import { useAppSwitcher } from '@henosia/app-next/platform/app-switcher';
84
+ *
85
+ * export function MyAppSwitcher() {
86
+ * const [open, setOpen] = useState(false);
87
+ * const { organization, groupedApps, isLoading, error } = useAppSwitcher({
88
+ * enabled: open
89
+ * });
90
+ * // ... render your own Popover/Command UI using the data above
91
+ * }
92
+ * ```
93
+ */
94
+ declare function useAppSwitcher(options: UseAppSwitcherOptions): UseAppSwitcherResult;
95
+ //#endregion
96
+ export { AppSwitcherApp, AppSwitcherAppGroup, AppSwitcherErrorResponse, AppSwitcherSuccessResponse, UseAppSwitcherOptions, UseAppSwitcherResult, useAppSwitcher };
@@ -0,0 +1,74 @@
1
+ /*! Copyright (c) 2026 Henosia ApS. Licensed under the Henosia Commercial Source License v1.0. See LICENSE */
2
+ "use client";
3
+ import { HENOSIA_ORGANIZATION_CTX_COOKIE } from "../shared.mjs";
4
+ import { useEffect, useLayoutEffect, useState } from "react";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ //#region src/platform/app-switcher.ts
7
+ /**
8
+ * Use {@link useLayoutEffect} on the client (so cookie reads happen before paint and we don't show a stale
9
+ * organization placeholder), and fall back to {@link useEffect} on the server to avoid the React SSR warning.
10
+ */
11
+ const useFastEffect = typeof document === "object" ? useLayoutEffect : useEffect;
12
+ async function fetchAppSwitcher() {
13
+ const response = await fetch("/api/henosia-platform/v1/app-switcher");
14
+ let body = null;
15
+ try {
16
+ body = await response.json();
17
+ } catch {}
18
+ if (!response.ok || !body || "error" in body && body.error) {
19
+ const message = body && "error" in body && body.error ? body.error : `Request failed: ${response.status} ${response.statusText}`;
20
+ throw new Error(message);
21
+ }
22
+ return body;
23
+ }
24
+ /**
25
+ * React hook backing the Henosia app-switcher UI.
26
+ *
27
+ * Reads the current organization context from the {@link HENOSIA_ORGANIZATION_CTX_COOKIE} cookie and queries
28
+ * the Henosia Platform `app-switcher` endpoint mounted at `/api/henosia-platform/v1/app-switcher` (provided by
29
+ * `@henosia/app-next/api/platform`).
30
+ *
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * 'use client';
35
+ * import { useState } from 'react';
36
+ * import { useAppSwitcher } from '@henosia/app-next/platform/app-switcher';
37
+ *
38
+ * export function MyAppSwitcher() {
39
+ * const [open, setOpen] = useState(false);
40
+ * const { organization, groupedApps, isLoading, error } = useAppSwitcher({
41
+ * enabled: open
42
+ * });
43
+ * // ... render your own Popover/Command UI using the data above
44
+ * }
45
+ * ```
46
+ */
47
+ function useAppSwitcher(options) {
48
+ const { enabled = true } = options;
49
+ const [organization, setOrganization] = useState(null);
50
+ useFastEffect(() => {
51
+ if (typeof document !== "object") return;
52
+ const orgIdx = document.cookie.indexOf(`${HENOSIA_ORGANIZATION_CTX_COOKIE}=`);
53
+ if (orgIdx === -1) return;
54
+ const cookie = document.cookie.substring(orgIdx).split(/[=;]/g)[1];
55
+ try {
56
+ setOrganization(JSON.parse(decodeURIComponent(cookie)));
57
+ } catch (e) {
58
+ console.warn("Invalid org cookie", cookie, e);
59
+ }
60
+ }, []);
61
+ const { data, error, isLoading } = useQuery({
62
+ queryKey: ["henosia-platform", "app-switcher"],
63
+ queryFn: fetchAppSwitcher,
64
+ enabled
65
+ });
66
+ return {
67
+ organization,
68
+ groupedApps: data?.groupedApps ?? [],
69
+ error,
70
+ isLoading
71
+ };
72
+ }
73
+ //#endregion
74
+ export { useAppSwitcher };
@@ -0,0 +1,19 @@
1
+
2
+ //#region src/shared.d.ts
3
+ declare const HENOSIA_AUTH_SIGN_IN_PATH_NAME = "/sign-in";
4
+ /**
5
+ * The pathname for better-auth's `/get-session` endpoint, used by the React `useSession` hook.
6
+ *
7
+ * `useSession` automatically refetches this endpoint on browser tab focus (and optionally on a polling
8
+ * interval), which we leverage as a built-in keep-alive for the OIDC access/refresh tokens — see
9
+ * `isPageOrRefreshTokenRequest` and the middleware special-case for this path.
10
+ */
11
+ declare const HENOSIA_AUTH_GET_SESSION_PATH_NAME = "/api/auth/get-session";
12
+ type HenosiaOrganizationContext = {
13
+ id: string;
14
+ name: string;
15
+ logoUrl: string | null;
16
+ } | null;
17
+ declare const HENOSIA_ORGANIZATION_CTX_COOKIE = "henosia.org";
18
+ //#endregion
19
+ export { HENOSIA_AUTH_GET_SESSION_PATH_NAME, HENOSIA_AUTH_SIGN_IN_PATH_NAME, HENOSIA_ORGANIZATION_CTX_COOKIE, HenosiaOrganizationContext };