@ghostly-solutions/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/dist/react.js ADDED
@@ -0,0 +1,681 @@
1
+ import { createContext, useMemo, useState, useEffect, useCallback, useContext } from 'react';
2
+ import { jsx, Fragment } from 'react/jsx-runtime';
3
+
4
+ // src/adapters/react/auth-callback-handler.tsx
5
+
6
+ // src/constants/auth-endpoints.ts
7
+ var authApiPrefix = "/v1/auth";
8
+ var authEndpoints = {
9
+ loginStart: `${authApiPrefix}/keycloak/login`,
10
+ validateKeycloakToken: `${authApiPrefix}/keycloak/validate`,
11
+ session: `${authApiPrefix}/me`,
12
+ logout: `${authApiPrefix}/logout`
13
+ };
14
+
15
+ // src/constants/http-status.ts
16
+ var httpStatus = {
17
+ ok: 200,
18
+ noContent: 204,
19
+ badRequest: 400,
20
+ unauthorized: 401
21
+ };
22
+
23
+ // src/errors/auth-sdk-error.ts
24
+ var AuthSdkError = class extends Error {
25
+ code;
26
+ details;
27
+ status;
28
+ constructor(payload) {
29
+ super(payload.message);
30
+ this.name = "AuthSdkError";
31
+ this.code = payload.code;
32
+ this.details = payload.details;
33
+ this.status = payload.status;
34
+ }
35
+ };
36
+
37
+ // src/types/auth-error-code.ts
38
+ var authErrorCode = {
39
+ callbackMissingToken: "callback_missing_token",
40
+ callbackInvalidToken: "callback_invalid_token",
41
+ callbackValidationFailed: "callback_validation_failed",
42
+ unauthorized: "unauthorized",
43
+ networkError: "network_error",
44
+ apiError: "api_error",
45
+ broadcastChannelUnsupported: "broadcast_channel_unsupported"};
46
+
47
+ // src/constants/auth-keys.ts
48
+ var authQueryKeys = {
49
+ token: "token"
50
+ };
51
+ var authStorageKeys = {
52
+ returnTo: "ghostly-auth:return-to"
53
+ };
54
+ var authBroadcast = {
55
+ channelName: "ghostly-auth-channel",
56
+ sessionUpdatedEvent: "session-updated"
57
+ };
58
+ var authRoutes = {
59
+ root: "/"};
60
+
61
+ // src/core/object-guards.ts
62
+ function isObjectRecord(value) {
63
+ return typeof value === "object" && value !== null;
64
+ }
65
+ function isStringValue(value) {
66
+ return typeof value === "string";
67
+ }
68
+
69
+ // src/core/session-parser.ts
70
+ function isStringArray(value) {
71
+ return Array.isArray(value) && value.every((entry) => isStringValue(entry));
72
+ }
73
+ function isGhostlySession(value) {
74
+ if (!isObjectRecord(value)) {
75
+ return false;
76
+ }
77
+ return isStringValue(value.id) && isStringValue(value.username) && (value.firstName === null || isStringValue(value.firstName)) && (value.lastName === null || isStringValue(value.lastName)) && isStringValue(value.email) && isStringValue(value.role) && isStringArray(value.permissions);
78
+ }
79
+
80
+ // src/core/broadcast-sync.ts
81
+ function isSessionUpdatedMessage(value) {
82
+ if (!isObjectRecord(value)) {
83
+ return false;
84
+ }
85
+ if (!isStringValue(value.type)) {
86
+ return false;
87
+ }
88
+ if (value.type !== authBroadcast.sessionUpdatedEvent) {
89
+ return false;
90
+ }
91
+ return value.session === null || isGhostlySession(value.session);
92
+ }
93
+ function createUnsupportedBroadcastChannelError() {
94
+ return new AuthSdkError({
95
+ code: authErrorCode.broadcastChannelUnsupported,
96
+ details: null,
97
+ message: "BroadcastChannel is unavailable in this runtime.",
98
+ status: null
99
+ });
100
+ }
101
+ function createBroadcastSync(options) {
102
+ if (typeof BroadcastChannel === "undefined") {
103
+ throw createUnsupportedBroadcastChannelError();
104
+ }
105
+ const channel = new BroadcastChannel(authBroadcast.channelName);
106
+ const onMessage = (event) => {
107
+ const messageEvent = event;
108
+ if (!isSessionUpdatedMessage(messageEvent.data)) {
109
+ return;
110
+ }
111
+ options.onSessionUpdated(messageEvent.data.session);
112
+ };
113
+ channel.addEventListener("message", onMessage);
114
+ return {
115
+ close() {
116
+ channel.removeEventListener("message", onMessage);
117
+ channel.close();
118
+ },
119
+ publishSession(session) {
120
+ const payload = {
121
+ session,
122
+ type: authBroadcast.sessionUpdatedEvent
123
+ };
124
+ channel.postMessage(payload);
125
+ }
126
+ };
127
+ }
128
+
129
+ // src/core/callback-url.ts
130
+ function readCallbackToken(url) {
131
+ return url.searchParams.get(authQueryKeys.token);
132
+ }
133
+ function removeCallbackToken(url) {
134
+ const nextUrl = new URL(url.toString());
135
+ nextUrl.searchParams.delete(authQueryKeys.token);
136
+ return nextUrl;
137
+ }
138
+ function replaceBrowserHistory(url) {
139
+ window.history.replaceState(null, "", url.toString());
140
+ }
141
+
142
+ // src/core/http-client.ts
143
+ var jsonContentType = "application/json";
144
+ var jsonHeaderName = "content-type";
145
+ var includeCredentials = "include";
146
+ var noStoreCache = "no-store";
147
+ function toTypedValue(value) {
148
+ return value;
149
+ }
150
+ function mapHttpStatusToAuthErrorCode(status) {
151
+ if (status === httpStatus.unauthorized) {
152
+ return authErrorCode.unauthorized;
153
+ }
154
+ return authErrorCode.apiError;
155
+ }
156
+ async function parseJsonPayload(response) {
157
+ try {
158
+ return await response.json();
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ async function parseErrorPayload(response) {
164
+ const payload = await parseJsonPayload(response);
165
+ if (!isObjectRecord(payload)) {
166
+ return {
167
+ code: null,
168
+ details: null,
169
+ message: null
170
+ };
171
+ }
172
+ const maybeCode = payload.code;
173
+ const maybeMessage = payload.message;
174
+ return {
175
+ code: isStringValue(maybeCode) ? maybeCode : null,
176
+ details: "details" in payload ? payload.details : null,
177
+ message: isStringValue(maybeMessage) ? maybeMessage : null
178
+ };
179
+ }
180
+ function buildApiErrorMessage(method, path) {
181
+ return `Auth API request failed: ${method} ${path}`;
182
+ }
183
+ function buildNetworkErrorMessage(method, path) {
184
+ return `Auth API network failure: ${method} ${path}`;
185
+ }
186
+ async function request(options) {
187
+ const expectedStatus = options.expectedStatus ?? httpStatus.ok;
188
+ const headers = new Headers();
189
+ const hasBody = typeof options.body !== "undefined";
190
+ if (hasBody) {
191
+ headers.set(jsonHeaderName, jsonContentType);
192
+ }
193
+ const requestInit = {
194
+ cache: noStoreCache,
195
+ credentials: includeCredentials,
196
+ headers,
197
+ method: options.method
198
+ };
199
+ if (hasBody) {
200
+ requestInit.body = JSON.stringify(options.body);
201
+ }
202
+ let response;
203
+ try {
204
+ response = await fetch(options.path, requestInit);
205
+ } catch (error) {
206
+ throw new AuthSdkError({
207
+ code: authErrorCode.networkError,
208
+ details: error,
209
+ message: buildNetworkErrorMessage(options.method, options.path),
210
+ status: null
211
+ });
212
+ }
213
+ if (response.status !== expectedStatus) {
214
+ const parsed = await parseErrorPayload(response);
215
+ throw new AuthSdkError({
216
+ code: mapHttpStatusToAuthErrorCode(response.status),
217
+ details: {
218
+ apiCode: parsed.code,
219
+ apiDetails: parsed.details
220
+ },
221
+ message: parsed.message ?? buildApiErrorMessage(options.method, options.path),
222
+ status: response.status
223
+ });
224
+ }
225
+ if (response.status === httpStatus.noContent) {
226
+ return toTypedValue(null);
227
+ }
228
+ const payload = await parseJsonPayload(response);
229
+ return toTypedValue(payload);
230
+ }
231
+ function getJson(path) {
232
+ return request({
233
+ method: "GET",
234
+ path
235
+ });
236
+ }
237
+ function postJson(path, body) {
238
+ return request({
239
+ body,
240
+ method: "POST",
241
+ path
242
+ });
243
+ }
244
+ function postEmpty(path) {
245
+ return request({
246
+ expectedStatus: httpStatus.noContent,
247
+ method: "POST",
248
+ path
249
+ });
250
+ }
251
+
252
+ // src/core/runtime.ts
253
+ var browserRuntimeErrorMessage = "Browser runtime is required for this auth operation.";
254
+ function isBrowserRuntime() {
255
+ return typeof window !== "undefined";
256
+ }
257
+ function assertBrowserRuntime() {
258
+ if (isBrowserRuntime()) {
259
+ return;
260
+ }
261
+ throw new AuthSdkError({
262
+ code: authErrorCode.apiError,
263
+ details: null,
264
+ message: browserRuntimeErrorMessage,
265
+ status: null
266
+ });
267
+ }
268
+
269
+ // src/core/return-to-storage.ts
270
+ function sanitizeReturnTo(value) {
271
+ if (!value) {
272
+ return authRoutes.root;
273
+ }
274
+ if (!value.startsWith(authRoutes.root)) {
275
+ return authRoutes.root;
276
+ }
277
+ const protocolRelativePrefix = "//";
278
+ if (value.startsWith(protocolRelativePrefix)) {
279
+ return authRoutes.root;
280
+ }
281
+ return value;
282
+ }
283
+ function getCurrentBrowserPath() {
284
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`;
285
+ }
286
+ function saveReturnToPath(returnTo) {
287
+ assertBrowserRuntime();
288
+ const fallbackPath = getCurrentBrowserPath();
289
+ const sanitized = sanitizeReturnTo(returnTo ?? fallbackPath);
290
+ window.sessionStorage.setItem(authStorageKeys.returnTo, sanitized);
291
+ return sanitized;
292
+ }
293
+ function consumeReturnToPath() {
294
+ assertBrowserRuntime();
295
+ const value = window.sessionStorage.getItem(authStorageKeys.returnTo);
296
+ window.sessionStorage.removeItem(authStorageKeys.returnTo);
297
+ return sanitizeReturnTo(value);
298
+ }
299
+
300
+ // src/core/session-store.ts
301
+ var SessionStore = class {
302
+ listeners = /* @__PURE__ */ new Set();
303
+ resolvedSession = null;
304
+ resolveState = "pending";
305
+ getSessionIfResolved() {
306
+ if (this.resolveState === "pending") {
307
+ return null;
308
+ }
309
+ return this.resolvedSession;
310
+ }
311
+ hasResolvedSession() {
312
+ return this.resolveState === "resolved";
313
+ }
314
+ setSession(session) {
315
+ this.resolveState = "resolved";
316
+ this.resolvedSession = session;
317
+ for (const listener of this.listeners) {
318
+ listener(session);
319
+ }
320
+ }
321
+ subscribe(listener) {
322
+ this.listeners.add(listener);
323
+ return () => {
324
+ this.listeners.delete(listener);
325
+ };
326
+ }
327
+ };
328
+
329
+ // src/core/auth-client.ts
330
+ function createPendingRedirectPromise() {
331
+ return new Promise(() => {
332
+ });
333
+ }
334
+ function createInvalidSessionPayloadError(path) {
335
+ return new AuthSdkError({
336
+ code: authErrorCode.apiError,
337
+ details: null,
338
+ message: `Auth API response has invalid session shape: ${path}`,
339
+ status: null
340
+ });
341
+ }
342
+ function toValidatedSession(payload, path) {
343
+ if (!isGhostlySession(payload)) {
344
+ throw createInvalidSessionPayloadError(path);
345
+ }
346
+ return payload;
347
+ }
348
+ function toCallbackFailure(error) {
349
+ if (error instanceof AuthSdkError) {
350
+ if (error.status === httpStatus.unauthorized) {
351
+ return new AuthSdkError({
352
+ code: authErrorCode.callbackInvalidToken,
353
+ details: error.details,
354
+ message: "Callback JWT is invalid or expired.",
355
+ status: error.status
356
+ });
357
+ }
358
+ return new AuthSdkError({
359
+ code: authErrorCode.callbackValidationFailed,
360
+ details: error.details,
361
+ message: "Keycloak callback validation failed.",
362
+ status: error.status
363
+ });
364
+ }
365
+ return new AuthSdkError({
366
+ code: authErrorCode.callbackValidationFailed,
367
+ details: error,
368
+ message: "Keycloak callback validation failed.",
369
+ status: null
370
+ });
371
+ }
372
+ function createNoopBroadcastSync() {
373
+ return {
374
+ close() {
375
+ },
376
+ publishSession() {
377
+ }
378
+ };
379
+ }
380
+ function createSafeBroadcastSync(onSessionUpdated) {
381
+ try {
382
+ return createBroadcastSync({
383
+ onSessionUpdated
384
+ });
385
+ } catch (error) {
386
+ if (error instanceof AuthSdkError && error.code === authErrorCode.broadcastChannelUnsupported) {
387
+ return createNoopBroadcastSync();
388
+ }
389
+ throw error;
390
+ }
391
+ }
392
+ async function fetchCurrentSessionFromApi() {
393
+ const payload = await getJson(authEndpoints.session);
394
+ if (payload === null) {
395
+ return null;
396
+ }
397
+ return toValidatedSession(payload, authEndpoints.session);
398
+ }
399
+ function createAuthClient() {
400
+ assertBrowserRuntime();
401
+ const sessionStore = new SessionStore();
402
+ const broadcastSync = createSafeBroadcastSync((session) => {
403
+ sessionStore.setSession(session);
404
+ });
405
+ const getSession = async (options) => {
406
+ const forceRefresh = options?.forceRefresh ?? false;
407
+ if (sessionStore.hasResolvedSession() && !forceRefresh) {
408
+ return sessionStore.getSessionIfResolved();
409
+ }
410
+ const session = await fetchCurrentSessionFromApi();
411
+ sessionStore.setSession(session);
412
+ broadcastSync.publishSession(session);
413
+ return session;
414
+ };
415
+ const requireSession = async () => {
416
+ const session = await getSession();
417
+ if (session) {
418
+ return session;
419
+ }
420
+ throw new AuthSdkError({
421
+ code: authErrorCode.unauthorized,
422
+ details: null,
423
+ message: "Authenticated session is required.",
424
+ status: httpStatus.unauthorized
425
+ });
426
+ };
427
+ const login = (options) => {
428
+ saveReturnToPath(options?.returnTo);
429
+ window.location.assign(authEndpoints.loginStart);
430
+ };
431
+ const processCallback = async () => {
432
+ const currentUrl = new URL(window.location.href);
433
+ const token = readCallbackToken(currentUrl);
434
+ if (!token) {
435
+ throw new AuthSdkError({
436
+ code: authErrorCode.callbackMissingToken,
437
+ details: null,
438
+ message: "Missing callback token query parameter.",
439
+ status: httpStatus.badRequest
440
+ });
441
+ }
442
+ const cleanedUrl = removeCallbackToken(currentUrl);
443
+ replaceBrowserHistory(cleanedUrl);
444
+ try {
445
+ const payload = await postJson(
446
+ authEndpoints.validateKeycloakToken,
447
+ { token }
448
+ );
449
+ const session = toValidatedSession(payload.session, authEndpoints.validateKeycloakToken);
450
+ sessionStore.setSession(session);
451
+ broadcastSync.publishSession(session);
452
+ return {
453
+ redirectTo: consumeReturnToPath(),
454
+ session
455
+ };
456
+ } catch (error) {
457
+ throw toCallbackFailure(error);
458
+ }
459
+ };
460
+ const completeCallbackRedirect = async () => {
461
+ const result = await processCallback();
462
+ window.location.replace(result.redirectTo);
463
+ return createPendingRedirectPromise();
464
+ };
465
+ const logout = async () => {
466
+ await postEmpty(authEndpoints.logout);
467
+ sessionStore.setSession(null);
468
+ broadcastSync.publishSession(null);
469
+ };
470
+ const subscribe = sessionStore.subscribe.bind(sessionStore);
471
+ return {
472
+ completeCallbackRedirect,
473
+ getSession,
474
+ login,
475
+ logout,
476
+ processCallback,
477
+ requireSession,
478
+ subscribe
479
+ };
480
+ }
481
+ function normalizeAuthError(error) {
482
+ if (error instanceof AuthSdkError) {
483
+ return error;
484
+ }
485
+ return new AuthSdkError({
486
+ code: "callback_validation_failed",
487
+ details: error,
488
+ message: "Auth callback redirect failed.",
489
+ status: null
490
+ });
491
+ }
492
+ function useAuthCallbackRedirect(options = {}) {
493
+ const client = useMemo(() => options.client ?? createAuthClient(), [options.client]);
494
+ const [error, setError] = useState(null);
495
+ useEffect(() => {
496
+ let isActive = true;
497
+ void client.completeCallbackRedirect().catch((caughtError) => {
498
+ if (!isActive) {
499
+ return;
500
+ }
501
+ setError(normalizeAuthError(caughtError));
502
+ });
503
+ return () => {
504
+ isActive = false;
505
+ };
506
+ }, [client]);
507
+ if (error) {
508
+ return {
509
+ error,
510
+ status: "failed"
511
+ };
512
+ }
513
+ return {
514
+ error: null,
515
+ status: "processing"
516
+ };
517
+ }
518
+ function AuthCallbackHandler(props) {
519
+ const state = useAuthCallbackRedirect({
520
+ client: props.client
521
+ });
522
+ if (state.status === "failed" && state.error) {
523
+ return props.renderError(state.error);
524
+ }
525
+ return /* @__PURE__ */ jsx(Fragment, { children: props.processing });
526
+ }
527
+ var AuthContext = createContext(null);
528
+ var initialLoadingState = true;
529
+ function toAuthError(error) {
530
+ if (error instanceof AuthSdkError) {
531
+ return error;
532
+ }
533
+ return new AuthSdkError({
534
+ code: "api_error",
535
+ details: error,
536
+ message: "Unexpected auth adapter error.",
537
+ status: null
538
+ });
539
+ }
540
+ function createDeferredAuthClient() {
541
+ let authClient = null;
542
+ const resolveClient = () => {
543
+ authClient ??= createAuthClient();
544
+ return authClient;
545
+ };
546
+ return {
547
+ completeCallbackRedirect() {
548
+ return resolveClient().completeCallbackRedirect();
549
+ },
550
+ getSession(options) {
551
+ return resolveClient().getSession(options);
552
+ },
553
+ login(options) {
554
+ resolveClient().login(options);
555
+ },
556
+ logout() {
557
+ return resolveClient().logout();
558
+ },
559
+ processCallback() {
560
+ return resolveClient().processCallback();
561
+ },
562
+ requireSession() {
563
+ return resolveClient().requireSession();
564
+ },
565
+ subscribe(listener) {
566
+ if (typeof window === "undefined") {
567
+ return () => {
568
+ };
569
+ }
570
+ return resolveClient().subscribe(listener);
571
+ }
572
+ };
573
+ }
574
+ function AuthProvider(props) {
575
+ const authClient = useMemo(() => props.client ?? createDeferredAuthClient(), [props.client]);
576
+ const [session, setSession] = useState(null);
577
+ const [error, setError] = useState(null);
578
+ const [isLoading, setIsLoading] = useState(initialLoadingState);
579
+ useEffect(() => {
580
+ let isActive = true;
581
+ const unsubscribe = authClient.subscribe((nextSession) => {
582
+ if (!isActive) {
583
+ return;
584
+ }
585
+ setSession(nextSession);
586
+ setIsLoading(false);
587
+ });
588
+ void authClient.getSession().then((nextSession) => {
589
+ if (!isActive) {
590
+ return;
591
+ }
592
+ setSession(nextSession);
593
+ }).catch((caughtError) => {
594
+ if (!isActive) {
595
+ return;
596
+ }
597
+ setError(toAuthError(caughtError));
598
+ }).finally(() => {
599
+ if (!isActive) {
600
+ return;
601
+ }
602
+ setIsLoading(false);
603
+ });
604
+ return () => {
605
+ isActive = false;
606
+ unsubscribe();
607
+ };
608
+ }, [authClient]);
609
+ const login = useCallback(
610
+ (options) => {
611
+ setError(null);
612
+ authClient.login(options);
613
+ },
614
+ [authClient]
615
+ );
616
+ const logout = useCallback(async () => {
617
+ setError(null);
618
+ try {
619
+ await authClient.logout();
620
+ setSession(null);
621
+ } catch (caughtError) {
622
+ const normalizedError = toAuthError(caughtError);
623
+ setError(normalizedError);
624
+ throw normalizedError;
625
+ }
626
+ }, [authClient]);
627
+ const refresh = useCallback(async () => {
628
+ setError(null);
629
+ try {
630
+ const nextSession = await authClient.getSession({ forceRefresh: true });
631
+ setSession(nextSession);
632
+ return nextSession;
633
+ } catch (caughtError) {
634
+ const normalizedError = toAuthError(caughtError);
635
+ setError(normalizedError);
636
+ throw normalizedError;
637
+ }
638
+ }, [authClient]);
639
+ const contextValue = useMemo(
640
+ () => ({
641
+ error,
642
+ isLoading,
643
+ login,
644
+ logout,
645
+ refresh,
646
+ session
647
+ }),
648
+ [error, isLoading, login, logout, refresh, session]
649
+ );
650
+ return /* @__PURE__ */ jsx(AuthContext.Provider, { value: contextValue, children: props.children });
651
+ }
652
+ var missingProviderErrorMessage = "useAuth must be used inside AuthProvider.";
653
+ function useAuth() {
654
+ const context = useContext(AuthContext);
655
+ if (context) {
656
+ return context;
657
+ }
658
+ throw new Error(missingProviderErrorMessage);
659
+ }
660
+ function resolveUnauthorizedContent(input, props) {
661
+ if (typeof input === "function") {
662
+ return input(props);
663
+ }
664
+ return input;
665
+ }
666
+ function AuthSessionGate(props) {
667
+ const auth = useAuth();
668
+ if (auth.isLoading) {
669
+ return /* @__PURE__ */ jsx(Fragment, { children: props.loading ?? null });
670
+ }
671
+ if (!auth.session) {
672
+ return /* @__PURE__ */ jsx(Fragment, { children: resolveUnauthorizedContent(props.unauthorized, {
673
+ login: auth.login
674
+ }) });
675
+ }
676
+ return /* @__PURE__ */ jsx(Fragment, { children: props.authorized(auth.session) });
677
+ }
678
+
679
+ export { AuthCallbackHandler, AuthProvider, AuthSessionGate, useAuth, useAuthCallbackRedirect };
680
+ //# sourceMappingURL=react.js.map
681
+ //# sourceMappingURL=react.js.map