@multiplatform.one/keycloak 7.6.3 → 7.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * How we ask Better Auth's `/get-access-token` for a provider access token.
3
+ *
4
+ * Better Auth changed this body between minors and the two shapes are
5
+ * mutually exclusive:
6
+ *
7
+ * <=1.6 body: z.object({ providerId, accountId?, userId? })
8
+ * >=1.7 body: z.union([ z.strictObject({ accountId, userId? }),
9
+ * z.strictObject({ useAccountCookie: true, userId? }) ])
10
+ *
11
+ * 1.7 dropped `providerId` (it survives only as a response field) and made
12
+ * both arms STRICT, so a 1.6 body fails 1.7 outright; and 1.6 requires
13
+ * `providerId`, so a 1.7 body fails 1.6. No single body satisfies both.
14
+ *
15
+ * We are a library. The server version is whatever the consuming app's
16
+ * lockfile resolved, and we do not get to know it — which is exactly how this
17
+ * broke in the first place: the client kept sending the 1.6 body while an
18
+ * app's `^1.6.9` floated its deployed server to 1.7, every token fetch 400'd
19
+ * at body validation, and the Frappe layer fell back to guest cookies while
20
+ * the UI still said the user was signed in.
21
+ *
22
+ * So negotiate instead of guessing: send the current shape, and on a body
23
+ * validation refusal send the legacy one, remembering the answer for the rest
24
+ * of the process. At most one extra request per process, and an app that
25
+ * upgrades or downgrades Better Auth keeps working with no code change.
26
+ *
27
+ * The cookie arm is the only 1.7 arm we can use: `createAuth` runs stateless
28
+ * with `account.storeAccountCookie`, so there is no account row to address by
29
+ * id.
30
+ */
31
+
32
+ export type AccessTokenSelection =
33
+ | { useAccountCookie: true; userId?: string }
34
+ | { providerId: string; userId?: string };
35
+
36
+ export type AccessTokenResponse = {
37
+ status: number;
38
+ body: { code?: string } | null;
39
+ };
40
+
41
+ /** Better Auth's code for a body that failed the endpoint's schema. */
42
+ const validationErrorCode = "VALIDATION_ERROR";
43
+
44
+ /** Which body shape this server accepted, once we know. */
45
+ let known: "current" | "legacy" | undefined;
46
+
47
+ /** Forget the negotiated shape (tests, and a server swapped under a client). */
48
+ export function resetAccessTokenSelection() {
49
+ known = undefined;
50
+ }
51
+
52
+ /** The >=1.7 body: the account comes from its signed cookie. */
53
+ export function currentSelection(userId?: string): AccessTokenSelection {
54
+ return userId ? { useAccountCookie: true, userId } : { useAccountCookie: true };
55
+ }
56
+
57
+ /** The <=1.6 body: the account is chosen by provider id. */
58
+ export function legacySelection(providerId: string, userId?: string): AccessTokenSelection {
59
+ return userId ? { providerId, userId } : { providerId };
60
+ }
61
+
62
+ /**
63
+ * True for "this server does not understand that body" — the only answer
64
+ * worth retrying with the other shape. A 401, a 403 or a dead refresh token
65
+ * are real answers about the session, not about the wire format.
66
+ */
67
+ export function isBodyRefusal(res: AccessTokenResponse): boolean {
68
+ return res.status === 400 && res.body?.code === validationErrorCode;
69
+ }
70
+
71
+ /**
72
+ * Ask for an access token, negotiating the body shape.
73
+ *
74
+ * `send` posts one body and reports the status plus the parsed JSON (null
75
+ * when there is no JSON to parse). It is called at most twice, and only twice
76
+ * on the first call of a process against a server whose shape we have not
77
+ * learned yet.
78
+ */
79
+ export async function requestAccessToken(
80
+ providerId: string,
81
+ send: (body: AccessTokenSelection) => Promise<AccessTokenResponse>,
82
+ userId?: string,
83
+ ): Promise<AccessTokenResponse> {
84
+ const current = () => currentSelection(userId);
85
+ const legacy = () => legacySelection(providerId, userId);
86
+
87
+ if (known === "legacy") return send(legacy());
88
+ if (known === "current") return send(current());
89
+
90
+ const first = await send(current());
91
+ if (!isBodyRefusal(first)) {
92
+ if (first.status !== 400) known = "current";
93
+ return first;
94
+ }
95
+
96
+ const second = await send(legacy());
97
+ if (!isBodyRefusal(second)) known = "legacy";
98
+ return second;
99
+ }
@@ -16,6 +16,7 @@ import { Platform } from "react-native";
16
16
  import * as Linking from "expo-linking";
17
17
  import * as SecureStore from "expo-secure-store";
18
18
  import * as WebBrowser from "expo-web-browser";
19
+ import { requestAccessToken } from "./accountSelection";
19
20
 
20
21
  try {
21
22
  WebBrowser.maybeCompleteAuthSession?.();
@@ -104,7 +105,13 @@ export interface ExpoAuthFlow {
104
105
  */
105
106
  signOut(postLogoutRedirectUri?: string): Promise<void>;
106
107
  getSession(): Promise<{ user?: { id: string; email?: string; name?: string } } | null>;
107
- /** Valid provider access token (server refreshes when expired). */
108
+ /**
109
+ * Valid provider access token (server refreshes when expired).
110
+ *
111
+ * `providerId` is kept for call-site readability only. The server picks the
112
+ * account from its signed account cookie, which is the sole selector a
113
+ * stateless Better Auth deployment has — see `accountSelection.ts`.
114
+ */
108
115
  getAccessToken(
109
116
  providerId: string,
110
117
  ): Promise<{ accessToken?: string; accessTokenExpiresAt?: string } | null>;
@@ -356,12 +363,17 @@ export function createExpoAuthFlow(baseURL: string, scheme?: string): ExpoAuthFl
356
363
  },
357
364
 
358
365
  async getAccessToken(providerId: string) {
359
- const res = await authFetch("/get-access-token", {
360
- method: "POST",
361
- body: JSON.stringify({ providerId }),
366
+ let payload: unknown = null;
367
+ const res = await requestAccessToken(providerId, async (body) => {
368
+ const response = await authFetch("/get-access-token", {
369
+ method: "POST",
370
+ body: JSON.stringify(body),
371
+ });
372
+ payload = await response.json().catch(() => null);
373
+ return { status: response.status, body: payload as { code?: string } | null };
362
374
  });
363
- if (!res.ok) return null;
364
- return (await res.json().catch(() => null)) as {
375
+ if (res.status < 200 || res.status >= 300) return null;
376
+ return payload as {
365
377
  accessToken?: string;
366
378
  accessTokenExpiresAt?: string;
367
379
  } | null;
@@ -19,6 +19,7 @@
19
19
  * backoff, keep the session. A Keycloak restart must not look like logout.
20
20
  */
21
21
 
22
+ import { requestAccessToken } from "./betterAuth/accountSelection";
22
23
  import { getAuthClient } from "./betterAuth/client";
23
24
 
24
25
  let cached: { token: string; expiresAt: number } | undefined;
@@ -92,17 +93,21 @@ async function fetchTokenOnce(): Promise<
92
93
  { ok: true; token: string; expiresAt: number } | { ok: false; kind: FailureKind }
93
94
  > {
94
95
  try {
95
- const res = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
96
- method: "POST",
97
- credentials: "include",
98
- headers: { "Content-Type": "application/json" },
99
- body: JSON.stringify({ providerId: "keycloak" }),
96
+ let payload: unknown = null;
97
+ const res = await requestAccessToken("keycloak", async (selection) => {
98
+ const response = await fetch(`${window.location.origin}/api/auth/get-access-token`, {
99
+ method: "POST",
100
+ credentials: "include",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify(selection),
103
+ });
104
+ payload = await response.json().catch(() => null);
105
+ return { status: response.status, body: payload as { code?: string } | null };
100
106
  });
101
- if (!res.ok) {
102
- const body = (await res.json().catch(() => null)) as { code?: string } | null;
103
- return { ok: false, kind: classifyAccessTokenResponse(res.status, body) };
107
+ if (res.status < 200 || res.status >= 300) {
108
+ return { ok: false, kind: classifyAccessTokenResponse(res.status, res.body) };
104
109
  }
105
- const data = (await res.json().catch(() => null)) as {
110
+ const data = payload as {
106
111
  accessToken?: string;
107
112
  accessTokenExpiresAt?: string;
108
113
  } | null;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * How we ask Better Auth's `/get-access-token` for a provider access token.
3
+ *
4
+ * Better Auth changed this body between minors and the two shapes are
5
+ * mutually exclusive:
6
+ *
7
+ * <=1.6 body: z.object({ providerId, accountId?, userId? })
8
+ * >=1.7 body: z.union([ z.strictObject({ accountId, userId? }),
9
+ * z.strictObject({ useAccountCookie: true, userId? }) ])
10
+ *
11
+ * 1.7 dropped `providerId` (it survives only as a response field) and made
12
+ * both arms STRICT, so a 1.6 body fails 1.7 outright; and 1.6 requires
13
+ * `providerId`, so a 1.7 body fails 1.6. No single body satisfies both.
14
+ *
15
+ * We are a library. The server version is whatever the consuming app's
16
+ * lockfile resolved, and we do not get to know it — which is exactly how this
17
+ * broke in the first place: the client kept sending the 1.6 body while an
18
+ * app's `^1.6.9` floated its deployed server to 1.7, every token fetch 400'd
19
+ * at body validation, and the Frappe layer fell back to guest cookies while
20
+ * the UI still said the user was signed in.
21
+ *
22
+ * So negotiate instead of guessing: send the current shape, and on a body
23
+ * validation refusal send the legacy one, remembering the answer for the rest
24
+ * of the process. At most one extra request per process, and an app that
25
+ * upgrades or downgrades Better Auth keeps working with no code change.
26
+ *
27
+ * The cookie arm is the only 1.7 arm we can use: `createAuth` runs stateless
28
+ * with `account.storeAccountCookie`, so there is no account row to address by
29
+ * id.
30
+ */
31
+ export type AccessTokenSelection = {
32
+ useAccountCookie: true;
33
+ userId?: string;
34
+ } | {
35
+ providerId: string;
36
+ userId?: string;
37
+ };
38
+ export type AccessTokenResponse = {
39
+ status: number;
40
+ body: {
41
+ code?: string;
42
+ } | null;
43
+ };
44
+ /** Forget the negotiated shape (tests, and a server swapped under a client). */
45
+ export declare function resetAccessTokenSelection(): void;
46
+ /** The >=1.7 body: the account comes from its signed cookie. */
47
+ export declare function currentSelection(userId?: string): AccessTokenSelection;
48
+ /** The <=1.6 body: the account is chosen by provider id. */
49
+ export declare function legacySelection(providerId: string, userId?: string): AccessTokenSelection;
50
+ /**
51
+ * True for "this server does not understand that body" — the only answer
52
+ * worth retrying with the other shape. A 401, a 403 or a dead refresh token
53
+ * are real answers about the session, not about the wire format.
54
+ */
55
+ export declare function isBodyRefusal(res: AccessTokenResponse): boolean;
56
+ /**
57
+ * Ask for an access token, negotiating the body shape.
58
+ *
59
+ * `send` posts one body and reports the status plus the parsed JSON (null
60
+ * when there is no JSON to parse). It is called at most twice, and only twice
61
+ * on the first call of a process against a server whose shape we have not
62
+ * learned yet.
63
+ */
64
+ export declare function requestAccessToken(providerId: string, send: (body: AccessTokenSelection) => Promise<AccessTokenResponse>, userId?: string): Promise<AccessTokenResponse>;
65
+ //# sourceMappingURL=accountSelection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accountSelection.d.ts","sourceRoot":"","sources":["../../src/betterAuth/accountSelection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,MAAM,oBAAoB,GAC5B;IAAE,gBAAgB,EAAE,IAAI,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3C;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CAChC,CAAC;AAQF,gFAAgF;AAChF,wBAAgB,yBAAyB,SAExC;AAED,gEAAgE;AAChE,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAEtE;AAED,4DAA4D;AAC5D,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAEzF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAE/D;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,CAAC,IAAI,EAAE,oBAAoB,KAAK,OAAO,CAAC,mBAAmB,CAAC,EAClE,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,mBAAmB,CAAC,CAgB9B"}
@@ -28,7 +28,13 @@ export interface ExpoAuthFlow {
28
28
  name?: string;
29
29
  };
30
30
  } | null>;
31
- /** Valid provider access token (server refreshes when expired). */
31
+ /**
32
+ * Valid provider access token (server refreshes when expired).
33
+ *
34
+ * `providerId` is kept for call-site readability only. The server picks the
35
+ * account from its signed account cookie, which is the sole selector a
36
+ * stateless Better Auth deployment has — see `accountSelection.ts`.
37
+ */
32
38
  getAccessToken(providerId: string): Promise<{
33
39
  accessToken?: string;
34
40
  accessTokenExpiresAt?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"expoAuthFlow.native.d.ts","sourceRoot":"","sources":["../../src/betterAuth/expoAuthFlow.native.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAmFH,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,OAAO,CAAC,qBAAqB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,UAAU,IAAI,OAAO,CAAC;QAAE,IAAI,CAAC,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACvF,mEAAmE;IACnE,cAAc,CACZ,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CAC5E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAGvE;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsB3E;AAqED,wBAAgB,qBAAqB,IAAI,YAAY,GAAG,IAAI,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,CAgHjF"}
1
+ {"version":3,"file":"expoAuthFlow.native.d.ts","sourceRoot":"","sources":["../../src/betterAuth/expoAuthFlow.native.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoFH,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,OAAO,CAAC,qBAAqB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,UAAU,IAAI,OAAO,CAAC;QAAE,IAAI,CAAC,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACvF;;;;;;OAMG;IACH,cAAc,CACZ,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CAC5E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAGvE;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsB3E;AAqED,wBAAgB,qBAAqB,IAAI,YAAY,GAAG,IAAI,CAE3D;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,CAqHjF"}
@@ -1 +1 @@
1
- {"version":3,"file":"frappeToken.d.ts","sourceRoot":"","sources":["../src/frappeToken.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAgDH;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAW1E;AA4DD,wDAAwD;AACxD,wBAAgB,wBAAwB,SAKvC;AAED;;;;GAIG;AACH,wBAAgB,kCAAkC,IAAI,MAAM,IAAI,CAiB/D"}
1
+ {"version":3,"file":"frappeToken.d.ts","sourceRoot":"","sources":["../src/frappeToken.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAiDH;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAW1E;AAgED,wDAAwD;AACxD,wBAAgB,wBAAwB,SAKvC;AAED;;;;GAIG;AACH,wBAAgB,kCAAkC,IAAI,MAAM,IAAI,CAiB/D"}