@confighub/react-auth 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) ConfigHub, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @confighub/react-auth
2
+
3
+ React provider and hooks for authenticating a browser app against the
4
+ [ConfigHub](https://confighub.com) API. Runs the browser-direct flow end to end —
5
+ runtime discovery, OIDC Authorization Code + PKCE against the discovered IdP, then
6
+ RFC 8693 token exchange for a minted ConfigHub token — and hands back a typed
7
+ [`@confighub/api`](https://www.npmjs.com/package/@confighub/api) client pre-wired
8
+ with the token.
9
+
10
+ ```tsx
11
+ import { ConfigHubAuthProvider, useAuth, useConfigHub } from '@confighub/react-auth';
12
+
13
+ <ConfigHubAuthProvider baseUrl="https://hub.confighub.com" clientId={CLIENT_ID}>
14
+ <App />
15
+ </ConfigHubAuthProvider>;
16
+
17
+ function App() {
18
+ const { status, user, login, logout } = useAuth();
19
+ const api = useConfigHub(); // typed client, token already attached
20
+ // await api.GET('/me'); await api.GET('/space/{space_id}/unit', { params: { path: { space_id } } });
21
+
22
+ if (status === 'loading') return <p>…</p>;
23
+ if (status !== 'authenticated') return <button onClick={login}>Log in</button>;
24
+ return <button onClick={logout}>Sign out {user!.organizationId}</button>;
25
+ }
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ - `baseUrl` — the ConfigHub instance, e.g. `https://hub.confighub.com`.
31
+ - `clientId` — this app's registered OAuth client id, from
32
+ `cub oauthclient create <name> --redirect-uri <origin>`.
33
+
34
+ The IdP issuer and OIDC endpoints are discovered from `{baseUrl}/api/info`, so the
35
+ same build runs against any ConfigHub instance (the bundled Keycloak for Cloud, an
36
+ organization's own IdP for Enterprise).
37
+
38
+ ## Token posture
39
+
40
+ The minted token is kept in memory, never `localStorage`; only the transient PKCE
41
+ verifier is parked in `sessionStorage` across the authorize redirect. A 401 clears
42
+ the session so the app re-authenticates.
43
+
44
+ Not yet implemented: silent refresh via refresh-token rotation, and IdP
45
+ end-session on logout. Today a 401 or `logout()` returns the app to the login
46
+ screen. These follow the server-side refresh-rotation work.
47
+
48
+ `react` (18 or 19) is a peer dependency.
package/dist/index.cjs ADDED
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ var api = require('@confighub/api');
4
+ var react = require('react');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+
7
+ // src/provider.tsx
8
+
9
+ // src/core.ts
10
+ var PKCE_KEY = "confighub_pkce";
11
+ var redirectUri = () => window.location.origin + window.location.pathname;
12
+ var trimSlash = (s) => s.replace(/\/+$/, "");
13
+ var b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
14
+ var randomString = (n = 64) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer);
15
+ async function sha256(s) {
16
+ return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)));
17
+ }
18
+ function decodeJwtClaims(token) {
19
+ const part = token.split(".")[1];
20
+ if (!part) return {};
21
+ return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/")));
22
+ }
23
+ async function discover(base) {
24
+ const r = await fetch(trimSlash(base) + "/api/info");
25
+ if (!r.ok) throw new Error("/api/info failed: " + r.status);
26
+ return r.json();
27
+ }
28
+ async function oidcMetadata(issuer) {
29
+ const r = await fetch(trimSlash(issuer) + "/.well-known/openid-configuration");
30
+ if (!r.ok) throw new Error("OIDC discovery failed: " + r.status);
31
+ return r.json();
32
+ }
33
+ async function startLogin(base, clientId) {
34
+ const info = await discover(base);
35
+ if (!info.AuthIssuer || !info.TokenExchangeEndpoint) {
36
+ throw new Error(
37
+ "this instance is not configured for token-exchange auth (server needs CONFIGHUB_IDP_ISSUER)"
38
+ );
39
+ }
40
+ const meta = await oidcMetadata(info.AuthIssuer);
41
+ const verifier = randomString();
42
+ const challenge = await sha256(verifier);
43
+ const state = randomString(16);
44
+ const pkce = {
45
+ verifier,
46
+ state,
47
+ clientId,
48
+ tokenEndpoint: meta.token_endpoint,
49
+ exchangeEndpoint: info.TokenExchangeEndpoint
50
+ };
51
+ sessionStorage.setItem(PKCE_KEY, JSON.stringify(pkce));
52
+ const authURL = new URL(meta.authorization_endpoint);
53
+ authURL.search = new URLSearchParams({
54
+ response_type: "code",
55
+ client_id: clientId,
56
+ redirect_uri: redirectUri(),
57
+ // The "organization" scope makes Keycloak emit the org claim the exchange resolves.
58
+ scope: "openid email profile organization",
59
+ code_challenge: challenge,
60
+ code_challenge_method: "S256",
61
+ state
62
+ }).toString();
63
+ window.location.assign(authURL.toString());
64
+ }
65
+ var pending = null;
66
+ function completeLoginFromRedirect() {
67
+ if (!pending) pending = doCompleteLogin();
68
+ return pending;
69
+ }
70
+ async function doCompleteLogin() {
71
+ const params = new URLSearchParams(window.location.search);
72
+ const code = params.get("code");
73
+ const error = params.get("error");
74
+ if (error) {
75
+ history.replaceState({}, "", redirectUri());
76
+ throw new Error(`IdP returned error: ${error} ${params.get("error_description") ?? ""}`);
77
+ }
78
+ if (!code) return null;
79
+ const savedRaw = sessionStorage.getItem(PKCE_KEY);
80
+ sessionStorage.removeItem(PKCE_KEY);
81
+ history.replaceState({}, "", redirectUri());
82
+ if (!savedRaw) throw new Error("no PKCE state; restart login");
83
+ const saved = JSON.parse(savedRaw);
84
+ if (params.get("state") !== saved.state) throw new Error("state mismatch; aborting");
85
+ const tokenResp = await fetch(saved.tokenEndpoint, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
88
+ body: new URLSearchParams({
89
+ grant_type: "authorization_code",
90
+ code,
91
+ redirect_uri: redirectUri(),
92
+ client_id: saved.clientId,
93
+ code_verifier: saved.verifier
94
+ })
95
+ });
96
+ if (!tokenResp.ok) {
97
+ throw new Error(`IdP token endpoint ${tokenResp.status}: ${await tokenResp.text()}`);
98
+ }
99
+ const idpToken = await tokenResp.json();
100
+ const exResp = await fetch(saved.exchangeEndpoint, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
103
+ body: new URLSearchParams({
104
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
105
+ subject_token: idpToken.access_token,
106
+ subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
107
+ })
108
+ });
109
+ if (!exResp.ok) throw new Error(`/auth/exchange ${exResp.status}: ${await exResp.text()}`);
110
+ const minted = await exResp.json();
111
+ return {
112
+ accessToken: minted.access_token,
113
+ organizationId: minted.organization_id,
114
+ idpClaims: decodeJwtClaims(idpToken.access_token)
115
+ };
116
+ }
117
+ function resetPending() {
118
+ pending = null;
119
+ }
120
+
121
+ // src/tokenStore.ts
122
+ var currentToken;
123
+ function setAccessToken(token) {
124
+ currentToken = token;
125
+ }
126
+ function getAccessToken() {
127
+ return currentToken;
128
+ }
129
+ var ConfigHubAuthContext = react.createContext(null);
130
+ function ConfigHubAuthProvider({
131
+ baseUrl,
132
+ clientId,
133
+ children
134
+ }) {
135
+ const [status, setStatus] = react.useState("loading");
136
+ const [user, setUser] = react.useState(null);
137
+ const [error, setError] = react.useState(null);
138
+ const tokenRef = react.useRef(void 0);
139
+ const applySession = react.useCallback((session) => {
140
+ tokenRef.current = session.accessToken;
141
+ setAccessToken(session.accessToken);
142
+ setUser({ organizationId: session.organizationId, idpClaims: session.idpClaims });
143
+ setError(null);
144
+ setStatus("authenticated");
145
+ }, []);
146
+ react.useEffect(() => {
147
+ let cancelled = false;
148
+ completeLoginFromRedirect().then((session) => {
149
+ if (cancelled) return;
150
+ if (session) applySession(session);
151
+ else setStatus("unauthenticated");
152
+ }).catch((e) => {
153
+ if (cancelled) return;
154
+ setError(e instanceof Error ? e : new Error(String(e)));
155
+ setStatus("error");
156
+ });
157
+ return () => {
158
+ cancelled = true;
159
+ };
160
+ }, [applySession]);
161
+ const login = react.useCallback(async () => {
162
+ setError(null);
163
+ try {
164
+ await startLogin(baseUrl, clientId);
165
+ } catch (e) {
166
+ setError(e instanceof Error ? e : new Error(String(e)));
167
+ setStatus("error");
168
+ }
169
+ }, [baseUrl, clientId]);
170
+ const logout = react.useCallback(() => {
171
+ tokenRef.current = void 0;
172
+ setAccessToken(void 0);
173
+ resetPending();
174
+ setUser(null);
175
+ setStatus("unauthenticated");
176
+ }, []);
177
+ const getToken = react.useCallback(() => tokenRef.current, []);
178
+ const client = react.useMemo(
179
+ () => api.createConfigHubClient({
180
+ baseUrl,
181
+ getToken,
182
+ onUnauthorized: () => {
183
+ logout();
184
+ }
185
+ }),
186
+ [baseUrl, getToken, logout]
187
+ );
188
+ const value = react.useMemo(
189
+ () => ({ status, user, error, login, logout, getToken, client }),
190
+ [status, user, error, login, logout, getToken, client]
191
+ );
192
+ return /* @__PURE__ */ jsxRuntime.jsx(ConfigHubAuthContext.Provider, { value, children });
193
+ }
194
+ function useAuth() {
195
+ const ctx = react.useContext(ConfigHubAuthContext);
196
+ if (!ctx) {
197
+ throw new Error("useAuth must be used within a <ConfigHubAuthProvider>");
198
+ }
199
+ return ctx;
200
+ }
201
+ function useConfigHub() {
202
+ return useAuth().client;
203
+ }
204
+
205
+ exports.ConfigHubAuthContext = ConfigHubAuthContext;
206
+ exports.ConfigHubAuthProvider = ConfigHubAuthProvider;
207
+ exports.getAccessToken = getAccessToken;
208
+ exports.useAuth = useAuth;
209
+ exports.useConfigHub = useConfigHub;
210
+ //# sourceMappingURL=index.cjs.map
211
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core.ts","../src/tokenStore.ts","../src/provider.tsx","../src/hooks.ts"],"names":["createContext","useState","useRef","useCallback","useEffect","useMemo","createConfigHubClient","jsx","useContext"],"mappings":"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","file":"index.cjs","sourcesContent":["// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n//\n// Framework-neutral browser-auth engine for ConfigHub. Productionized from the\n// reference harness `test/browser-auth/src/confighubAuth.ts` in the ConfigHub\n// monorepo, which is validated end to end against staging and prod.\n//\n// Flow (design: third-party-browser-app-auth.md §6):\n// GET {base}/api/info -> discovery { AuthIssuer, TokenExchangeEndpoint }\n// OIDC discovery on AuthIssuer -> authorize/token endpoints\n// PKCE authorize + code->token -> IdP token\n// POST {TokenExchangeEndpoint} (8693) -> minted ConfigHub token\n//\n// The minted token then rides `Authorization: Bearer` against `/api`. The flow is\n// edition-agnostic: `AuthIssuer` is whatever discovery names (ConfigHub's bundled\n// Keycloak for Cloud, the org's own IdP for Enterprise), so the same code runs\n// against both. Tokens are held in memory by the caller; only the transient PKCE\n// verifier is parked in sessionStorage across the authorize redirect.\n\nexport interface Discovery {\n AuthIssuer?: string;\n TokenExchangeEndpoint?: string;\n TokenExchangeAudience?: string;\n}\n\nexport interface MintedSession {\n accessToken: string;\n organizationId: string;\n /** Claims of the validated IdP token (owning-org, audience, organization shape). */\n idpClaims: Record<string, unknown>;\n}\n\ninterface PkceState {\n verifier: string;\n state: string;\n clientId: string;\n tokenEndpoint: string;\n exchangeEndpoint: string;\n}\n\nconst PKCE_KEY = 'confighub_pkce';\n\nconst redirectUri = (): string => window.location.origin + window.location.pathname;\n\nconst trimSlash = (s: string): string => s.replace(/\\/+$/, '');\n\nconst b64url = (buf: ArrayBuffer): string =>\n btoa(String.fromCharCode(...new Uint8Array(buf)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nconst randomString = (n = 64): string =>\n b64url(crypto.getRandomValues(new Uint8Array(n)).buffer);\n\nasync function sha256(s: string): Promise<string> {\n return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));\n}\n\nfunction decodeJwtClaims(token: string): Record<string, unknown> {\n const part = token.split('.')[1];\n if (!part) return {};\n return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));\n}\n\nexport async function discover(base: string): Promise<Discovery> {\n const r = await fetch(trimSlash(base) + '/api/info');\n if (!r.ok) throw new Error('/api/info failed: ' + r.status);\n return r.json();\n}\n\nasync function oidcMetadata(\n issuer: string,\n): Promise<{ authorization_endpoint: string; token_endpoint: string }> {\n const r = await fetch(trimSlash(issuer) + '/.well-known/openid-configuration');\n if (!r.ok) throw new Error('OIDC discovery failed: ' + r.status);\n return r.json();\n}\n\n/**\n * Discover, build a PKCE request, and navigate to the IdP authorize endpoint.\n * Returns only by redirecting the page; `completeLoginFromRedirect()` finishes on\n * the way back.\n */\nexport async function startLogin(base: string, clientId: string): Promise<void> {\n const info = await discover(base);\n if (!info.AuthIssuer || !info.TokenExchangeEndpoint) {\n throw new Error(\n 'this instance is not configured for token-exchange auth (server needs CONFIGHUB_IDP_ISSUER)',\n );\n }\n const meta = await oidcMetadata(info.AuthIssuer);\n const verifier = randomString();\n const challenge = await sha256(verifier);\n const state = randomString(16);\n const pkce: PkceState = {\n verifier,\n state,\n clientId,\n tokenEndpoint: meta.token_endpoint,\n exchangeEndpoint: info.TokenExchangeEndpoint,\n };\n sessionStorage.setItem(PKCE_KEY, JSON.stringify(pkce));\n\n const authURL = new URL(meta.authorization_endpoint);\n authURL.search = new URLSearchParams({\n response_type: 'code',\n client_id: clientId,\n redirect_uri: redirectUri(),\n // The \"organization\" scope makes Keycloak emit the org claim the exchange resolves.\n scope: 'openid email profile organization',\n code_challenge: challenge,\n code_challenge_method: 'S256',\n state,\n }).toString();\n window.location.assign(authURL.toString());\n}\n\n// Memoize so React StrictMode's double-mount can't redeem the one-time code twice.\nlet pending: Promise<MintedSession | null> | null = null;\n\n/**\n * If the page is the IdP redirect (`?code=...`), exchange the code for an IdP token\n * and then exchange that for a minted ConfigHub token. Returns null on a normal load.\n */\nexport function completeLoginFromRedirect(): Promise<MintedSession | null> {\n if (!pending) pending = doCompleteLogin();\n return pending;\n}\n\nasync function doCompleteLogin(): Promise<MintedSession | null> {\n const params = new URLSearchParams(window.location.search);\n const code = params.get('code');\n const error = params.get('error');\n if (error) {\n history.replaceState({}, '', redirectUri());\n throw new Error(`IdP returned error: ${error} ${params.get('error_description') ?? ''}`);\n }\n if (!code) return null;\n\n const savedRaw = sessionStorage.getItem(PKCE_KEY);\n sessionStorage.removeItem(PKCE_KEY);\n history.replaceState({}, '', redirectUri()); // strip ?code= from the URL\n if (!savedRaw) throw new Error('no PKCE state; restart login');\n const saved: PkceState = JSON.parse(savedRaw);\n if (params.get('state') !== saved.state) throw new Error('state mismatch; aborting');\n\n // Exchange the authorization code for an IdP token (PKCE, public client).\n const tokenResp = await fetch(saved.tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: redirectUri(),\n client_id: saved.clientId,\n code_verifier: saved.verifier,\n }),\n });\n if (!tokenResp.ok) {\n throw new Error(`IdP token endpoint ${tokenResp.status}: ${await tokenResp.text()}`);\n }\n const idpToken = await tokenResp.json();\n\n // RFC 8693 token exchange against ConfigHub -> minted ConfigHub token.\n const exResp = await fetch(saved.exchangeEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',\n subject_token: idpToken.access_token,\n subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',\n }),\n });\n if (!exResp.ok) throw new Error(`/auth/exchange ${exResp.status}: ${await exResp.text()}`);\n const minted = await exResp.json();\n\n return {\n accessToken: minted.access_token,\n organizationId: minted.organization_id,\n idpClaims: decodeJwtClaims(idpToken.access_token),\n };\n}\n\n/** Discard the in-progress login memo (used on logout so a later login re-runs). */\nexport function resetPending(): void {\n pending = null;\n}\n","// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n\n// A module-level holder for the current minted token, so non-React consumers can read\n// it. RTK Query's `prepareHeaders` (in @confighub/rtk-query) is not a hook and cannot\n// read React context, so it calls getAccessToken() instead. The provider keeps this in\n// sync with its React state.\nlet currentToken: string | undefined;\n\n/** @internal — called by the provider; not part of the public surface. */\nexport function setAccessToken(token: string | undefined): void {\n currentToken = token;\n}\n\n/**\n * The current minted ConfigHub token, or undefined when unauthenticated. Pass this as\n * the `getToken` for `@confighub/rtk-query`'s `configureConfigHub`, or read it anywhere\n * you need the token outside React.\n */\nexport function getAccessToken(): string | undefined {\n return currentToken;\n}\n","// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n\nimport { createConfigHubClient, type ConfigHubClient } from '@confighub/api';\nimport {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n completeLoginFromRedirect,\n resetPending,\n startLogin,\n type MintedSession,\n} from './core';\nimport { setAccessToken } from './tokenStore';\n\nexport type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'error';\n\nexport interface ConfigHubUser {\n organizationId: string;\n idpClaims: Record<string, unknown>;\n}\n\nexport interface ConfigHubAuthContextValue {\n status: AuthStatus;\n user: ConfigHubUser | null;\n error: Error | null;\n /** Begin login: redirects the page to the IdP. */\n login: () => Promise<void>;\n /** Clear the in-memory session. Does not call the IdP end-session endpoint. */\n logout: () => void;\n /** Current bearer token, or undefined when unauthenticated. */\n getToken: () => string | undefined;\n /** A typed API client pre-wired with the current token. Stable across renders. */\n client: ConfigHubClient;\n}\n\nexport const ConfigHubAuthContext = createContext<ConfigHubAuthContextValue | null>(null);\n\nexport interface ConfigHubAuthProviderProps {\n /** Absolute base URL of the ConfigHub instance, e.g. `https://hub.confighub.com`. */\n baseUrl: string;\n /** This app's registered OAuth `client_id` (from `cub oauthclient create`). */\n clientId: string;\n children: ReactNode;\n}\n\n/**\n * Runs the browser-direct auth flow and manages the token lifecycle. On mount it\n * completes a redirect if the page is the IdP callback; otherwise it starts\n * unauthenticated until `login()` is called.\n */\nexport function ConfigHubAuthProvider({\n baseUrl,\n clientId,\n children,\n}: ConfigHubAuthProviderProps): JSX.Element {\n const [status, setStatus] = useState<AuthStatus>('loading');\n const [user, setUser] = useState<ConfigHubUser | null>(null);\n const [error, setError] = useState<Error | null>(null);\n\n // The token lives in a ref so getToken() reads the latest value synchronously\n // without re-creating the API client on every render.\n const tokenRef = useRef<string | undefined>(undefined);\n\n const applySession = useCallback((session: MintedSession) => {\n tokenRef.current = session.accessToken;\n setAccessToken(session.accessToken); // keep the non-React accessor in sync (rtk-query)\n setUser({ organizationId: session.organizationId, idpClaims: session.idpClaims });\n setError(null);\n setStatus('authenticated');\n }, []);\n\n useEffect(() => {\n let cancelled = false;\n completeLoginFromRedirect()\n .then((session) => {\n if (cancelled) return;\n if (session) applySession(session);\n else setStatus('unauthenticated');\n })\n .catch((e: unknown) => {\n if (cancelled) return;\n setError(e instanceof Error ? e : new Error(String(e)));\n setStatus('error');\n });\n return () => {\n cancelled = true;\n };\n }, [applySession]);\n\n const login = useCallback(async () => {\n setError(null);\n try {\n await startLogin(baseUrl, clientId);\n } catch (e: unknown) {\n setError(e instanceof Error ? e : new Error(String(e)));\n setStatus('error');\n }\n }, [baseUrl, clientId]);\n\n const logout = useCallback(() => {\n tokenRef.current = undefined;\n setAccessToken(undefined);\n resetPending();\n setUser(null);\n setStatus('unauthenticated');\n }, []);\n\n const getToken = useCallback(() => tokenRef.current, []);\n\n // One client for the provider's lifetime. getToken reads tokenRef, and a 401\n // routes back to login() so an expired session re-authenticates.\n const client = useMemo(\n () =>\n createConfigHubClient({\n baseUrl,\n getToken,\n onUnauthorized: () => {\n logout();\n },\n }),\n [baseUrl, getToken, logout],\n );\n\n const value = useMemo<ConfigHubAuthContextValue>(\n () => ({ status, user, error, login, logout, getToken, client }),\n [status, user, error, login, logout, getToken, client],\n );\n\n return (\n <ConfigHubAuthContext.Provider value={value}>{children}</ConfigHubAuthContext.Provider>\n );\n}\n","// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n\nimport type { ConfigHubClient } from '@confighub/api';\nimport { useContext } from 'react';\nimport { ConfigHubAuthContext, type ConfigHubAuthContextValue } from './provider';\n\n/**\n * Access the ConfigHub auth state and actions. Must be called under a\n * `<ConfigHubAuthProvider>`.\n *\n * ```ts\n * const { status, user, login, logout } = useAuth();\n * ```\n */\nexport function useAuth(): ConfigHubAuthContextValue {\n const ctx = useContext(ConfigHubAuthContext);\n if (!ctx) {\n throw new Error('useAuth must be used within a <ConfigHubAuthProvider>');\n }\n return ctx;\n}\n\n/**\n * The typed ConfigHub API client, pre-wired with the current token. This is the\n * seam between `@confighub/react-auth` and `@confighub/api`: you never pass a\n * token by hand.\n *\n * ```ts\n * const api = useConfigHub();\n * const { data } = await api.GET('/space/{space_id}/unit', { params: { path: { space_id } } });\n * ```\n */\nexport function useConfigHub(): ConfigHubClient {\n return useAuth().client;\n}\n"]}
@@ -0,0 +1,78 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { ConfigHubClient } from '@confighub/api';
4
+
5
+ type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'error';
6
+ interface ConfigHubUser {
7
+ organizationId: string;
8
+ idpClaims: Record<string, unknown>;
9
+ }
10
+ interface ConfigHubAuthContextValue {
11
+ status: AuthStatus;
12
+ user: ConfigHubUser | null;
13
+ error: Error | null;
14
+ /** Begin login: redirects the page to the IdP. */
15
+ login: () => Promise<void>;
16
+ /** Clear the in-memory session. Does not call the IdP end-session endpoint. */
17
+ logout: () => void;
18
+ /** Current bearer token, or undefined when unauthenticated. */
19
+ getToken: () => string | undefined;
20
+ /** A typed API client pre-wired with the current token. Stable across renders. */
21
+ client: ConfigHubClient;
22
+ }
23
+ declare const ConfigHubAuthContext: react.Context<ConfigHubAuthContextValue | null>;
24
+ interface ConfigHubAuthProviderProps {
25
+ /** Absolute base URL of the ConfigHub instance, e.g. `https://hub.confighub.com`. */
26
+ baseUrl: string;
27
+ /** This app's registered OAuth `client_id` (from `cub oauthclient create`). */
28
+ clientId: string;
29
+ children: ReactNode;
30
+ }
31
+ /**
32
+ * Runs the browser-direct auth flow and manages the token lifecycle. On mount it
33
+ * completes a redirect if the page is the IdP callback; otherwise it starts
34
+ * unauthenticated until `login()` is called.
35
+ */
36
+ declare function ConfigHubAuthProvider({ baseUrl, clientId, children, }: ConfigHubAuthProviderProps): JSX.Element;
37
+
38
+ /**
39
+ * Access the ConfigHub auth state and actions. Must be called under a
40
+ * `<ConfigHubAuthProvider>`.
41
+ *
42
+ * ```ts
43
+ * const { status, user, login, logout } = useAuth();
44
+ * ```
45
+ */
46
+ declare function useAuth(): ConfigHubAuthContextValue;
47
+ /**
48
+ * The typed ConfigHub API client, pre-wired with the current token. This is the
49
+ * seam between `@confighub/react-auth` and `@confighub/api`: you never pass a
50
+ * token by hand.
51
+ *
52
+ * ```ts
53
+ * const api = useConfigHub();
54
+ * const { data } = await api.GET('/space/{space_id}/unit', { params: { path: { space_id } } });
55
+ * ```
56
+ */
57
+ declare function useConfigHub(): ConfigHubClient;
58
+
59
+ /**
60
+ * The current minted ConfigHub token, or undefined when unauthenticated. Pass this as
61
+ * the `getToken` for `@confighub/rtk-query`'s `configureConfigHub`, or read it anywhere
62
+ * you need the token outside React.
63
+ */
64
+ declare function getAccessToken(): string | undefined;
65
+
66
+ interface Discovery {
67
+ AuthIssuer?: string;
68
+ TokenExchangeEndpoint?: string;
69
+ TokenExchangeAudience?: string;
70
+ }
71
+ interface MintedSession {
72
+ accessToken: string;
73
+ organizationId: string;
74
+ /** Claims of the validated IdP token (owning-org, audience, organization shape). */
75
+ idpClaims: Record<string, unknown>;
76
+ }
77
+
78
+ export { type AuthStatus, ConfigHubAuthContext, type ConfigHubAuthContextValue, ConfigHubAuthProvider, type ConfigHubAuthProviderProps, type ConfigHubUser, type Discovery, type MintedSession, getAccessToken, useAuth, useConfigHub };
@@ -0,0 +1,78 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { ConfigHubClient } from '@confighub/api';
4
+
5
+ type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'error';
6
+ interface ConfigHubUser {
7
+ organizationId: string;
8
+ idpClaims: Record<string, unknown>;
9
+ }
10
+ interface ConfigHubAuthContextValue {
11
+ status: AuthStatus;
12
+ user: ConfigHubUser | null;
13
+ error: Error | null;
14
+ /** Begin login: redirects the page to the IdP. */
15
+ login: () => Promise<void>;
16
+ /** Clear the in-memory session. Does not call the IdP end-session endpoint. */
17
+ logout: () => void;
18
+ /** Current bearer token, or undefined when unauthenticated. */
19
+ getToken: () => string | undefined;
20
+ /** A typed API client pre-wired with the current token. Stable across renders. */
21
+ client: ConfigHubClient;
22
+ }
23
+ declare const ConfigHubAuthContext: react.Context<ConfigHubAuthContextValue | null>;
24
+ interface ConfigHubAuthProviderProps {
25
+ /** Absolute base URL of the ConfigHub instance, e.g. `https://hub.confighub.com`. */
26
+ baseUrl: string;
27
+ /** This app's registered OAuth `client_id` (from `cub oauthclient create`). */
28
+ clientId: string;
29
+ children: ReactNode;
30
+ }
31
+ /**
32
+ * Runs the browser-direct auth flow and manages the token lifecycle. On mount it
33
+ * completes a redirect if the page is the IdP callback; otherwise it starts
34
+ * unauthenticated until `login()` is called.
35
+ */
36
+ declare function ConfigHubAuthProvider({ baseUrl, clientId, children, }: ConfigHubAuthProviderProps): JSX.Element;
37
+
38
+ /**
39
+ * Access the ConfigHub auth state and actions. Must be called under a
40
+ * `<ConfigHubAuthProvider>`.
41
+ *
42
+ * ```ts
43
+ * const { status, user, login, logout } = useAuth();
44
+ * ```
45
+ */
46
+ declare function useAuth(): ConfigHubAuthContextValue;
47
+ /**
48
+ * The typed ConfigHub API client, pre-wired with the current token. This is the
49
+ * seam between `@confighub/react-auth` and `@confighub/api`: you never pass a
50
+ * token by hand.
51
+ *
52
+ * ```ts
53
+ * const api = useConfigHub();
54
+ * const { data } = await api.GET('/space/{space_id}/unit', { params: { path: { space_id } } });
55
+ * ```
56
+ */
57
+ declare function useConfigHub(): ConfigHubClient;
58
+
59
+ /**
60
+ * The current minted ConfigHub token, or undefined when unauthenticated. Pass this as
61
+ * the `getToken` for `@confighub/rtk-query`'s `configureConfigHub`, or read it anywhere
62
+ * you need the token outside React.
63
+ */
64
+ declare function getAccessToken(): string | undefined;
65
+
66
+ interface Discovery {
67
+ AuthIssuer?: string;
68
+ TokenExchangeEndpoint?: string;
69
+ TokenExchangeAudience?: string;
70
+ }
71
+ interface MintedSession {
72
+ accessToken: string;
73
+ organizationId: string;
74
+ /** Claims of the validated IdP token (owning-org, audience, organization shape). */
75
+ idpClaims: Record<string, unknown>;
76
+ }
77
+
78
+ export { type AuthStatus, ConfigHubAuthContext, type ConfigHubAuthContextValue, ConfigHubAuthProvider, type ConfigHubAuthProviderProps, type ConfigHubUser, type Discovery, type MintedSession, getAccessToken, useAuth, useConfigHub };
package/dist/index.js ADDED
@@ -0,0 +1,205 @@
1
+ import { createConfigHubClient } from '@confighub/api';
2
+ import { createContext, useState, useRef, useCallback, useEffect, useMemo, useContext } from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ // src/provider.tsx
6
+
7
+ // src/core.ts
8
+ var PKCE_KEY = "confighub_pkce";
9
+ var redirectUri = () => window.location.origin + window.location.pathname;
10
+ var trimSlash = (s) => s.replace(/\/+$/, "");
11
+ var b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
12
+ var randomString = (n = 64) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer);
13
+ async function sha256(s) {
14
+ return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)));
15
+ }
16
+ function decodeJwtClaims(token) {
17
+ const part = token.split(".")[1];
18
+ if (!part) return {};
19
+ return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/")));
20
+ }
21
+ async function discover(base) {
22
+ const r = await fetch(trimSlash(base) + "/api/info");
23
+ if (!r.ok) throw new Error("/api/info failed: " + r.status);
24
+ return r.json();
25
+ }
26
+ async function oidcMetadata(issuer) {
27
+ const r = await fetch(trimSlash(issuer) + "/.well-known/openid-configuration");
28
+ if (!r.ok) throw new Error("OIDC discovery failed: " + r.status);
29
+ return r.json();
30
+ }
31
+ async function startLogin(base, clientId) {
32
+ const info = await discover(base);
33
+ if (!info.AuthIssuer || !info.TokenExchangeEndpoint) {
34
+ throw new Error(
35
+ "this instance is not configured for token-exchange auth (server needs CONFIGHUB_IDP_ISSUER)"
36
+ );
37
+ }
38
+ const meta = await oidcMetadata(info.AuthIssuer);
39
+ const verifier = randomString();
40
+ const challenge = await sha256(verifier);
41
+ const state = randomString(16);
42
+ const pkce = {
43
+ verifier,
44
+ state,
45
+ clientId,
46
+ tokenEndpoint: meta.token_endpoint,
47
+ exchangeEndpoint: info.TokenExchangeEndpoint
48
+ };
49
+ sessionStorage.setItem(PKCE_KEY, JSON.stringify(pkce));
50
+ const authURL = new URL(meta.authorization_endpoint);
51
+ authURL.search = new URLSearchParams({
52
+ response_type: "code",
53
+ client_id: clientId,
54
+ redirect_uri: redirectUri(),
55
+ // The "organization" scope makes Keycloak emit the org claim the exchange resolves.
56
+ scope: "openid email profile organization",
57
+ code_challenge: challenge,
58
+ code_challenge_method: "S256",
59
+ state
60
+ }).toString();
61
+ window.location.assign(authURL.toString());
62
+ }
63
+ var pending = null;
64
+ function completeLoginFromRedirect() {
65
+ if (!pending) pending = doCompleteLogin();
66
+ return pending;
67
+ }
68
+ async function doCompleteLogin() {
69
+ const params = new URLSearchParams(window.location.search);
70
+ const code = params.get("code");
71
+ const error = params.get("error");
72
+ if (error) {
73
+ history.replaceState({}, "", redirectUri());
74
+ throw new Error(`IdP returned error: ${error} ${params.get("error_description") ?? ""}`);
75
+ }
76
+ if (!code) return null;
77
+ const savedRaw = sessionStorage.getItem(PKCE_KEY);
78
+ sessionStorage.removeItem(PKCE_KEY);
79
+ history.replaceState({}, "", redirectUri());
80
+ if (!savedRaw) throw new Error("no PKCE state; restart login");
81
+ const saved = JSON.parse(savedRaw);
82
+ if (params.get("state") !== saved.state) throw new Error("state mismatch; aborting");
83
+ const tokenResp = await fetch(saved.tokenEndpoint, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
86
+ body: new URLSearchParams({
87
+ grant_type: "authorization_code",
88
+ code,
89
+ redirect_uri: redirectUri(),
90
+ client_id: saved.clientId,
91
+ code_verifier: saved.verifier
92
+ })
93
+ });
94
+ if (!tokenResp.ok) {
95
+ throw new Error(`IdP token endpoint ${tokenResp.status}: ${await tokenResp.text()}`);
96
+ }
97
+ const idpToken = await tokenResp.json();
98
+ const exResp = await fetch(saved.exchangeEndpoint, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
101
+ body: new URLSearchParams({
102
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
103
+ subject_token: idpToken.access_token,
104
+ subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
105
+ })
106
+ });
107
+ if (!exResp.ok) throw new Error(`/auth/exchange ${exResp.status}: ${await exResp.text()}`);
108
+ const minted = await exResp.json();
109
+ return {
110
+ accessToken: minted.access_token,
111
+ organizationId: minted.organization_id,
112
+ idpClaims: decodeJwtClaims(idpToken.access_token)
113
+ };
114
+ }
115
+ function resetPending() {
116
+ pending = null;
117
+ }
118
+
119
+ // src/tokenStore.ts
120
+ var currentToken;
121
+ function setAccessToken(token) {
122
+ currentToken = token;
123
+ }
124
+ function getAccessToken() {
125
+ return currentToken;
126
+ }
127
+ var ConfigHubAuthContext = createContext(null);
128
+ function ConfigHubAuthProvider({
129
+ baseUrl,
130
+ clientId,
131
+ children
132
+ }) {
133
+ const [status, setStatus] = useState("loading");
134
+ const [user, setUser] = useState(null);
135
+ const [error, setError] = useState(null);
136
+ const tokenRef = useRef(void 0);
137
+ const applySession = useCallback((session) => {
138
+ tokenRef.current = session.accessToken;
139
+ setAccessToken(session.accessToken);
140
+ setUser({ organizationId: session.organizationId, idpClaims: session.idpClaims });
141
+ setError(null);
142
+ setStatus("authenticated");
143
+ }, []);
144
+ useEffect(() => {
145
+ let cancelled = false;
146
+ completeLoginFromRedirect().then((session) => {
147
+ if (cancelled) return;
148
+ if (session) applySession(session);
149
+ else setStatus("unauthenticated");
150
+ }).catch((e) => {
151
+ if (cancelled) return;
152
+ setError(e instanceof Error ? e : new Error(String(e)));
153
+ setStatus("error");
154
+ });
155
+ return () => {
156
+ cancelled = true;
157
+ };
158
+ }, [applySession]);
159
+ const login = useCallback(async () => {
160
+ setError(null);
161
+ try {
162
+ await startLogin(baseUrl, clientId);
163
+ } catch (e) {
164
+ setError(e instanceof Error ? e : new Error(String(e)));
165
+ setStatus("error");
166
+ }
167
+ }, [baseUrl, clientId]);
168
+ const logout = useCallback(() => {
169
+ tokenRef.current = void 0;
170
+ setAccessToken(void 0);
171
+ resetPending();
172
+ setUser(null);
173
+ setStatus("unauthenticated");
174
+ }, []);
175
+ const getToken = useCallback(() => tokenRef.current, []);
176
+ const client = useMemo(
177
+ () => createConfigHubClient({
178
+ baseUrl,
179
+ getToken,
180
+ onUnauthorized: () => {
181
+ logout();
182
+ }
183
+ }),
184
+ [baseUrl, getToken, logout]
185
+ );
186
+ const value = useMemo(
187
+ () => ({ status, user, error, login, logout, getToken, client }),
188
+ [status, user, error, login, logout, getToken, client]
189
+ );
190
+ return /* @__PURE__ */ jsx(ConfigHubAuthContext.Provider, { value, children });
191
+ }
192
+ function useAuth() {
193
+ const ctx = useContext(ConfigHubAuthContext);
194
+ if (!ctx) {
195
+ throw new Error("useAuth must be used within a <ConfigHubAuthProvider>");
196
+ }
197
+ return ctx;
198
+ }
199
+ function useConfigHub() {
200
+ return useAuth().client;
201
+ }
202
+
203
+ export { ConfigHubAuthContext, ConfigHubAuthProvider, getAccessToken, useAuth, useConfigHub };
204
+ //# sourceMappingURL=index.js.map
205
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core.ts","../src/tokenStore.ts","../src/provider.tsx","../src/hooks.ts"],"names":[],"mappings":";;;;;;;AAwCA,IAAM,QAAA,GAAW,gBAAA;AAEjB,IAAM,cAAc,MAAc,MAAA,CAAO,QAAA,CAAS,MAAA,GAAS,OAAO,QAAA,CAAS,QAAA;AAE3E,IAAM,YAAY,CAAC,CAAA,KAAsB,CAAA,CAAE,OAAA,CAAQ,QAAQ,EAAE,CAAA;AAE7D,IAAM,MAAA,GAAS,CAAC,GAAA,KACd,IAAA,CAAK,OAAO,YAAA,CAAa,GAAG,IAAI,UAAA,CAAW,GAAG,CAAC,CAAC,CAAA,CAC7C,OAAA,CAAQ,KAAA,EAAO,GAAG,CAAA,CAClB,OAAA,CAAQ,OAAO,GAAG,CAAA,CAClB,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AAEtB,IAAM,YAAA,GAAe,CAAC,CAAA,GAAI,EAAA,KACxB,MAAA,CAAO,MAAA,CAAO,eAAA,CAAgB,IAAI,UAAA,CAAW,CAAC,CAAC,CAAA,CAAE,MAAM,CAAA;AAEzD,eAAe,OAAO,CAAA,EAA4B;AAChD,EAAA,OAAO,MAAA,CAAO,MAAM,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,SAAA,EAAW,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,CAAC,CAAC,CAAC,CAAA;AAClF;AAEA,SAAS,gBAAgB,KAAA,EAAwC;AAC/D,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,KAAA,CAAM,GAAG,EAAE,CAAC,CAAA;AAC/B,EAAA,IAAI,CAAC,IAAA,EAAM,OAAO,EAAC;AACnB,EAAA,OAAO,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,IAAA,EAAM,GAAG,CAAA,CAAE,OAAA,CAAQ,IAAA,EAAM,GAAG,CAAC,CAAC,CAAA;AACpE;AAEA,eAAsB,SAAS,IAAA,EAAkC;AAC/D,EAAA,MAAM,IAAI,MAAM,KAAA,CAAM,SAAA,CAAU,IAAI,IAAI,WAAW,CAAA;AACnD,EAAA,IAAI,CAAC,EAAE,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,oBAAA,GAAuB,EAAE,MAAM,CAAA;AAC1D,EAAA,OAAO,EAAE,IAAA,EAAK;AAChB;AAEA,eAAe,aACb,MAAA,EACqE;AACrE,EAAA,MAAM,IAAI,MAAM,KAAA,CAAM,SAAA,CAAU,MAAM,IAAI,mCAAmC,CAAA;AAC7E,EAAA,IAAI,CAAC,EAAE,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,yBAAA,GAA4B,EAAE,MAAM,CAAA;AAC/D,EAAA,OAAO,EAAE,IAAA,EAAK;AAChB;AAOA,eAAsB,UAAA,CAAW,MAAc,QAAA,EAAiC;AAC9E,EAAA,MAAM,IAAA,GAAO,MAAM,QAAA,CAAS,IAAI,CAAA;AAChC,EAAA,IAAI,CAAC,IAAA,CAAK,UAAA,IAAc,CAAC,KAAK,qBAAA,EAAuB;AACnD,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,MAAM,IAAA,GAAO,MAAM,YAAA,CAAa,IAAA,CAAK,UAAU,CAAA;AAC/C,EAAA,MAAM,WAAW,YAAA,EAAa;AAC9B,EAAA,MAAM,SAAA,GAAY,MAAM,MAAA,CAAO,QAAQ,CAAA;AACvC,EAAA,MAAM,KAAA,GAAQ,aAAa,EAAE,CAAA;AAC7B,EAAA,MAAM,IAAA,GAAkB;AAAA,IACtB,QAAA;AAAA,IACA,KAAA;AAAA,IACA,QAAA;AAAA,IACA,eAAe,IAAA,CAAK,cAAA;AAAA,IACpB,kBAAkB,IAAA,CAAK;AAAA,GACzB;AACA,EAAA,cAAA,CAAe,OAAA,CAAQ,QAAA,EAAU,IAAA,CAAK,SAAA,CAAU,IAAI,CAAC,CAAA;AAErD,EAAA,MAAM,OAAA,GAAU,IAAI,GAAA,CAAI,IAAA,CAAK,sBAAsB,CAAA;AACnD,EAAA,OAAA,CAAQ,MAAA,GAAS,IAAI,eAAA,CAAgB;AAAA,IACnC,aAAA,EAAe,MAAA;AAAA,IACf,SAAA,EAAW,QAAA;AAAA,IACX,cAAc,WAAA,EAAY;AAAA;AAAA,IAE1B,KAAA,EAAO,mCAAA;AAAA,IACP,cAAA,EAAgB,SAAA;AAAA,IAChB,qBAAA,EAAuB,MAAA;AAAA,IACvB;AAAA,GACD,EAAE,QAAA,EAAS;AACZ,EAAA,MAAA,CAAO,QAAA,CAAS,MAAA,CAAO,OAAA,CAAQ,QAAA,EAAU,CAAA;AAC3C;AAGA,IAAI,OAAA,GAAgD,IAAA;AAM7C,SAAS,yBAAA,GAA2D;AACzE,EAAA,IAAI,CAAC,OAAA,EAAS,OAAA,GAAU,eAAA,EAAgB;AACxC,EAAA,OAAO,OAAA;AACT;AAEA,eAAe,eAAA,GAAiD;AAC9D,EAAA,MAAM,MAAA,GAAS,IAAI,eAAA,CAAgB,MAAA,CAAO,SAAS,MAAM,CAAA;AACzD,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,GAAA,CAAI,MAAM,CAAA;AAC9B,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,OAAO,CAAA;AAChC,EAAA,IAAI,KAAA,EAAO;AACT,IAAA,OAAA,CAAQ,YAAA,CAAa,EAAC,EAAG,EAAA,EAAI,aAAa,CAAA;AAC1C,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,oBAAA,EAAuB,KAAK,CAAA,CAAA,EAAI,OAAO,GAAA,CAAI,mBAAmB,CAAA,IAAK,EAAE,CAAA,CAAE,CAAA;AAAA,EACzF;AACA,EAAA,IAAI,CAAC,MAAM,OAAO,IAAA;AAElB,EAAA,MAAM,QAAA,GAAW,cAAA,CAAe,OAAA,CAAQ,QAAQ,CAAA;AAChD,EAAA,cAAA,CAAe,WAAW,QAAQ,CAAA;AAClC,EAAA,OAAA,CAAQ,YAAA,CAAa,EAAC,EAAG,EAAA,EAAI,aAAa,CAAA;AAC1C,EAAA,IAAI,CAAC,QAAA,EAAU,MAAM,IAAI,MAAM,8BAA8B,CAAA;AAC7D,EAAA,MAAM,KAAA,GAAmB,IAAA,CAAK,KAAA,CAAM,QAAQ,CAAA;AAC5C,EAAA,IAAI,MAAA,CAAO,IAAI,OAAO,CAAA,KAAM,MAAM,KAAA,EAAO,MAAM,IAAI,KAAA,CAAM,0BAA0B,CAAA;AAGnF,EAAA,MAAM,SAAA,GAAY,MAAM,KAAA,CAAM,KAAA,CAAM,aAAA,EAAe;AAAA,IACjD,MAAA,EAAQ,MAAA;AAAA,IACR,OAAA,EAAS,EAAE,cAAA,EAAgB,mCAAA,EAAoC;AAAA,IAC/D,IAAA,EAAM,IAAI,eAAA,CAAgB;AAAA,MACxB,UAAA,EAAY,oBAAA;AAAA,MACZ,IAAA;AAAA,MACA,cAAc,WAAA,EAAY;AAAA,MAC1B,WAAW,KAAA,CAAM,QAAA;AAAA,MACjB,eAAe,KAAA,CAAM;AAAA,KACtB;AAAA,GACF,CAAA;AACD,EAAA,IAAI,CAAC,UAAU,EAAA,EAAI;AACjB,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mBAAA,EAAsB,SAAA,CAAU,MAAM,KAAK,MAAM,SAAA,CAAU,IAAA,EAAM,CAAA,CAAE,CAAA;AAAA,EACrF;AACA,EAAA,MAAM,QAAA,GAAW,MAAM,SAAA,CAAU,IAAA,EAAK;AAGtC,EAAA,MAAM,MAAA,GAAS,MAAM,KAAA,CAAM,KAAA,CAAM,gBAAA,EAAkB;AAAA,IACjD,MAAA,EAAQ,MAAA;AAAA,IACR,OAAA,EAAS,EAAE,cAAA,EAAgB,mCAAA,EAAoC;AAAA,IAC/D,IAAA,EAAM,IAAI,eAAA,CAAgB;AAAA,MACxB,UAAA,EAAY,iDAAA;AAAA,MACZ,eAAe,QAAA,CAAS,YAAA;AAAA,MACxB,kBAAA,EAAoB;AAAA,KACrB;AAAA,GACF,CAAA;AACD,EAAA,IAAI,CAAC,MAAA,CAAO,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,CAAA,eAAA,EAAkB,MAAA,CAAO,MAAM,CAAA,EAAA,EAAK,MAAM,MAAA,CAAO,IAAA,EAAM,CAAA,CAAE,CAAA;AACzF,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,CAAO,IAAA,EAAK;AAEjC,EAAA,OAAO;AAAA,IACL,aAAa,MAAA,CAAO,YAAA;AAAA,IACpB,gBAAgB,MAAA,CAAO,eAAA;AAAA,IACvB,SAAA,EAAW,eAAA,CAAgB,QAAA,CAAS,YAAY;AAAA,GAClD;AACF;AAGO,SAAS,YAAA,GAAqB;AACnC,EAAA,OAAA,GAAU,IAAA;AACZ;;;ACpLA,IAAI,YAAA;AAGG,SAAS,eAAe,KAAA,EAAiC;AAC9D,EAAA,YAAA,GAAe,KAAA;AACjB;AAOO,SAAS,cAAA,GAAqC;AACnD,EAAA,OAAO,YAAA;AACT;ACqBO,IAAM,oBAAA,GAAuB,cAAgD,IAAI;AAejF,SAAS,qBAAA,CAAsB;AAAA,EACpC,OAAA;AAAA,EACA,QAAA;AAAA,EACA;AACF,CAAA,EAA4C;AAC1C,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAI,SAAqB,SAAS,CAAA;AAC1D,EAAA,MAAM,CAAC,IAAA,EAAM,OAAO,CAAA,GAAI,SAA+B,IAAI,CAAA;AAC3D,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAI,SAAuB,IAAI,CAAA;AAIrD,EAAA,MAAM,QAAA,GAAW,OAA2B,MAAS,CAAA;AAErD,EAAA,MAAM,YAAA,GAAe,WAAA,CAAY,CAAC,OAAA,KAA2B;AAC3D,IAAA,QAAA,CAAS,UAAU,OAAA,CAAQ,WAAA;AAC3B,IAAA,cAAA,CAAe,QAAQ,WAAW,CAAA;AAClC,IAAA,OAAA,CAAQ,EAAE,cAAA,EAAgB,OAAA,CAAQ,gBAAgB,SAAA,EAAW,OAAA,CAAQ,WAAW,CAAA;AAChF,IAAA,QAAA,CAAS,IAAI,CAAA;AACb,IAAA,SAAA,CAAU,eAAe,CAAA;AAAA,EAC3B,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,IAAI,SAAA,GAAY,KAAA;AAChB,IAAA,yBAAA,EAA0B,CACvB,IAAA,CAAK,CAAC,OAAA,KAAY;AACjB,MAAA,IAAI,SAAA,EAAW;AACf,MAAA,IAAI,OAAA,eAAsB,OAAO,CAAA;AAAA,qBAClB,iBAAiB,CAAA;AAAA,IAClC,CAAC,CAAA,CACA,KAAA,CAAM,CAAC,CAAA,KAAe;AACrB,MAAA,IAAI,SAAA,EAAW;AACf,MAAA,QAAA,CAAS,CAAA,YAAa,QAAQ,CAAA,GAAI,IAAI,MAAM,MAAA,CAAO,CAAC,CAAC,CAAC,CAAA;AACtD,MAAA,SAAA,CAAU,OAAO,CAAA;AAAA,IACnB,CAAC,CAAA;AACH,IAAA,OAAO,MAAM;AACX,MAAA,SAAA,GAAY,IAAA;AAAA,IACd,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,YAAY,CAAC,CAAA;AAEjB,EAAA,MAAM,KAAA,GAAQ,YAAY,YAAY;AACpC,IAAA,QAAA,CAAS,IAAI,CAAA;AACb,IAAA,IAAI;AACF,MAAA,MAAM,UAAA,CAAW,SAAS,QAAQ,CAAA;AAAA,IACpC,SAAS,CAAA,EAAY;AACnB,MAAA,QAAA,CAAS,CAAA,YAAa,QAAQ,CAAA,GAAI,IAAI,MAAM,MAAA,CAAO,CAAC,CAAC,CAAC,CAAA;AACtD,MAAA,SAAA,CAAU,OAAO,CAAA;AAAA,IACnB;AAAA,EACF,CAAA,EAAG,CAAC,OAAA,EAAS,QAAQ,CAAC,CAAA;AAEtB,EAAA,MAAM,MAAA,GAAS,YAAY,MAAM;AAC/B,IAAA,QAAA,CAAS,OAAA,GAAU,MAAA;AACnB,IAAA,cAAA,CAAe,MAAS,CAAA;AACxB,IAAA,YAAA,EAAa;AACb,IAAA,OAAA,CAAQ,IAAI,CAAA;AACZ,IAAA,SAAA,CAAU,iBAAiB,CAAA;AAAA,EAC7B,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,WAAW,WAAA,CAAY,MAAM,QAAA,CAAS,OAAA,EAAS,EAAE,CAAA;AAIvD,EAAA,MAAM,MAAA,GAAS,OAAA;AAAA,IACb,MACE,qBAAA,CAAsB;AAAA,MACpB,OAAA;AAAA,MACA,QAAA;AAAA,MACA,gBAAgB,MAAM;AACpB,QAAA,MAAA,EAAO;AAAA,MACT;AAAA,KACD,CAAA;AAAA,IACH,CAAC,OAAA,EAAS,QAAA,EAAU,MAAM;AAAA,GAC5B;AAEA,EAAA,MAAM,KAAA,GAAQ,OAAA;AAAA,IACZ,OAAO,EAAE,MAAA,EAAQ,IAAA,EAAM,OAAO,KAAA,EAAO,MAAA,EAAQ,UAAU,MAAA,EAAO,CAAA;AAAA,IAC9D,CAAC,MAAA,EAAQ,IAAA,EAAM,OAAO,KAAA,EAAO,MAAA,EAAQ,UAAU,MAAM;AAAA,GACvD;AAEA,EAAA,uBACE,GAAA,CAAC,oBAAA,CAAqB,QAAA,EAArB,EAA8B,OAAe,QAAA,EAAS,CAAA;AAE3D;AC3HO,SAAS,OAAA,GAAqC;AACnD,EAAA,MAAM,GAAA,GAAM,WAAW,oBAAoB,CAAA;AAC3C,EAAA,IAAI,CAAC,GAAA,EAAK;AACR,IAAA,MAAM,IAAI,MAAM,uDAAuD,CAAA;AAAA,EACzE;AACA,EAAA,OAAO,GAAA;AACT;AAYO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,SAAQ,CAAE,MAAA;AACnB","file":"index.js","sourcesContent":["// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n//\n// Framework-neutral browser-auth engine for ConfigHub. Productionized from the\n// reference harness `test/browser-auth/src/confighubAuth.ts` in the ConfigHub\n// monorepo, which is validated end to end against staging and prod.\n//\n// Flow (design: third-party-browser-app-auth.md §6):\n// GET {base}/api/info -> discovery { AuthIssuer, TokenExchangeEndpoint }\n// OIDC discovery on AuthIssuer -> authorize/token endpoints\n// PKCE authorize + code->token -> IdP token\n// POST {TokenExchangeEndpoint} (8693) -> minted ConfigHub token\n//\n// The minted token then rides `Authorization: Bearer` against `/api`. The flow is\n// edition-agnostic: `AuthIssuer` is whatever discovery names (ConfigHub's bundled\n// Keycloak for Cloud, the org's own IdP for Enterprise), so the same code runs\n// against both. Tokens are held in memory by the caller; only the transient PKCE\n// verifier is parked in sessionStorage across the authorize redirect.\n\nexport interface Discovery {\n AuthIssuer?: string;\n TokenExchangeEndpoint?: string;\n TokenExchangeAudience?: string;\n}\n\nexport interface MintedSession {\n accessToken: string;\n organizationId: string;\n /** Claims of the validated IdP token (owning-org, audience, organization shape). */\n idpClaims: Record<string, unknown>;\n}\n\ninterface PkceState {\n verifier: string;\n state: string;\n clientId: string;\n tokenEndpoint: string;\n exchangeEndpoint: string;\n}\n\nconst PKCE_KEY = 'confighub_pkce';\n\nconst redirectUri = (): string => window.location.origin + window.location.pathname;\n\nconst trimSlash = (s: string): string => s.replace(/\\/+$/, '');\n\nconst b64url = (buf: ArrayBuffer): string =>\n btoa(String.fromCharCode(...new Uint8Array(buf)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\nconst randomString = (n = 64): string =>\n b64url(crypto.getRandomValues(new Uint8Array(n)).buffer);\n\nasync function sha256(s: string): Promise<string> {\n return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));\n}\n\nfunction decodeJwtClaims(token: string): Record<string, unknown> {\n const part = token.split('.')[1];\n if (!part) return {};\n return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));\n}\n\nexport async function discover(base: string): Promise<Discovery> {\n const r = await fetch(trimSlash(base) + '/api/info');\n if (!r.ok) throw new Error('/api/info failed: ' + r.status);\n return r.json();\n}\n\nasync function oidcMetadata(\n issuer: string,\n): Promise<{ authorization_endpoint: string; token_endpoint: string }> {\n const r = await fetch(trimSlash(issuer) + '/.well-known/openid-configuration');\n if (!r.ok) throw new Error('OIDC discovery failed: ' + r.status);\n return r.json();\n}\n\n/**\n * Discover, build a PKCE request, and navigate to the IdP authorize endpoint.\n * Returns only by redirecting the page; `completeLoginFromRedirect()` finishes on\n * the way back.\n */\nexport async function startLogin(base: string, clientId: string): Promise<void> {\n const info = await discover(base);\n if (!info.AuthIssuer || !info.TokenExchangeEndpoint) {\n throw new Error(\n 'this instance is not configured for token-exchange auth (server needs CONFIGHUB_IDP_ISSUER)',\n );\n }\n const meta = await oidcMetadata(info.AuthIssuer);\n const verifier = randomString();\n const challenge = await sha256(verifier);\n const state = randomString(16);\n const pkce: PkceState = {\n verifier,\n state,\n clientId,\n tokenEndpoint: meta.token_endpoint,\n exchangeEndpoint: info.TokenExchangeEndpoint,\n };\n sessionStorage.setItem(PKCE_KEY, JSON.stringify(pkce));\n\n const authURL = new URL(meta.authorization_endpoint);\n authURL.search = new URLSearchParams({\n response_type: 'code',\n client_id: clientId,\n redirect_uri: redirectUri(),\n // The \"organization\" scope makes Keycloak emit the org claim the exchange resolves.\n scope: 'openid email profile organization',\n code_challenge: challenge,\n code_challenge_method: 'S256',\n state,\n }).toString();\n window.location.assign(authURL.toString());\n}\n\n// Memoize so React StrictMode's double-mount can't redeem the one-time code twice.\nlet pending: Promise<MintedSession | null> | null = null;\n\n/**\n * If the page is the IdP redirect (`?code=...`), exchange the code for an IdP token\n * and then exchange that for a minted ConfigHub token. Returns null on a normal load.\n */\nexport function completeLoginFromRedirect(): Promise<MintedSession | null> {\n if (!pending) pending = doCompleteLogin();\n return pending;\n}\n\nasync function doCompleteLogin(): Promise<MintedSession | null> {\n const params = new URLSearchParams(window.location.search);\n const code = params.get('code');\n const error = params.get('error');\n if (error) {\n history.replaceState({}, '', redirectUri());\n throw new Error(`IdP returned error: ${error} ${params.get('error_description') ?? ''}`);\n }\n if (!code) return null;\n\n const savedRaw = sessionStorage.getItem(PKCE_KEY);\n sessionStorage.removeItem(PKCE_KEY);\n history.replaceState({}, '', redirectUri()); // strip ?code= from the URL\n if (!savedRaw) throw new Error('no PKCE state; restart login');\n const saved: PkceState = JSON.parse(savedRaw);\n if (params.get('state') !== saved.state) throw new Error('state mismatch; aborting');\n\n // Exchange the authorization code for an IdP token (PKCE, public client).\n const tokenResp = await fetch(saved.tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: redirectUri(),\n client_id: saved.clientId,\n code_verifier: saved.verifier,\n }),\n });\n if (!tokenResp.ok) {\n throw new Error(`IdP token endpoint ${tokenResp.status}: ${await tokenResp.text()}`);\n }\n const idpToken = await tokenResp.json();\n\n // RFC 8693 token exchange against ConfigHub -> minted ConfigHub token.\n const exResp = await fetch(saved.exchangeEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',\n subject_token: idpToken.access_token,\n subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',\n }),\n });\n if (!exResp.ok) throw new Error(`/auth/exchange ${exResp.status}: ${await exResp.text()}`);\n const minted = await exResp.json();\n\n return {\n accessToken: minted.access_token,\n organizationId: minted.organization_id,\n idpClaims: decodeJwtClaims(idpToken.access_token),\n };\n}\n\n/** Discard the in-progress login memo (used on logout so a later login re-runs). */\nexport function resetPending(): void {\n pending = null;\n}\n","// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n\n// A module-level holder for the current minted token, so non-React consumers can read\n// it. RTK Query's `prepareHeaders` (in @confighub/rtk-query) is not a hook and cannot\n// read React context, so it calls getAccessToken() instead. The provider keeps this in\n// sync with its React state.\nlet currentToken: string | undefined;\n\n/** @internal — called by the provider; not part of the public surface. */\nexport function setAccessToken(token: string | undefined): void {\n currentToken = token;\n}\n\n/**\n * The current minted ConfigHub token, or undefined when unauthenticated. Pass this as\n * the `getToken` for `@confighub/rtk-query`'s `configureConfigHub`, or read it anywhere\n * you need the token outside React.\n */\nexport function getAccessToken(): string | undefined {\n return currentToken;\n}\n","// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n\nimport { createConfigHubClient, type ConfigHubClient } from '@confighub/api';\nimport {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n completeLoginFromRedirect,\n resetPending,\n startLogin,\n type MintedSession,\n} from './core';\nimport { setAccessToken } from './tokenStore';\n\nexport type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'error';\n\nexport interface ConfigHubUser {\n organizationId: string;\n idpClaims: Record<string, unknown>;\n}\n\nexport interface ConfigHubAuthContextValue {\n status: AuthStatus;\n user: ConfigHubUser | null;\n error: Error | null;\n /** Begin login: redirects the page to the IdP. */\n login: () => Promise<void>;\n /** Clear the in-memory session. Does not call the IdP end-session endpoint. */\n logout: () => void;\n /** Current bearer token, or undefined when unauthenticated. */\n getToken: () => string | undefined;\n /** A typed API client pre-wired with the current token. Stable across renders. */\n client: ConfigHubClient;\n}\n\nexport const ConfigHubAuthContext = createContext<ConfigHubAuthContextValue | null>(null);\n\nexport interface ConfigHubAuthProviderProps {\n /** Absolute base URL of the ConfigHub instance, e.g. `https://hub.confighub.com`. */\n baseUrl: string;\n /** This app's registered OAuth `client_id` (from `cub oauthclient create`). */\n clientId: string;\n children: ReactNode;\n}\n\n/**\n * Runs the browser-direct auth flow and manages the token lifecycle. On mount it\n * completes a redirect if the page is the IdP callback; otherwise it starts\n * unauthenticated until `login()` is called.\n */\nexport function ConfigHubAuthProvider({\n baseUrl,\n clientId,\n children,\n}: ConfigHubAuthProviderProps): JSX.Element {\n const [status, setStatus] = useState<AuthStatus>('loading');\n const [user, setUser] = useState<ConfigHubUser | null>(null);\n const [error, setError] = useState<Error | null>(null);\n\n // The token lives in a ref so getToken() reads the latest value synchronously\n // without re-creating the API client on every render.\n const tokenRef = useRef<string | undefined>(undefined);\n\n const applySession = useCallback((session: MintedSession) => {\n tokenRef.current = session.accessToken;\n setAccessToken(session.accessToken); // keep the non-React accessor in sync (rtk-query)\n setUser({ organizationId: session.organizationId, idpClaims: session.idpClaims });\n setError(null);\n setStatus('authenticated');\n }, []);\n\n useEffect(() => {\n let cancelled = false;\n completeLoginFromRedirect()\n .then((session) => {\n if (cancelled) return;\n if (session) applySession(session);\n else setStatus('unauthenticated');\n })\n .catch((e: unknown) => {\n if (cancelled) return;\n setError(e instanceof Error ? e : new Error(String(e)));\n setStatus('error');\n });\n return () => {\n cancelled = true;\n };\n }, [applySession]);\n\n const login = useCallback(async () => {\n setError(null);\n try {\n await startLogin(baseUrl, clientId);\n } catch (e: unknown) {\n setError(e instanceof Error ? e : new Error(String(e)));\n setStatus('error');\n }\n }, [baseUrl, clientId]);\n\n const logout = useCallback(() => {\n tokenRef.current = undefined;\n setAccessToken(undefined);\n resetPending();\n setUser(null);\n setStatus('unauthenticated');\n }, []);\n\n const getToken = useCallback(() => tokenRef.current, []);\n\n // One client for the provider's lifetime. getToken reads tokenRef, and a 401\n // routes back to login() so an expired session re-authenticates.\n const client = useMemo(\n () =>\n createConfigHubClient({\n baseUrl,\n getToken,\n onUnauthorized: () => {\n logout();\n },\n }),\n [baseUrl, getToken, logout],\n );\n\n const value = useMemo<ConfigHubAuthContextValue>(\n () => ({ status, user, error, login, logout, getToken, client }),\n [status, user, error, login, logout, getToken, client],\n );\n\n return (\n <ConfigHubAuthContext.Provider value={value}>{children}</ConfigHubAuthContext.Provider>\n );\n}\n","// Copyright (C) ConfigHub, Inc.\n// SPDX-License-Identifier: MIT\n\nimport type { ConfigHubClient } from '@confighub/api';\nimport { useContext } from 'react';\nimport { ConfigHubAuthContext, type ConfigHubAuthContextValue } from './provider';\n\n/**\n * Access the ConfigHub auth state and actions. Must be called under a\n * `<ConfigHubAuthProvider>`.\n *\n * ```ts\n * const { status, user, login, logout } = useAuth();\n * ```\n */\nexport function useAuth(): ConfigHubAuthContextValue {\n const ctx = useContext(ConfigHubAuthContext);\n if (!ctx) {\n throw new Error('useAuth must be used within a <ConfigHubAuthProvider>');\n }\n return ctx;\n}\n\n/**\n * The typed ConfigHub API client, pre-wired with the current token. This is the\n * seam between `@confighub/react-auth` and `@confighub/api`: you never pass a\n * token by hand.\n *\n * ```ts\n * const api = useConfigHub();\n * const { data } = await api.GET('/space/{space_id}/unit', { params: { path: { space_id } } });\n * ```\n */\nexport function useConfigHub(): ConfigHubClient {\n return useAuth().client;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@confighub/react-auth",
3
+ "version": "0.1.0",
4
+ "description": "React auth provider and hooks for ConfigHub browser apps (OIDC PKCE + RFC 8693 token exchange)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/confighub/js-sdk.git",
9
+ "directory": "packages/react-auth"
10
+ },
11
+ "homepage": "https://github.com/confighub/js-sdk/tree/main/packages/react-auth#readme",
12
+ "bugs": "https://github.com/confighub/js-sdk/issues",
13
+ "type": "module",
14
+ "main": "./dist/index.cjs",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "import": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "require": {
24
+ "types": "./dist/index.d.cts",
25
+ "default": "./dist/index.cjs"
26
+ }
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "sideEffects": false,
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "@confighub/api": "^0.1.0"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^18.0.0 || ^19.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/react": "^18.3.12",
49
+ "react": "^18.3.1"
50
+ },
51
+ "keywords": [
52
+ "confighub",
53
+ "oauth",
54
+ "oidc",
55
+ "pkce",
56
+ "react",
57
+ "auth"
58
+ ],
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }