@fluixi/oauth2 1.0.0-alpha.8 → 1.0.0-alpha.99

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.
Files changed (56) hide show
  1. package/LICENSE +1 -1
  2. package/dist/backchannel.cjs +462 -0
  3. package/dist/backchannel.d.ts +39 -0
  4. package/dist/backchannel.d.ts.map +1 -0
  5. package/dist/backchannel.js +97 -0
  6. package/dist/backchannel.mjs +307 -0
  7. package/dist/{browser-NYKDDB73.mjs → browser-LQSO5OS2.mjs} +2 -2
  8. package/dist/browser.cjs +6 -1
  9. package/dist/browser.mjs +6 -1
  10. package/dist/callback.cjs +8 -7
  11. package/dist/callback.js +1 -1
  12. package/dist/callback.mjs +3 -2
  13. package/dist/chunk-32562UUL.mjs +108 -0
  14. package/dist/{chunk-7R2PVIX6.mjs → chunk-P6SSCHQX.mjs} +1 -1
  15. package/dist/{chunk-QPL6B2YE.mjs → chunk-V27WQGTT.mjs} +1 -1
  16. package/dist/discovery.cjs +6 -1
  17. package/dist/discovery.d.ts.map +1 -1
  18. package/dist/discovery.js +6 -1
  19. package/dist/discovery.mjs +6 -1
  20. package/dist/dpop-2Q6R3KLB.mjs +22 -0
  21. package/dist/{dpop-BR4S6SCZ.mjs → dpop-4FGDUD4B.mjs} +4 -4
  22. package/dist/index.cjs +116 -8
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -0
  26. package/dist/index.mjs +119 -11
  27. package/dist/module.cjs +1 -1
  28. package/dist/module.mjs +1 -1
  29. package/dist/server.cjs +649 -137
  30. package/dist/server.d.ts +64 -4
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +376 -61
  33. package/dist/server.mjs +532 -139
  34. package/dist/service-client.cjs +7 -2
  35. package/dist/service-client.mjs +9 -4
  36. package/dist/service.cjs +112 -5
  37. package/dist/service.d.ts +0 -14
  38. package/dist/service.d.ts.map +1 -1
  39. package/dist/service.js +48 -5
  40. package/dist/service.mjs +112 -5
  41. package/dist/session-monitor.cjs +98 -0
  42. package/dist/session-monitor.d.ts +32 -0
  43. package/dist/session-monitor.d.ts.map +1 -0
  44. package/dist/session-monitor.js +104 -0
  45. package/dist/session-monitor.mjs +77 -0
  46. package/dist/tokens.cjs +1 -1
  47. package/dist/tokens.d.ts +2 -2
  48. package/dist/tokens.d.ts.map +1 -1
  49. package/dist/tokens.js +1 -1
  50. package/dist/tokens.mjs +1 -1
  51. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  52. package/dist/types.d.ts +8 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/verify.cjs +7 -2
  55. package/dist/verify.mjs +12 -7
  56. package/package.json +17 -15
@@ -1,4 +1,4 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.8 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.99 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
@@ -126,8 +126,12 @@ async function discover(issuer, fetcher = fetch) {
126
126
  try {
127
127
  response = await fetcher(documentUrl(issuer));
128
128
  } catch (cause) {
129
+ const why = cause instanceof Error ? cause.message : String(cause);
129
130
  throw new OAuthError(
130
- `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${cause instanceof Error ? cause.message : String(cause)})`,
131
+ typeof window === "undefined" ? (
132
+ // On a server nothing blocks the read: the provider is down, or the address is wrong.
133
+ `Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
134
+ ) : `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${why})`,
131
135
  "discovery_unreachable"
132
136
  );
133
137
  }
@@ -152,6 +156,7 @@ async function discover(issuer, fetcher = fetch) {
152
156
  jwks: doc.jwks_uri,
153
157
  introspection: doc.introspection_endpoint,
154
158
  revocation: doc.revocation_endpoint,
159
+ checkSession: doc.check_session_iframe,
155
160
  issuer: doc.issuer ?? issuer
156
161
  };
157
162
  })();
@@ -1,5 +1,5 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.8 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
- import "./chunk-7R2PVIX6.mjs";
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.99 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ import "./chunk-P6SSCHQX.mjs";
3
3
 
4
4
  // src/types.ts
5
5
  var OAuthError = class extends Error {
@@ -42,7 +42,7 @@ function assertUsable(auth, who) {
42
42
  }
43
43
  async function loadJose() {
44
44
  try {
45
- return await import("./browser-NYKDDB73.mjs");
45
+ return await import("./browser-LQSO5OS2.mjs");
46
46
  } catch {
47
47
  throw new OAuthError(
48
48
  "private_key_jwt needs `jose`. Install it: npm i jose. It is an optional peer, so a client using a secret does not carry it.",
@@ -92,8 +92,12 @@ async function discover(issuer, fetcher = fetch) {
92
92
  try {
93
93
  response = await fetcher(documentUrl(issuer));
94
94
  } catch (cause) {
95
+ const why = cause instanceof Error ? cause.message : String(cause);
95
96
  throw new OAuthError(
96
- `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${cause instanceof Error ? cause.message : String(cause)})`,
97
+ typeof window === "undefined" ? (
98
+ // On a server nothing blocks the read: the provider is down, or the address is wrong.
99
+ `Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
100
+ ) : `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${why})`,
97
101
  "discovery_unreachable"
98
102
  );
99
103
  }
@@ -118,6 +122,7 @@ async function discover(issuer, fetcher = fetch) {
118
122
  jwks: doc.jwks_uri,
119
123
  introspection: doc.introspection_endpoint,
120
124
  revocation: doc.revocation_endpoint,
125
+ checkSession: doc.check_session_iframe,
121
126
  issuer: doc.issuer ?? issuer
122
127
  };
123
128
  })();
package/dist/service.cjs CHANGED
@@ -34,6 +34,7 @@ __export(service_exports, {
34
34
  paramsFromUrl: () => paramsFromUrl
35
35
  });
36
36
  module.exports = __toCommonJS(service_exports);
37
+ var import_signal = require("@fluixi/reactive/signal");
37
38
  var import_session = require("@fluixi/session");
38
39
  var import_client = require("@fluixi/auth/client");
39
40
 
@@ -333,8 +334,12 @@ async function discover(issuer, fetcher = fetch) {
333
334
  try {
334
335
  response = await fetcher(documentUrl(issuer));
335
336
  } catch (cause) {
337
+ const why = cause instanceof Error ? cause.message : String(cause);
336
338
  throw new OAuthError(
337
- `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${cause instanceof Error ? cause.message : String(cause)})`,
339
+ typeof window === "undefined" ? (
340
+ // On a server nothing blocks the read: the provider is down, or the address is wrong.
341
+ `Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
342
+ ) : `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${why})`,
338
343
  "discovery_unreachable"
339
344
  );
340
345
  }
@@ -359,6 +364,7 @@ async function discover(issuer, fetcher = fetch) {
359
364
  jwks: doc.jwks_uri,
360
365
  introspection: doc.introspection_endpoint,
361
366
  revocation: doc.revocation_endpoint,
367
+ checkSession: doc.check_session_iframe,
362
368
  issuer: doc.issuer ?? issuer
363
369
  };
364
370
  })();
@@ -814,6 +820,81 @@ function createServerClient(config, fetcher = fetch) {
814
820
  };
815
821
  }
816
822
 
823
+ // src/session-monitor.ts
824
+ var SETTLE_MS = 15e3;
825
+ var GUARD_KEY = "fx-session-check";
826
+ function recentlyActed() {
827
+ try {
828
+ return Date.now() - Number(sessionStorage.getItem(GUARD_KEY) ?? 0) < SETTLE_MS;
829
+ } catch {
830
+ return false;
831
+ }
832
+ }
833
+ function markActed() {
834
+ try {
835
+ sessionStorage.setItem(GUARD_KEY, String(Date.now()));
836
+ } catch {
837
+ }
838
+ }
839
+ function watchProviderSession(options = {}) {
840
+ if (typeof window === "undefined" || typeof document === "undefined") return () => {
841
+ };
842
+ const base = (options.basePath ?? "/auth").replace(/\/$/, "");
843
+ const fetcher = options.fetcher ?? fetch;
844
+ const onChange = options.onChange ?? (() => {
845
+ const login = new URLSearchParams({ prompt: "none", returnTo: location.pathname + location.search });
846
+ location.assign(`${base}/login?${login}`);
847
+ });
848
+ let stopped = false;
849
+ const cleanups = [];
850
+ const stop = () => {
851
+ stopped = true;
852
+ for (const cleanup of cleanups.splice(0)) cleanup();
853
+ };
854
+ void (async () => {
855
+ const check = await fetcher(`${base}/session-check`, { credentials: "same-origin" }).then((response) => response.ok ? response.json() : null).catch(() => null);
856
+ if (stopped || !check?.frame || !check.state) return;
857
+ const origin = new URL(check.frame).origin;
858
+ const frame = document.createElement("iframe");
859
+ frame.src = check.frame;
860
+ frame.title = "session check";
861
+ frame.setAttribute("aria-hidden", "true");
862
+ frame.tabIndex = -1;
863
+ frame.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden";
864
+ const message = `${check.clientId} ${check.state}`;
865
+ const ask = () => {
866
+ if (!stopped && document.visibilityState === "visible") frame.contentWindow?.postMessage(message, origin);
867
+ };
868
+ let confirmed = false;
869
+ const answer = (event) => {
870
+ if (event.origin !== origin || event.source !== frame.contentWindow) return;
871
+ if (event.data === "unchanged") confirmed = true;
872
+ else if (event.data === "changed") {
873
+ stop();
874
+ if (!confirmed && recentlyActed()) return;
875
+ markActed();
876
+ onChange();
877
+ } else if (event.data === "error") {
878
+ stop();
879
+ }
880
+ };
881
+ window.addEventListener("message", answer);
882
+ frame.addEventListener("load", ask);
883
+ document.addEventListener("visibilitychange", ask);
884
+ window.addEventListener("focus", ask);
885
+ const timer = setInterval(ask, options.intervalMs ?? 3e3);
886
+ document.body.appendChild(frame);
887
+ cleanups.push(() => {
888
+ window.removeEventListener("message", answer);
889
+ document.removeEventListener("visibilitychange", ask);
890
+ window.removeEventListener("focus", ask);
891
+ clearInterval(timer);
892
+ frame.remove();
893
+ });
894
+ })();
895
+ return stop;
896
+ }
897
+
817
898
  // src/service.ts
818
899
  function paramsFromUrl(url) {
819
900
  const href = url ?? (typeof location === "undefined" ? null : location.href);
@@ -831,18 +912,28 @@ function clientFor(options) {
831
912
  return options.config.tokens === "server" ? createServerClient(options.config) : createBrowserClient(options.config);
832
913
  }
833
914
  function createAuthService(options) {
915
+ const tokens = options.config.tokens;
916
+ const scope = (0, import_signal.openReactiveScope)({ kind: "service", name: "AuthService", ...tokens ? { detail: `tokens: ${tokens}` } : {} });
917
+ const service = (0, import_signal.withReactiveScope)(scope, () => buildAuthService(options));
918
+ (0, import_signal.markReactiveScope)(service, scope);
919
+ return service;
920
+ }
921
+ function buildAuthService(options) {
834
922
  const client = clientFor(options);
835
923
  let latest = null;
924
+ const fetchUserFor = async (token) => {
925
+ if (options.fetchUser) return options.fetchUser(token);
926
+ return client.fetchUser(token === COOKIE_SESSION ? void 0 : token);
927
+ };
928
+ const resumesFromCookie = options.config.tokens === "server" && typeof window !== "undefined";
929
+ const [ready, setReady] = (0, import_signal.createSignal)(!resumesFromCookie);
836
930
  const session = (0, import_session.createSession)({
837
931
  /** The credential is the callback, so this is the code exchange. */
838
932
  async login(params) {
839
933
  latest = await client.handleCallback(params);
840
934
  return { token: latest.accessToken, expiresAt: latest.expiresAt };
841
935
  },
842
- async fetchUser(token) {
843
- if (options.fetchUser) return options.fetchUser(token);
844
- return client.fetchUser(token === COOKIE_SESSION ? void 0 : token);
845
- },
936
+ fetchUser: fetchUserFor,
846
937
  async refresh() {
847
938
  latest = await client.refresh();
848
939
  return { token: latest.accessToken, expiresAt: latest.expiresAt };
@@ -865,11 +956,27 @@ function createAuthService(options) {
865
956
  });
866
957
  const hooks = (0, import_client.createAuthHooks)({
867
958
  session,
959
+ ready,
868
960
  navigate: options.navigate,
869
961
  loginPath: options.loginPath ?? "/login",
870
962
  homePath: options.homePath ?? "/",
871
963
  forbiddenPath: options.forbiddenPath ?? "/403"
872
964
  });
965
+ if (resumesFromCookie && options.config.tokens === "server" && options.config.watchSession !== false) {
966
+ watchProviderSession({ basePath: options.config.basePath });
967
+ }
968
+ const renderedSignedOut = hooks.store.seeded && hooks.store.value().user == null;
969
+ if (resumesFromCookie && renderedSignedOut) setReady(true);
970
+ else if (resumesFromCookie) {
971
+ void (async () => {
972
+ try {
973
+ await session.adopt(COOKIE_SESSION, await fetchUserFor(COOKIE_SESSION));
974
+ } catch {
975
+ } finally {
976
+ setReady(true);
977
+ }
978
+ })();
979
+ }
873
980
  return Object.assign(session, {
874
981
  client,
875
982
  protectedRoute: hooks.protectedRoute,
package/dist/service.d.ts CHANGED
@@ -1,17 +1,3 @@
1
- /**
2
- * The application-facing half.
3
- *
4
- * `AuthClient` knows the protocol. This knows the application: who is signed in, whether
5
- * the session is still good, when to refresh, and where to send someone who is not
6
- * allowed in. It is built on `@fluixi/session`, which already owns that state machine,
7
- * rather than a second one written here.
8
- *
9
- * The mapping onto a session adapter is the part worth explaining. `createSession`'s
10
- * `login(creds)` means "turn these credentials into a token". In a redirect flow the
11
- * credential is the authorization code on the callback URL, so `login` is the code
12
- * exchange and the redirect itself is a separate call that never returns. Everything
13
- * else lines up directly: `fetchUser`, `refresh`, `logout` and `getExpiry`.
14
- */
15
1
  import { type AccessControl, type Session } from '@fluixi/session';
16
2
  import { createAuthHooks, type AuthHooksOptions } from '@fluixi/auth/client';
17
3
  import type { AuthClient, AuthConfig, CallbackParams, LoginOptions } from './types.js';
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAeA,OAAO,EAAiB,KAAK,aAAa,EAAE,KAAK,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAClF,OAAO,EAAE,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAO7E,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,cAAc,EAAE,YAAY,EAAU,MAAM,YAAY,CAAC;AAE/F,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC;AAE7C,MAAM,WAAW,kBAAkB,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,OAAO;IACvE,mDAAmD;IACnD,MAAM,EAAE,UAAU,CAAC;IACnB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAChD,+FAA+F;IAC/F,MAAM,CAAC,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAChC,qCAAqC;IACrC,WAAW,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAE/C;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,gBAAgB,CAAC,UAAU,CAAC,CAAC;IACxC,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,OAAO,CAChE,SAAQ,OAAO,CAAC,CAAC,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,EAChD,IAAI,CACF,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAC3C,gBAAgB,GAAG,KAAK,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CACxE;IACH,oFAAoF;IACpF,MAAM,CAAC,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;CAC7B;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,cAAc,CAWhE;AAUD,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,OAAO,EACrE,OAAO,EAAE,kBAAkB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GACnC,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAStB"}
package/dist/service.js CHANGED
@@ -12,12 +12,14 @@
12
12
  * exchange and the redirect itself is a separate call that never returns. Everything
13
13
  * else lines up directly: `fetchUser`, `refresh`, `logout` and `getExpiry`.
14
14
  */
15
+ import { createSignal, markReactiveScope, openReactiveScope, withReactiveScope } from '@fluixi/reactive/signal';
15
16
  import { createSession } from '@fluixi/session';
16
17
  import { createAuthHooks } from '@fluixi/auth/client';
17
18
  import { expiryFromJwt } from './pkce.js';
18
19
  import { safeReturnTo } from './return-to.js';
19
20
  import { createBrowserClient } from './browser.js';
20
21
  import { createServerClient, COOKIE_SESSION } from './server-client.js';
22
+ import { watchProviderSession } from './session-monitor.js';
21
23
  import { OAuthError } from './types.js';
22
24
  /**
23
25
  * The parameters on the current URL, which is what a callback route has.
@@ -48,21 +50,36 @@ function clientFor(options) {
48
50
  : createBrowserClient(options.config);
49
51
  }
50
52
  export function createAuthService(options) {
53
+ // One group in the devtools graph for everything the service holds: the session's `user`,
54
+ // `token`, `status` and the rest. Marked on the instance, so `authModule(auth)` places the
55
+ // group under the module that provides it.
56
+ const tokens = options.config.tokens;
57
+ const scope = openReactiveScope({ kind: 'service', name: 'AuthService', ...(tokens ? { detail: `tokens: ${tokens}` } : {}) });
58
+ const service = withReactiveScope(scope, () => buildAuthService(options));
59
+ markReactiveScope(service, scope);
60
+ return service;
61
+ }
62
+ function buildAuthService(options) {
51
63
  const client = clientFor(options);
52
64
  // Held so `getExpiry` can answer for a token the session hands back, and so a
53
65
  // server-mode session has something to report when the token is a cookie.
54
66
  let latest = null;
67
+ const fetchUserFor = async (token) => {
68
+ if (options.fetchUser)
69
+ return options.fetchUser(token);
70
+ return client.fetchUser(token === COOKIE_SESSION ? undefined : token);
71
+ };
72
+ // With the tokens on the server, the browser learns it is signed in by asking. Until it
73
+ // has, the hooks keep the user the server rendered with rather than showing nobody.
74
+ const resumesFromCookie = options.config.tokens === 'server' && typeof window !== 'undefined';
75
+ const [ready, setReady] = createSignal(!resumesFromCookie);
55
76
  const session = createSession({
56
77
  /** The credential is the callback, so this is the code exchange. */
57
78
  async login(params) {
58
79
  latest = await client.handleCallback(params);
59
80
  return { token: latest.accessToken, expiresAt: latest.expiresAt };
60
81
  },
61
- async fetchUser(token) {
62
- if (options.fetchUser)
63
- return options.fetchUser(token);
64
- return client.fetchUser(token === COOKIE_SESSION ? undefined : token);
65
- },
82
+ fetchUser: fetchUserFor,
66
83
  async refresh() {
67
84
  latest = await client.refresh();
68
85
  return { token: latest.accessToken, expiresAt: latest.expiresAt };
@@ -90,11 +107,37 @@ export function createAuthService(options) {
90
107
  // rather than a second file an app has to wire up and keep in step.
91
108
  const hooks = createAuthHooks({
92
109
  session: session,
110
+ ready,
93
111
  navigate: options.navigate,
94
112
  loginPath: options.loginPath ?? '/login',
95
113
  homePath: options.homePath ?? '/',
96
114
  forbiddenPath: options.forbiddenPath ?? '/403',
97
115
  });
116
+ // The page the server rendered already says whether anyone is signed in. When it said
117
+ // nobody, there is no session to resume: asking would only get a 401 on every anonymous
118
+ // page. A client-only load has no such answer and still asks.
119
+ // Signed in or not, a session that changes at the provider while the page is open is
120
+ // noticed here rather than at the next page load.
121
+ if (resumesFromCookie && options.config.tokens === 'server' && options.config.watchSession !== false) {
122
+ watchProviderSession({ basePath: options.config.basePath });
123
+ }
124
+ const renderedSignedOut = hooks.store.seeded && hooks.store.value().user == null;
125
+ if (resumesFromCookie && renderedSignedOut)
126
+ setReady(true);
127
+ else if (resumesFromCookie) {
128
+ void (async () => {
129
+ try {
130
+ // The user first: a cookie the server no longer accepts adopts nothing.
131
+ await session.adopt(COOKIE_SESSION, await fetchUserFor(COOKIE_SESSION));
132
+ }
133
+ catch {
134
+ // Signed out after all; the hooks drop the server's user once ready.
135
+ }
136
+ finally {
137
+ setReady(true);
138
+ }
139
+ })();
140
+ }
98
141
  return Object.assign(session, {
99
142
  client,
100
143
  protectedRoute: hooks.protectedRoute,
package/dist/service.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import "./chunk-7P6ASYW6.mjs";
2
2
 
3
3
  // src/service.ts
4
+ import { createSignal, markReactiveScope, openReactiveScope, withReactiveScope } from "@fluixi/reactive/signal";
4
5
  import { createSession } from "@fluixi/session";
5
6
  import { createAuthHooks } from "@fluixi/auth/client";
6
7
 
@@ -300,8 +301,12 @@ async function discover(issuer, fetcher = fetch) {
300
301
  try {
301
302
  response = await fetcher(documentUrl(issuer));
302
303
  } catch (cause) {
304
+ const why = cause instanceof Error ? cause.message : String(cause);
303
305
  throw new OAuthError(
304
- `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${cause instanceof Error ? cause.message : String(cause)})`,
306
+ typeof window === "undefined" ? (
307
+ // On a server nothing blocks the read: the provider is down, or the address is wrong.
308
+ `Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
309
+ ) : `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${why})`,
305
310
  "discovery_unreachable"
306
311
  );
307
312
  }
@@ -326,6 +331,7 @@ async function discover(issuer, fetcher = fetch) {
326
331
  jwks: doc.jwks_uri,
327
332
  introspection: doc.introspection_endpoint,
328
333
  revocation: doc.revocation_endpoint,
334
+ checkSession: doc.check_session_iframe,
329
335
  issuer: doc.issuer ?? issuer
330
336
  };
331
337
  })();
@@ -781,6 +787,81 @@ function createServerClient(config, fetcher = fetch) {
781
787
  };
782
788
  }
783
789
 
790
+ // src/session-monitor.ts
791
+ var SETTLE_MS = 15e3;
792
+ var GUARD_KEY = "fx-session-check";
793
+ function recentlyActed() {
794
+ try {
795
+ return Date.now() - Number(sessionStorage.getItem(GUARD_KEY) ?? 0) < SETTLE_MS;
796
+ } catch {
797
+ return false;
798
+ }
799
+ }
800
+ function markActed() {
801
+ try {
802
+ sessionStorage.setItem(GUARD_KEY, String(Date.now()));
803
+ } catch {
804
+ }
805
+ }
806
+ function watchProviderSession(options = {}) {
807
+ if (typeof window === "undefined" || typeof document === "undefined") return () => {
808
+ };
809
+ const base = (options.basePath ?? "/auth").replace(/\/$/, "");
810
+ const fetcher = options.fetcher ?? fetch;
811
+ const onChange = options.onChange ?? (() => {
812
+ const login = new URLSearchParams({ prompt: "none", returnTo: location.pathname + location.search });
813
+ location.assign(`${base}/login?${login}`);
814
+ });
815
+ let stopped = false;
816
+ const cleanups = [];
817
+ const stop = () => {
818
+ stopped = true;
819
+ for (const cleanup of cleanups.splice(0)) cleanup();
820
+ };
821
+ void (async () => {
822
+ const check = await fetcher(`${base}/session-check`, { credentials: "same-origin" }).then((response) => response.ok ? response.json() : null).catch(() => null);
823
+ if (stopped || !check?.frame || !check.state) return;
824
+ const origin = new URL(check.frame).origin;
825
+ const frame = document.createElement("iframe");
826
+ frame.src = check.frame;
827
+ frame.title = "session check";
828
+ frame.setAttribute("aria-hidden", "true");
829
+ frame.tabIndex = -1;
830
+ frame.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden";
831
+ const message = `${check.clientId} ${check.state}`;
832
+ const ask = () => {
833
+ if (!stopped && document.visibilityState === "visible") frame.contentWindow?.postMessage(message, origin);
834
+ };
835
+ let confirmed = false;
836
+ const answer = (event) => {
837
+ if (event.origin !== origin || event.source !== frame.contentWindow) return;
838
+ if (event.data === "unchanged") confirmed = true;
839
+ else if (event.data === "changed") {
840
+ stop();
841
+ if (!confirmed && recentlyActed()) return;
842
+ markActed();
843
+ onChange();
844
+ } else if (event.data === "error") {
845
+ stop();
846
+ }
847
+ };
848
+ window.addEventListener("message", answer);
849
+ frame.addEventListener("load", ask);
850
+ document.addEventListener("visibilitychange", ask);
851
+ window.addEventListener("focus", ask);
852
+ const timer = setInterval(ask, options.intervalMs ?? 3e3);
853
+ document.body.appendChild(frame);
854
+ cleanups.push(() => {
855
+ window.removeEventListener("message", answer);
856
+ document.removeEventListener("visibilitychange", ask);
857
+ window.removeEventListener("focus", ask);
858
+ clearInterval(timer);
859
+ frame.remove();
860
+ });
861
+ })();
862
+ return stop;
863
+ }
864
+
784
865
  // src/service.ts
785
866
  function paramsFromUrl(url) {
786
867
  const href = url ?? (typeof location === "undefined" ? null : location.href);
@@ -798,18 +879,28 @@ function clientFor(options) {
798
879
  return options.config.tokens === "server" ? createServerClient(options.config) : createBrowserClient(options.config);
799
880
  }
800
881
  function createAuthService(options) {
882
+ const tokens = options.config.tokens;
883
+ const scope = openReactiveScope({ kind: "service", name: "AuthService", ...tokens ? { detail: `tokens: ${tokens}` } : {} });
884
+ const service = withReactiveScope(scope, () => buildAuthService(options));
885
+ markReactiveScope(service, scope);
886
+ return service;
887
+ }
888
+ function buildAuthService(options) {
801
889
  const client = clientFor(options);
802
890
  let latest = null;
891
+ const fetchUserFor = async (token) => {
892
+ if (options.fetchUser) return options.fetchUser(token);
893
+ return client.fetchUser(token === COOKIE_SESSION ? void 0 : token);
894
+ };
895
+ const resumesFromCookie = options.config.tokens === "server" && typeof window !== "undefined";
896
+ const [ready, setReady] = createSignal(!resumesFromCookie);
803
897
  const session = createSession({
804
898
  /** The credential is the callback, so this is the code exchange. */
805
899
  async login(params) {
806
900
  latest = await client.handleCallback(params);
807
901
  return { token: latest.accessToken, expiresAt: latest.expiresAt };
808
902
  },
809
- async fetchUser(token) {
810
- if (options.fetchUser) return options.fetchUser(token);
811
- return client.fetchUser(token === COOKIE_SESSION ? void 0 : token);
812
- },
903
+ fetchUser: fetchUserFor,
813
904
  async refresh() {
814
905
  latest = await client.refresh();
815
906
  return { token: latest.accessToken, expiresAt: latest.expiresAt };
@@ -832,11 +923,27 @@ function createAuthService(options) {
832
923
  });
833
924
  const hooks = createAuthHooks({
834
925
  session,
926
+ ready,
835
927
  navigate: options.navigate,
836
928
  loginPath: options.loginPath ?? "/login",
837
929
  homePath: options.homePath ?? "/",
838
930
  forbiddenPath: options.forbiddenPath ?? "/403"
839
931
  });
932
+ if (resumesFromCookie && options.config.tokens === "server" && options.config.watchSession !== false) {
933
+ watchProviderSession({ basePath: options.config.basePath });
934
+ }
935
+ const renderedSignedOut = hooks.store.seeded && hooks.store.value().user == null;
936
+ if (resumesFromCookie && renderedSignedOut) setReady(true);
937
+ else if (resumesFromCookie) {
938
+ void (async () => {
939
+ try {
940
+ await session.adopt(COOKIE_SESSION, await fetchUserFor(COOKIE_SESSION));
941
+ } catch {
942
+ } finally {
943
+ setReady(true);
944
+ }
945
+ })();
946
+ }
840
947
  return Object.assign(session, {
841
948
  client,
842
949
  protectedRoute: hooks.protectedRoute,
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/session-monitor.ts
21
+ var session_monitor_exports = {};
22
+ __export(session_monitor_exports, {
23
+ watchProviderSession: () => watchProviderSession
24
+ });
25
+ module.exports = __toCommonJS(session_monitor_exports);
26
+ var SETTLE_MS = 15e3;
27
+ var GUARD_KEY = "fx-session-check";
28
+ function recentlyActed() {
29
+ try {
30
+ return Date.now() - Number(sessionStorage.getItem(GUARD_KEY) ?? 0) < SETTLE_MS;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ function markActed() {
36
+ try {
37
+ sessionStorage.setItem(GUARD_KEY, String(Date.now()));
38
+ } catch {
39
+ }
40
+ }
41
+ function watchProviderSession(options = {}) {
42
+ if (typeof window === "undefined" || typeof document === "undefined") return () => {
43
+ };
44
+ const base = (options.basePath ?? "/auth").replace(/\/$/, "");
45
+ const fetcher = options.fetcher ?? fetch;
46
+ const onChange = options.onChange ?? (() => {
47
+ const login = new URLSearchParams({ prompt: "none", returnTo: location.pathname + location.search });
48
+ location.assign(`${base}/login?${login}`);
49
+ });
50
+ let stopped = false;
51
+ const cleanups = [];
52
+ const stop = () => {
53
+ stopped = true;
54
+ for (const cleanup of cleanups.splice(0)) cleanup();
55
+ };
56
+ void (async () => {
57
+ const check = await fetcher(`${base}/session-check`, { credentials: "same-origin" }).then((response) => response.ok ? response.json() : null).catch(() => null);
58
+ if (stopped || !check?.frame || !check.state) return;
59
+ const origin = new URL(check.frame).origin;
60
+ const frame = document.createElement("iframe");
61
+ frame.src = check.frame;
62
+ frame.title = "session check";
63
+ frame.setAttribute("aria-hidden", "true");
64
+ frame.tabIndex = -1;
65
+ frame.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden";
66
+ const message = `${check.clientId} ${check.state}`;
67
+ const ask = () => {
68
+ if (!stopped && document.visibilityState === "visible") frame.contentWindow?.postMessage(message, origin);
69
+ };
70
+ let confirmed = false;
71
+ const answer = (event) => {
72
+ if (event.origin !== origin || event.source !== frame.contentWindow) return;
73
+ if (event.data === "unchanged") confirmed = true;
74
+ else if (event.data === "changed") {
75
+ stop();
76
+ if (!confirmed && recentlyActed()) return;
77
+ markActed();
78
+ onChange();
79
+ } else if (event.data === "error") {
80
+ stop();
81
+ }
82
+ };
83
+ window.addEventListener("message", answer);
84
+ frame.addEventListener("load", ask);
85
+ document.addEventListener("visibilitychange", ask);
86
+ window.addEventListener("focus", ask);
87
+ const timer = setInterval(ask, options.intervalMs ?? 3e3);
88
+ document.body.appendChild(frame);
89
+ cleanups.push(() => {
90
+ window.removeEventListener("message", answer);
91
+ document.removeEventListener("visibilitychange", ask);
92
+ window.removeEventListener("focus", ask);
93
+ clearInterval(timer);
94
+ frame.remove();
95
+ });
96
+ })();
97
+ return stop;
98
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * OpenID Connect Session Management, the app's half.
3
+ *
4
+ * The provider's check page sits in a hidden iframe. The page posts it `client_id` and the
5
+ * `session_state` of the last authorization answer; the check page recomputes that value
6
+ * from the provider's own cookie and answers `changed` or `unchanged`, with no request.
7
+ * `changed` means someone signed in, out, or switched account at the provider since: the
8
+ * app asks again silently, which signs it in, out, or as the other account.
9
+ *
10
+ * Only on the same site as the provider, or where the browser lets a framed page read its
11
+ * cookies. Elsewhere the check page sees no cookie and answers `changed` at once, every
12
+ * time: the guard below tells that apart from a real change, which follows an `unchanged`.
13
+ */
14
+ export interface SessionCheck {
15
+ /** The provider's check page, or null when it has none. */
16
+ frame: string | null;
17
+ clientId: string;
18
+ /** The last answer's `session_state`, or null when there is nothing to compare. */
19
+ state: string | null;
20
+ }
21
+ export interface WatchOptions {
22
+ /** Where the server routes are mounted. Default `/auth`. */
23
+ basePath?: string;
24
+ /** How often to ask while the page is visible. Default 3 seconds. */
25
+ intervalMs?: number;
26
+ /** What a change does. Default: a silent sign-in that returns to this page. */
27
+ onChange?: () => void;
28
+ fetcher?: typeof fetch;
29
+ }
30
+ /** Starts watching; returns a function that stops. Does nothing outside a browser. */
31
+ export declare function watchProviderSession(options?: WatchOptions): () => void;
32
+ //# sourceMappingURL=session-monitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-monitor.d.ts","sourceRoot":"","sources":["../src/session-monitor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,YAAY;IAC3B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,mFAAmF;IACnF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACxB;AAyBD,sFAAsF;AACtF,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,YAAiB,GAAG,MAAM,IAAI,CAiE3E"}