@confighub/react-auth 0.1.2 → 0.2.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/README.md +38 -8
- package/dist/index.cjs +203 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +104 -19
- package/dist/index.d.ts +104 -19
- package/dist/index.js +202 -59
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -29,20 +29,50 @@ function App() {
|
|
|
29
29
|
|
|
30
30
|
- `baseUrl` — the ConfigHub instance, e.g. `https://hub.confighub.com`.
|
|
31
31
|
- `clientId` — this app's registered OAuth client id, from
|
|
32
|
-
`cub oauthclient create <name> --redirect-uri <origin
|
|
32
|
+
`cub oauthclient create <name> --redirect-uri <origin>/`.
|
|
33
|
+
- `callbackPath` (default `/`) — the IdP redirects back to `{origin}{callbackPath}`,
|
|
34
|
+
which is the redirect URI to register. It is fixed on purpose: the page the user
|
|
35
|
+
started from travels in the PKCE `state` and is restored on return, so a login
|
|
36
|
+
from `/space/x?tab=units` lands back there without registering every path.
|
|
37
|
+
- `persist` (default `'none'`) — `'session'` keeps the session in `sessionStorage`,
|
|
38
|
+
so a reload or in-tab navigation does not round-trip through the IdP. Tab-scoped,
|
|
39
|
+
gone when the tab closes, dropped when the token has expired.
|
|
40
|
+
- `onUnauthorized` (default `'login'`) — what a 401 from the API means. `'login'`
|
|
41
|
+
tries a silent re-authentication; `'logout'` just drops the session.
|
|
33
42
|
|
|
34
43
|
The IdP issuer and OIDC endpoints are discovered from `{baseUrl}/api/info`, so the
|
|
35
44
|
same build runs against any ConfigHub instance (the bundled Keycloak for Cloud, an
|
|
36
45
|
organization's own IdP for Enterprise).
|
|
37
46
|
|
|
38
|
-
##
|
|
47
|
+
## `useAuth()`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const { status, user, error, login, logout, switchOrganization, reauthenticate, getToken } = useAuth();
|
|
51
|
+
```
|
|
39
52
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
the
|
|
53
|
+
- `login(options?)` — redirects to the IdP. `returnTo` picks the landing path
|
|
54
|
+
(default: the current one). `organization` is a Keycloak organization alias, sent
|
|
55
|
+
as the `organization:<alias>` scope so a multi-org user is not prompted; without
|
|
56
|
+
it Keycloak prompts. `prompt: 'none' | 'login'` is passed through.
|
|
57
|
+
- `logout(options?)` — forgets the session in this tab. `endSession: true` also ends
|
|
58
|
+
the IdP session (RP-initiated logout with `id_token_hint`), landing on
|
|
59
|
+
`postLogoutRedirectUri` (default: the callback URI), which must be registered
|
|
60
|
+
for the client. Without it the next login rides the SSO cookie silently.
|
|
61
|
+
- `switchOrganization(organizationId)` — `POST /auth/switch-organization` with the
|
|
62
|
+
bearer token, re-minting for another org the user belongs to. Requires a server
|
|
63
|
+
that offers the bearer form; a fresh `login()` with no organization hint is the
|
|
64
|
+
portable alternative, since the IdP then prompts for the organization.
|
|
65
|
+
- `reauthenticate()` — the token stopped working: a `prompt=none` round trip for the
|
|
66
|
+
organization the session already had. Status is `loading` meanwhile, not
|
|
67
|
+
`unauthenticated`, so an app that auto-logs-in on `unauthenticated` does not race
|
|
68
|
+
it. If the IdP session is gone too, the page comes back `unauthenticated`.
|
|
69
|
+
|
|
70
|
+
## Token posture
|
|
43
71
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
72
|
+
The minted token is kept in memory by default; opt in to `sessionStorage` with
|
|
73
|
+
`persist: 'session'`. Never `localStorage`. The transient PKCE state is parked in
|
|
74
|
+
`sessionStorage` across the authorize redirect. A 401 triggers a silent
|
|
75
|
+
re-authentication (see `onUnauthorized`), which needs a live IdP session; refresh
|
|
76
|
+
tokens are not used.
|
|
47
77
|
|
|
48
78
|
`react` (18 or 19) is a peer dependency.
|
package/dist/index.cjs
CHANGED
|
@@ -8,8 +8,9 @@ var jsxRuntime = require('react/jsx-runtime');
|
|
|
8
8
|
|
|
9
9
|
// src/core.ts
|
|
10
10
|
var PKCE_KEY = "confighub_pkce";
|
|
11
|
-
var redirectUri = () => window.location.origin + window.location.pathname;
|
|
12
11
|
var trimSlash = (s) => s.replace(/\/+$/, "");
|
|
12
|
+
var callbackUri = (opts) => window.location.origin + (opts?.callbackPath ?? "/");
|
|
13
|
+
var currentPath = () => window.location.pathname + window.location.search + window.location.hash;
|
|
13
14
|
var b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
14
15
|
var randomString = (n = 64) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer);
|
|
15
16
|
async function sha256(s) {
|
|
@@ -18,7 +19,16 @@ async function sha256(s) {
|
|
|
18
19
|
function decodeJwtClaims(token) {
|
|
19
20
|
const part = token.split(".")[1];
|
|
20
21
|
if (!part) return {};
|
|
21
|
-
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/")));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isExpired(token, skewSeconds = 30) {
|
|
29
|
+
const exp = decodeJwtClaims(token).exp;
|
|
30
|
+
if (typeof exp !== "number") return false;
|
|
31
|
+
return exp * 1e3 <= Date.now() + skewSeconds * 1e3;
|
|
22
32
|
}
|
|
23
33
|
async function discover(base) {
|
|
24
34
|
const r = await fetch(trimSlash(base) + "/api/info");
|
|
@@ -30,7 +40,7 @@ async function oidcMetadata(issuer) {
|
|
|
30
40
|
if (!r.ok) throw new Error("OIDC discovery failed: " + r.status);
|
|
31
41
|
return r.json();
|
|
32
42
|
}
|
|
33
|
-
async function startLogin(base, clientId) {
|
|
43
|
+
async function startLogin(base, clientId, login = {}, flow = {}) {
|
|
34
44
|
const info = await discover(base);
|
|
35
45
|
if (!info.AuthIssuer || !info.TokenExchangeEndpoint) {
|
|
36
46
|
throw new Error(
|
|
@@ -41,25 +51,31 @@ async function startLogin(base, clientId) {
|
|
|
41
51
|
const verifier = randomString();
|
|
42
52
|
const challenge = await sha256(verifier);
|
|
43
53
|
const state = randomString(16);
|
|
54
|
+
const redirectUri = callbackUri(flow);
|
|
44
55
|
const pkce = {
|
|
45
56
|
verifier,
|
|
46
57
|
state,
|
|
47
58
|
clientId,
|
|
48
59
|
tokenEndpoint: meta.token_endpoint,
|
|
49
|
-
exchangeEndpoint: info.TokenExchangeEndpoint
|
|
60
|
+
exchangeEndpoint: info.TokenExchangeEndpoint,
|
|
61
|
+
redirectUri,
|
|
62
|
+
returnTo: login.returnTo ?? currentPath(),
|
|
63
|
+
silent: login.prompt === "none"
|
|
50
64
|
};
|
|
51
65
|
sessionStorage.setItem(PKCE_KEY, JSON.stringify(pkce));
|
|
52
|
-
const
|
|
53
|
-
|
|
66
|
+
const orgScope = login.organization ? `organization:${login.organization}` : "organization";
|
|
67
|
+
const params = {
|
|
54
68
|
response_type: "code",
|
|
55
69
|
client_id: clientId,
|
|
56
|
-
redirect_uri: redirectUri
|
|
57
|
-
|
|
58
|
-
scope: "openid email profile organization",
|
|
70
|
+
redirect_uri: redirectUri,
|
|
71
|
+
scope: `openid email profile ${orgScope}`,
|
|
59
72
|
code_challenge: challenge,
|
|
60
73
|
code_challenge_method: "S256",
|
|
61
74
|
state
|
|
62
|
-
}
|
|
75
|
+
};
|
|
76
|
+
if (login.prompt) params.prompt = login.prompt;
|
|
77
|
+
const authURL = new URL(meta.authorization_endpoint);
|
|
78
|
+
authURL.search = new URLSearchParams(params).toString();
|
|
63
79
|
window.location.assign(authURL.toString());
|
|
64
80
|
}
|
|
65
81
|
var pending = null;
|
|
@@ -67,20 +83,21 @@ function completeLoginFromRedirect() {
|
|
|
67
83
|
if (!pending) pending = doCompleteLogin();
|
|
68
84
|
return pending;
|
|
69
85
|
}
|
|
86
|
+
var SILENT_FAILURES = /* @__PURE__ */ new Set(["login_required", "interaction_required", "consent_required"]);
|
|
70
87
|
async function doCompleteLogin() {
|
|
71
88
|
const params = new URLSearchParams(window.location.search);
|
|
72
89
|
const code = params.get("code");
|
|
73
90
|
const error = params.get("error");
|
|
91
|
+
if (!code && !error) return null;
|
|
92
|
+
const savedRaw = sessionStorage.getItem(PKCE_KEY);
|
|
93
|
+
sessionStorage.removeItem(PKCE_KEY);
|
|
94
|
+
const saved = savedRaw ? JSON.parse(savedRaw) : null;
|
|
95
|
+
history.replaceState({}, "", saved?.returnTo ?? callbackUri());
|
|
74
96
|
if (error) {
|
|
75
|
-
|
|
97
|
+
if (saved?.silent && SILENT_FAILURES.has(error)) return null;
|
|
76
98
|
throw new Error(`IdP returned error: ${error} ${params.get("error_description") ?? ""}`);
|
|
77
99
|
}
|
|
78
|
-
if (!
|
|
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);
|
|
100
|
+
if (!saved) throw new Error("no PKCE state; restart login");
|
|
84
101
|
if (params.get("state") !== saved.state) throw new Error("state mismatch; aborting");
|
|
85
102
|
const tokenResp = await fetch(saved.tokenEndpoint, {
|
|
86
103
|
method: "POST",
|
|
@@ -88,7 +105,7 @@ async function doCompleteLogin() {
|
|
|
88
105
|
body: new URLSearchParams({
|
|
89
106
|
grant_type: "authorization_code",
|
|
90
107
|
code,
|
|
91
|
-
redirect_uri: redirectUri
|
|
108
|
+
redirect_uri: saved.redirectUri,
|
|
92
109
|
client_id: saved.clientId,
|
|
93
110
|
code_verifier: saved.verifier
|
|
94
111
|
})
|
|
@@ -97,22 +114,55 @@ async function doCompleteLogin() {
|
|
|
97
114
|
throw new Error(`IdP token endpoint ${tokenResp.status}: ${await tokenResp.text()}`);
|
|
98
115
|
}
|
|
99
116
|
const idpToken = await tokenResp.json();
|
|
100
|
-
const
|
|
117
|
+
const minted = await exchange(saved.exchangeEndpoint, idpToken.access_token);
|
|
118
|
+
return {
|
|
119
|
+
...minted,
|
|
120
|
+
idpClaims: decodeJwtClaims(idpToken.access_token),
|
|
121
|
+
idToken: typeof idpToken.id_token === "string" ? idpToken.id_token : void 0
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function exchange(exchangeEndpoint, subjectToken) {
|
|
125
|
+
const exResp = await fetch(exchangeEndpoint, {
|
|
101
126
|
method: "POST",
|
|
102
127
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
103
128
|
body: new URLSearchParams({
|
|
104
129
|
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
105
|
-
subject_token:
|
|
130
|
+
subject_token: subjectToken,
|
|
106
131
|
subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
|
|
107
132
|
})
|
|
108
133
|
});
|
|
109
134
|
if (!exResp.ok) throw new Error(`/auth/exchange ${exResp.status}: ${await exResp.text()}`);
|
|
110
135
|
const minted = await exResp.json();
|
|
111
|
-
return {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
136
|
+
return { accessToken: minted.access_token, organizationId: minted.organization_id };
|
|
137
|
+
}
|
|
138
|
+
async function switchOrganization(base, accessToken, organizationId) {
|
|
139
|
+
const r = await fetch(trimSlash(base) + "/auth/switch-organization", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${accessToken}`,
|
|
143
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
144
|
+
},
|
|
145
|
+
body: new URLSearchParams({ organization_id: organizationId })
|
|
146
|
+
});
|
|
147
|
+
if (!r.ok) throw new Error(`/auth/switch-organization ${r.status}: ${await r.text()}`);
|
|
148
|
+
const minted = await r.json();
|
|
149
|
+
return { accessToken: minted.access_token, organizationId: minted.organization_id };
|
|
150
|
+
}
|
|
151
|
+
async function endSession(base, clientId, idToken, postLogoutRedirectUri) {
|
|
152
|
+
const info = await discover(base);
|
|
153
|
+
const meta = info.AuthIssuer ? await oidcMetadata(info.AuthIssuer) : void 0;
|
|
154
|
+
if (!meta?.end_session_endpoint) {
|
|
155
|
+
window.location.assign(postLogoutRedirectUri);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const params = {
|
|
159
|
+
client_id: clientId,
|
|
160
|
+
post_logout_redirect_uri: postLogoutRedirectUri
|
|
115
161
|
};
|
|
162
|
+
if (idToken) params.id_token_hint = idToken;
|
|
163
|
+
const url = new URL(meta.end_session_endpoint);
|
|
164
|
+
url.search = new URLSearchParams(params).toString();
|
|
165
|
+
window.location.assign(url.toString());
|
|
116
166
|
}
|
|
117
167
|
function resetPending() {
|
|
118
168
|
pending = null;
|
|
@@ -127,28 +177,78 @@ function getAccessToken() {
|
|
|
127
177
|
return currentToken;
|
|
128
178
|
}
|
|
129
179
|
var ConfigHubAuthContext = react.createContext(null);
|
|
180
|
+
var SESSION_KEY = "confighub_session";
|
|
181
|
+
function organizationAlias(session) {
|
|
182
|
+
const org = session?.idpClaims.organization;
|
|
183
|
+
if (!org || typeof org !== "object") return void 0;
|
|
184
|
+
const aliases = Object.keys(org);
|
|
185
|
+
return aliases.length === 1 ? aliases[0] : void 0;
|
|
186
|
+
}
|
|
187
|
+
function readPersisted() {
|
|
188
|
+
try {
|
|
189
|
+
const raw = sessionStorage.getItem(SESSION_KEY);
|
|
190
|
+
if (!raw) return null;
|
|
191
|
+
const session = JSON.parse(raw);
|
|
192
|
+
if (!session.accessToken || isExpired(session.accessToken)) {
|
|
193
|
+
sessionStorage.removeItem(SESSION_KEY);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return session;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
130
201
|
function ConfigHubAuthProvider({
|
|
131
202
|
baseUrl,
|
|
132
203
|
clientId,
|
|
204
|
+
callbackPath,
|
|
205
|
+
persist = "none",
|
|
206
|
+
onUnauthorized = "login",
|
|
133
207
|
children
|
|
134
208
|
}) {
|
|
135
209
|
const [status, setStatus] = react.useState("loading");
|
|
136
210
|
const [user, setUser] = react.useState(null);
|
|
137
211
|
const [error, setError] = react.useState(null);
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
212
|
+
const sessionRef = react.useRef(void 0);
|
|
213
|
+
const flow = react.useMemo(() => ({ callbackPath }), [callbackPath]);
|
|
214
|
+
const persistSession = react.useCallback(
|
|
215
|
+
(session) => {
|
|
216
|
+
if (persist !== "session") return;
|
|
217
|
+
try {
|
|
218
|
+
if (session) sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
|
219
|
+
else sessionStorage.removeItem(SESSION_KEY);
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
[persist]
|
|
224
|
+
);
|
|
225
|
+
const applySession = react.useCallback(
|
|
226
|
+
(session) => {
|
|
227
|
+
sessionRef.current = session;
|
|
228
|
+
setAccessToken(session.accessToken);
|
|
229
|
+
persistSession(session);
|
|
230
|
+
setUser({ organizationId: session.organizationId, idpClaims: session.idpClaims });
|
|
231
|
+
setError(null);
|
|
232
|
+
setStatus("authenticated");
|
|
233
|
+
},
|
|
234
|
+
[persistSession]
|
|
235
|
+
);
|
|
236
|
+
const clearSession = react.useCallback(() => {
|
|
237
|
+
sessionRef.current = void 0;
|
|
238
|
+
setAccessToken(void 0);
|
|
239
|
+
persistSession(void 0);
|
|
240
|
+
resetPending();
|
|
241
|
+
setUser(null);
|
|
242
|
+
setStatus("unauthenticated");
|
|
243
|
+
}, [persistSession]);
|
|
146
244
|
react.useEffect(() => {
|
|
147
245
|
let cancelled = false;
|
|
148
246
|
completeLoginFromRedirect().then((session) => {
|
|
149
247
|
if (cancelled) return;
|
|
150
|
-
if (session) applySession(session);
|
|
151
|
-
|
|
248
|
+
if (session) return applySession(session);
|
|
249
|
+
const persisted = persist === "session" ? readPersisted() : null;
|
|
250
|
+
if (persisted) return applySession(persisted);
|
|
251
|
+
setStatus("unauthenticated");
|
|
152
252
|
}).catch((e) => {
|
|
153
253
|
if (cancelled) return;
|
|
154
254
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
@@ -157,37 +257,80 @@ function ConfigHubAuthProvider({
|
|
|
157
257
|
return () => {
|
|
158
258
|
cancelled = true;
|
|
159
259
|
};
|
|
160
|
-
}, [applySession]);
|
|
161
|
-
const login = react.useCallback(
|
|
162
|
-
|
|
260
|
+
}, [applySession, persist]);
|
|
261
|
+
const login = react.useCallback(
|
|
262
|
+
async (options) => {
|
|
263
|
+
setError(null);
|
|
264
|
+
try {
|
|
265
|
+
await startLogin(baseUrl, clientId, options, flow);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
268
|
+
setStatus("error");
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
[baseUrl, clientId, flow]
|
|
272
|
+
);
|
|
273
|
+
const logout = react.useCallback(
|
|
274
|
+
async (options) => {
|
|
275
|
+
const idToken = sessionRef.current?.idToken;
|
|
276
|
+
clearSession();
|
|
277
|
+
if (options?.endSession) {
|
|
278
|
+
await endSession(
|
|
279
|
+
baseUrl,
|
|
280
|
+
clientId,
|
|
281
|
+
idToken,
|
|
282
|
+
options.postLogoutRedirectUri ?? callbackUri(flow)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
[baseUrl, clientId, clearSession, flow]
|
|
287
|
+
);
|
|
288
|
+
const switchOrganization2 = react.useCallback(
|
|
289
|
+
async (organizationId) => {
|
|
290
|
+
const current = sessionRef.current;
|
|
291
|
+
if (!current) throw new Error("not authenticated");
|
|
292
|
+
const minted = await switchOrganization(baseUrl, current.accessToken, organizationId);
|
|
293
|
+
applySession({ ...current, ...minted });
|
|
294
|
+
},
|
|
295
|
+
[applySession, baseUrl]
|
|
296
|
+
);
|
|
297
|
+
const getToken = react.useCallback(() => sessionRef.current?.accessToken, []);
|
|
298
|
+
const reauthenticate = react.useCallback(async () => {
|
|
299
|
+
const organization = organizationAlias(sessionRef.current);
|
|
300
|
+
sessionRef.current = void 0;
|
|
301
|
+
setAccessToken(void 0);
|
|
302
|
+
persistSession(void 0);
|
|
303
|
+
resetPending();
|
|
304
|
+
setStatus("loading");
|
|
163
305
|
try {
|
|
164
|
-
await startLogin(baseUrl, clientId);
|
|
306
|
+
await startLogin(baseUrl, clientId, { prompt: "none", organization }, flow);
|
|
165
307
|
} catch (e) {
|
|
166
308
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
167
309
|
setStatus("error");
|
|
168
310
|
}
|
|
169
|
-
}, [baseUrl, clientId]);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
setStatus("unauthenticated");
|
|
176
|
-
}, []);
|
|
177
|
-
const getToken = react.useCallback(() => tokenRef.current, []);
|
|
311
|
+
}, [baseUrl, clientId, flow, persistSession]);
|
|
312
|
+
const handleUnauthorized = react.useCallback(() => {
|
|
313
|
+
if (!sessionRef.current) return;
|
|
314
|
+
if (onUnauthorized === "login") void reauthenticate();
|
|
315
|
+
else clearSession();
|
|
316
|
+
}, [clearSession, reauthenticate, onUnauthorized]);
|
|
178
317
|
const client = react.useMemo(
|
|
179
|
-
() => api.createConfigHubClient({
|
|
180
|
-
|
|
181
|
-
getToken,
|
|
182
|
-
onUnauthorized: () => {
|
|
183
|
-
logout();
|
|
184
|
-
}
|
|
185
|
-
}),
|
|
186
|
-
[baseUrl, getToken, logout]
|
|
318
|
+
() => api.createConfigHubClient({ baseUrl, getToken, onUnauthorized: handleUnauthorized }),
|
|
319
|
+
[baseUrl, getToken, handleUnauthorized]
|
|
187
320
|
);
|
|
188
321
|
const value = react.useMemo(
|
|
189
|
-
() => ({
|
|
190
|
-
|
|
322
|
+
() => ({
|
|
323
|
+
status,
|
|
324
|
+
user,
|
|
325
|
+
error,
|
|
326
|
+
login,
|
|
327
|
+
logout,
|
|
328
|
+
switchOrganization: switchOrganization2,
|
|
329
|
+
reauthenticate,
|
|
330
|
+
getToken,
|
|
331
|
+
client
|
|
332
|
+
}),
|
|
333
|
+
[status, user, error, login, logout, switchOrganization2, reauthenticate, getToken, client]
|
|
191
334
|
);
|
|
192
335
|
return /* @__PURE__ */ jsxRuntime.jsx(ConfigHubAuthContext.Provider, { value, children });
|
|
193
336
|
}
|
|
@@ -204,7 +347,10 @@ function useConfigHub() {
|
|
|
204
347
|
|
|
205
348
|
exports.ConfigHubAuthContext = ConfigHubAuthContext;
|
|
206
349
|
exports.ConfigHubAuthProvider = ConfigHubAuthProvider;
|
|
350
|
+
exports.callbackUri = callbackUri;
|
|
351
|
+
exports.decodeJwtClaims = decodeJwtClaims;
|
|
207
352
|
exports.getAccessToken = getAccessToken;
|
|
353
|
+
exports.isExpired = isExpired;
|
|
208
354
|
exports.useAuth = useAuth;
|
|
209
355
|
exports.useConfigHub = useConfigHub;
|
|
210
356
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"sources":["../src/core.ts","../src/tokenStore.ts","../src/provider.tsx","../src/hooks.ts"],"names":["createContext","useState","useRef","useMemo","useCallback","useEffect","switchOrganization","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/end_session 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 by the caller; only the transient PKCE state is\n// 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 * The IdP's ID token, kept only so logout can pass it as `id_token_hint` to the\n * end-session endpoint. Absent for sessions that did not come from an OIDC login.\n */\n idToken?: string;\n}\n\nexport interface LoginOptions {\n /**\n * Where to land after login, as a same-origin path (`/space/x?tab=units`). Carried\n * through the authorize round trip in the PKCE state, never in the redirect URI:\n * OAuth clients register exact redirect URIs, so the URI itself must not vary with\n * the page the user started from. Defaults to the current path and query.\n */\n returnTo?: string;\n /**\n * Keycloak organization alias to sign in to, sent as the `organization:<alias>`\n * scope. Without it Keycloak prompts a multi-org user to pick one (or uses the\n * organization already selected in the SSO session).\n */\n organization?: string;\n /**\n * `'none'` asks the IdP to re-authenticate without any UI, failing with\n * `login_required` if the SSO session is gone -- the way to refresh an expired\n * ConfigHub token when the user is still signed in at the IdP. `'login'` forces the\n * login form even with a live SSO session.\n */\n prompt?: 'none' | 'login';\n}\n\nexport interface FlowOptions {\n /**\n * Same-origin path the IdP redirects back to, and therefore the redirect URI to\n * register for the client: `{origin}{callbackPath}`. Defaults to `/`.\n */\n callbackPath?: string;\n}\n\ninterface PkceState {\n verifier: string;\n state: string;\n clientId: string;\n tokenEndpoint: string;\n exchangeEndpoint: string;\n redirectUri: string;\n returnTo: string;\n silent: boolean;\n}\n\nconst PKCE_KEY = 'confighub_pkce';\n\nconst trimSlash = (s: string): string => s.replace(/\\/+$/, '');\n\n/** The fixed callback URI: the page origin plus the configured callback path. */\nexport const callbackUri = (opts?: FlowOptions): string =>\n window.location.origin + (opts?.callbackPath ?? '/');\n\nconst currentPath = (): string =>\n window.location.pathname + window.location.search + window.location.hash;\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\n/** Decode a JWT's claims without verifying it. Returns {} for anything malformed. */\nexport function decodeJwtClaims(token: string): Record<string, unknown> {\n const part = token.split('.')[1];\n if (!part) return {};\n try {\n return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));\n } catch {\n return {};\n }\n}\n\n/** Whether a JWT's `exp` is in the past (with a small skew allowance). */\nexport function isExpired(token: string, skewSeconds = 30): boolean {\n const exp = decodeJwtClaims(token).exp;\n if (typeof exp !== 'number') return false;\n return exp * 1000 <= Date.now() + skewSeconds * 1000;\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\ninterface OidcMetadata {\n authorization_endpoint: string;\n token_endpoint: string;\n end_session_endpoint?: string;\n}\n\nasync function oidcMetadata(issuer: string): Promise<OidcMetadata> {\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(\n base: string,\n clientId: string,\n login: LoginOptions = {},\n flow: FlowOptions = {},\n): 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 redirectUri = callbackUri(flow);\n const pkce: PkceState = {\n verifier,\n state,\n clientId,\n tokenEndpoint: meta.token_endpoint,\n exchangeEndpoint: info.TokenExchangeEndpoint,\n redirectUri,\n returnTo: login.returnTo ?? currentPath(),\n silent: login.prompt === 'none',\n };\n sessionStorage.setItem(PKCE_KEY, JSON.stringify(pkce));\n\n // The \"organization\" scope makes Keycloak emit the org claim the exchange resolves;\n // \"organization:<alias>\" selects one without prompting.\n const orgScope = login.organization ? `organization:${login.organization}` : 'organization';\n const params: Record<string, string> = {\n response_type: 'code',\n client_id: clientId,\n redirect_uri: redirectUri,\n scope: `openid email profile ${orgScope}`,\n code_challenge: challenge,\n code_challenge_method: 'S256',\n state,\n };\n if (login.prompt) params.prompt = login.prompt;\n\n const authURL = new URL(meta.authorization_endpoint);\n authURL.search = new URLSearchParams(params).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, and restore the URL the\n * login started from. Returns null on a normal load, and also when a `prompt=none`\n * attempt came back with `login_required` (the SSO session is gone; the caller\n * should offer an interactive login).\n */\nexport function completeLoginFromRedirect(): Promise<MintedSession | null> {\n if (!pending) pending = doCompleteLogin();\n return pending;\n}\n\nconst SILENT_FAILURES = new Set(['login_required', 'interaction_required', 'consent_required']);\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 (!code && !error) return null;\n\n const savedRaw = sessionStorage.getItem(PKCE_KEY);\n sessionStorage.removeItem(PKCE_KEY);\n const saved: PkceState | null = savedRaw ? JSON.parse(savedRaw) : null;\n\n // Put the URL back to where the user started before anything else can fail, so\n // neither the code nor an error string lingers in the address bar or history.\n history.replaceState({}, '', saved?.returnTo ?? callbackUri());\n\n if (error) {\n if (saved?.silent && SILENT_FAILURES.has(error)) return null;\n throw new Error(`IdP returned error: ${error} ${params.get('error_description') ?? ''}`);\n }\n if (!saved) throw new Error('no PKCE state; restart login');\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: code!,\n redirect_uri: saved.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 minted = await exchange(saved.exchangeEndpoint, idpToken.access_token);\n return {\n ...minted,\n idpClaims: decodeJwtClaims(idpToken.access_token),\n idToken: typeof idpToken.id_token === 'string' ? idpToken.id_token : undefined,\n };\n}\n\nasync function exchange(\n exchangeEndpoint: string,\n subjectToken: string,\n): Promise<Pick<MintedSession, 'accessToken' | 'organizationId'>> {\n const exResp = await fetch(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: subjectToken,\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 return { accessToken: minted.access_token, organizationId: minted.organization_id };\n}\n\n/**\n * Trade the current minted token for one scoped to another organization the user\n * belongs to (`POST {base}/auth/switch-organization`, bearer-authenticated). The\n * IdP session is untouched; only the ConfigHub token changes.\n */\nexport async function switchOrganization(\n base: string,\n accessToken: string,\n organizationId: string,\n): Promise<Pick<MintedSession, 'accessToken' | 'organizationId'>> {\n const r = await fetch(trimSlash(base) + '/auth/switch-organization', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ organization_id: organizationId }),\n });\n if (!r.ok) throw new Error(`/auth/switch-organization ${r.status}: ${await r.text()}`);\n const minted = await r.json();\n return { accessToken: minted.access_token, organizationId: minted.organization_id };\n}\n\n/**\n * End the IdP session (RP-initiated logout) and land on `postLogoutRedirectUri`,\n * which must be registered for the client. Returns only by redirecting. If the\n * issuer publishes no end-session endpoint, navigates to the redirect URI directly.\n */\nexport async function endSession(\n base: string,\n clientId: string,\n idToken: string | undefined,\n postLogoutRedirectUri: string,\n): Promise<void> {\n const info = await discover(base);\n const meta = info.AuthIssuer ? await oidcMetadata(info.AuthIssuer) : undefined;\n if (!meta?.end_session_endpoint) {\n window.location.assign(postLogoutRedirectUri);\n return;\n }\n const params: Record<string, string> = {\n client_id: clientId,\n post_logout_redirect_uri: postLogoutRedirectUri,\n };\n if (idToken) params.id_token_hint = idToken;\n const url = new URL(meta.end_session_endpoint);\n url.search = new URLSearchParams(params).toString();\n window.location.assign(url.toString());\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 callbackUri,\n completeLoginFromRedirect,\n endSession,\n isExpired,\n resetPending,\n startLogin,\n switchOrganization as switchOrganizationCore,\n type LoginOptions,\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 LogoutOptions {\n /**\n * Also end the IdP session (RP-initiated logout), so the next login asks for\n * credentials instead of riding the SSO cookie. Redirects the page; the landing\n * URI must be registered for the client. Default: false, which only forgets the\n * token in this tab.\n */\n endSession?: boolean;\n /** Where to land after IdP logout. Defaults to the callback URI. */\n postLogoutRedirectUri?: string;\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: (options?: LoginOptions) => Promise<void>;\n /** Forget the session in this tab and, optionally, end the IdP session too. */\n logout: (options?: LogoutOptions) => Promise<void>;\n /**\n * Re-mint the ConfigHub token for another organization the user belongs to. The\n * IdP session is untouched. Rejects with the server's error if the user is not a\n * member; the current session stays as it was.\n */\n switchOrganization: (organizationId: string) => Promise<void>;\n /**\n * The ConfigHub token stopped working (a 401). Try to get a new one without any\n * UI: a `prompt=none` round trip through the IdP, for the organization the session\n * already had. Status goes to `loading` meanwhile, not `unauthenticated`, so an\n * app that auto-logs-in on `unauthenticated` does not race this with an\n * interactive login. If the IdP session is gone too, the page comes back\n * `unauthenticated`. Redirects the page.\n */\n reauthenticate: () => Promise<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 /**\n * Same-origin path the IdP redirects back to; `{origin}{callbackPath}` is the\n * redirect URI to register. Defaults to `/`. Fixed on purpose: the page a user\n * starts login from travels in the PKCE state, not in the redirect URI.\n */\n callbackPath?: string;\n /**\n * `'session'` keeps the minted token in `sessionStorage` so a reload or an in-tab\n * navigation does not round-trip through the IdP. Tab-scoped and gone when the tab\n * closes. Default `'none'`: memory only, a reload starts unauthenticated.\n */\n persist?: 'none' | 'session';\n /**\n * What a 401 from the API means. `'login'` (default): the token is stale, try a\n * silent re-authentication (`prompt=none`) and fall back to unauthenticated if the\n * IdP session is gone too. `'logout'`: just drop the session.\n */\n onUnauthorized?: 'login' | 'logout';\n children: ReactNode;\n}\n\nconst SESSION_KEY = 'confighub_session';\n\n/**\n * The Keycloak organization alias of the session, from the IdP token's\n * `organization` claim (`{ \"<alias>\": { id } }`), for re-selecting the same org\n * without a prompt. Undefined when the claim is absent or not in that shape.\n */\nfunction organizationAlias(session: MintedSession | undefined): string | undefined {\n const org = session?.idpClaims.organization;\n if (!org || typeof org !== 'object') return undefined;\n const aliases = Object.keys(org as Record<string, unknown>);\n return aliases.length === 1 ? aliases[0] : undefined;\n}\n\nfunction readPersisted(): MintedSession | null {\n try {\n const raw = sessionStorage.getItem(SESSION_KEY);\n if (!raw) return null;\n const session: MintedSession = JSON.parse(raw);\n if (!session.accessToken || isExpired(session.accessToken)) {\n sessionStorage.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n } catch {\n return null;\n }\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, restores a persisted\n * session if there is one, and otherwise starts unauthenticated until `login()`\n * is called.\n */\nexport function ConfigHubAuthProvider({\n baseUrl,\n clientId,\n callbackPath,\n persist = 'none',\n onUnauthorized = 'login',\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 session lives in a ref so getToken() reads the latest value synchronously\n // without re-creating the API client on every render.\n const sessionRef = useRef<MintedSession | undefined>(undefined);\n const flow = useMemo(() => ({ callbackPath }), [callbackPath]);\n\n const persistSession = useCallback(\n (session: MintedSession | undefined) => {\n if (persist !== 'session') return;\n try {\n if (session) sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));\n else sessionStorage.removeItem(SESSION_KEY);\n } catch {\n // Storage unavailable (private mode, quota): memory-only for this tab.\n }\n },\n [persist],\n );\n\n const applySession = useCallback(\n (session: MintedSession) => {\n sessionRef.current = session;\n setAccessToken(session.accessToken); // keep the non-React accessor in sync (rtk-query)\n persistSession(session);\n setUser({ organizationId: session.organizationId, idpClaims: session.idpClaims });\n setError(null);\n setStatus('authenticated');\n },\n [persistSession],\n );\n\n const clearSession = useCallback(() => {\n sessionRef.current = undefined;\n setAccessToken(undefined);\n persistSession(undefined);\n resetPending();\n setUser(null);\n setStatus('unauthenticated');\n }, [persistSession]);\n\n useEffect(() => {\n let cancelled = false;\n completeLoginFromRedirect()\n .then((session) => {\n if (cancelled) return;\n if (session) return applySession(session);\n const persisted = persist === 'session' ? readPersisted() : null;\n if (persisted) return applySession(persisted);\n 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, persist]);\n\n const login = useCallback(\n async (options?: LoginOptions) => {\n setError(null);\n try {\n await startLogin(baseUrl, clientId, options, flow);\n } catch (e: unknown) {\n setError(e instanceof Error ? e : new Error(String(e)));\n setStatus('error');\n }\n },\n [baseUrl, clientId, flow],\n );\n\n const logout = useCallback(\n async (options?: LogoutOptions) => {\n const idToken = sessionRef.current?.idToken;\n clearSession();\n if (options?.endSession) {\n await endSession(\n baseUrl,\n clientId,\n idToken,\n options.postLogoutRedirectUri ?? callbackUri(flow),\n );\n }\n },\n [baseUrl, clientId, clearSession, flow],\n );\n\n const switchOrganization = useCallback(\n async (organizationId: string) => {\n const current = sessionRef.current;\n if (!current) throw new Error('not authenticated');\n const minted = await switchOrganizationCore(baseUrl, current.accessToken, organizationId);\n applySession({ ...current, ...minted });\n },\n [applySession, baseUrl],\n );\n\n const getToken = useCallback(() => sessionRef.current?.accessToken, []);\n\n const reauthenticate = useCallback(async () => {\n const organization = organizationAlias(sessionRef.current);\n // Forget the token but stay 'loading': the page is about to navigate away,\n // and 'unauthenticated' would invite an interactive login in the meantime.\n sessionRef.current = undefined;\n setAccessToken(undefined);\n persistSession(undefined);\n resetPending();\n setStatus('loading');\n try {\n await startLogin(baseUrl, clientId, { prompt: 'none', organization }, flow);\n } catch (e: unknown) {\n setError(e instanceof Error ? e : new Error(String(e)));\n setStatus('error');\n }\n }, [baseUrl, clientId, flow, persistSession]);\n\n // A 401 means the minted token no longer works. Silent re-auth keeps the user's\n // place if the IdP session is still alive; otherwise the page comes back\n // unauthenticated and the app offers a real login.\n const handleUnauthorized = useCallback(() => {\n if (!sessionRef.current) return;\n if (onUnauthorized === 'login') void reauthenticate();\n else clearSession();\n }, [clearSession, reauthenticate, onUnauthorized]);\n\n // One client for the provider's lifetime. getToken reads the session ref.\n const client = useMemo(\n () => createConfigHubClient({ baseUrl, getToken, onUnauthorized: handleUnauthorized }),\n [baseUrl, getToken, handleUnauthorized],\n );\n\n const value = useMemo<ConfigHubAuthContextValue>(\n () => ({\n status,\n user,\n error,\n login,\n logout,\n switchOrganization,\n reauthenticate,\n getToken,\n client,\n }),\n [status, user, error, login, logout, switchOrganization, reauthenticate, 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"]}
|