@synqauth/plugin-siws 0.2.0 → 0.3.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 CHANGED
@@ -4,7 +4,7 @@ A Synq plugin for Sign In With Solana from your own UI.
4
4
 
5
5
  Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with Solana wallet, social, email and passkey sign-in. The documentation is at [docs.synq.build](https://docs.synq.build).
6
6
 
7
- This package is at 0.x and hasn't been published yet: its API can change in any release until Synq's first customer depends on it.
7
+ This package is at 0.x, so a minor release can change its API: see its CHANGELOG.md.
8
8
 
9
9
  ## Usage
10
10
 
@@ -26,9 +26,14 @@ import { useSolanaWallets } from '@synqauth/solana/react'
26
26
 
27
27
  const siws = siwsPlugin().actions(mountRequest())
28
28
  const wallets = useSolanaWallets()
29
+ // When the sign-in comes into view: the issuer's request, before the tap.
30
+ useEffect(() => void siws.prepare(), [])
31
+ // On the tap: straight to the wallet.
29
32
  const { error } = await siws.signIn(wallets[0], { returnTo: '/account' })
30
33
  ```
31
34
 
35
+ `prepare()` asks for the request the wallet signs before the tap and keeps it fresh, so the tap reaches the wallet with nothing fetched first: Safari blocks a wallet's prompt that opens after a network wait.
36
+
32
37
  The wallet signs the issuer's own request on your page, and your server pushes the signature to Synq, which checks it. Your server verifies nothing.
33
38
 
34
39
  ## License
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SynqError } from "@synqauth/js";
2
- import { Wallet, WalletAccount } from "@synqauth/solana";
2
+ import { SignInRequest, Wallet, WalletAccount } from "@synqauth/solana";
3
3
  //#region src/index.d.ts
4
4
  /** The plugin's id: its server half's routes are under it, and its actions under it in `usePlugin`. */
5
5
  export declare const SIWS_PLUGIN_ID = "siws";
@@ -15,20 +15,56 @@ export type SiwsResult<T> = {
15
15
  export type MountRequest = (path: string, init?: RequestInit) => Promise<Response>;
16
16
  /** What `signIn` takes besides the wallet. */
17
17
  export interface SiwsSignInOptions {
18
- /** Where on the app the person lands afterwards. */
18
+ /**
19
+ * Where on the app the person lands afterwards: the browser goes there
20
+ * once signed in. Left out, a sign-in that finishes in place stays on the
21
+ * page.
22
+ */
19
23
  returnTo?: string;
20
24
  /** The account to sign with; the wallet's choice when left out. */
21
25
  account?: WalletAccount;
26
+ /**
27
+ * The issuer's request to sign, from `prepare()`. Left out, the one
28
+ * `prepare()` keeps is used when it is fresh. Either way, a fresh request
29
+ * means nothing is fetched between the tap and the wallet; a missing or
30
+ * stale one is asked for first, and the wallet is asked once.
31
+ */
32
+ request?: SignInRequest | null;
33
+ }
34
+ /** How a wallet's sign-in went. */
35
+ export interface SiwsSignedIn {
36
+ /**
37
+ * True when it finished in place, with no page (AERO-2681): the session
38
+ * is there, and the page refreshes what it shows of it
39
+ * (`useSession().refresh()`, `router.refresh()`). False when the browser
40
+ * is on its way through Synq, the redirect way.
41
+ */
42
+ readonly inPlace: boolean;
43
+ /** Where the sign-in was to land, on the app. */
44
+ readonly returnTo: string | null;
22
45
  }
23
46
  /** The browser half's actions. */
24
47
  export interface SiwsActions {
25
48
  /**
26
49
  * Signs in with `wallet`: the issuer's request, the wallet's signature,
27
- * then off to Synq and back to `returnTo`. Answers `{ data, error }`
28
- * instead of navigating when something fails: `wallet_rejected` when the
29
- * person declines.
50
+ * then the session, in place where the app can sign in on its own
51
+ * screens, else through Synq and back to `returnTo`. Answers
52
+ * `{ data, error }` and never navigates when something fails:
53
+ * `wallet_rejected` when the person declines, `wallet_failed` or
54
+ * `wallet_expired` when the signature didn't count, `access_denied` when
55
+ * this person can't sign in here, `redirect_to_web` (its `cause` says
56
+ * why) when only the hosted page can go on: a token gate, a step-up.
57
+ */
58
+ signIn: (wallet: Wallet, options?: SiwsSignInOptions) => Promise<SiwsResult<SiwsSignedIn>>;
59
+ /**
60
+ * Asks the issuer for the request `signIn` signs, before the tap, and
61
+ * answers it (AERO-2679): Safari lets a wallet's popup open only while the
62
+ * tap's user activation lasts, and drops it after a short network wait,
63
+ * so call this when the sign-in comes into view. The request is kept
64
+ * fresh, asked for again about a minute before it expires, until a
65
+ * sign-in goes through or ten minutes pass with no other `prepare()`.
30
66
  */
31
- signIn: (wallet: Wallet, options?: SiwsSignInOptions) => Promise<SiwsResult<null>>;
67
+ prepare: () => Promise<SiwsResult<SignInRequest>>;
32
68
  }
33
69
  /** The SIWS plugin's browser half, in `SynqClientPlugin`'s shape: for `<SynqProvider plugins>` or `@synqauth/nextjs/client`. */
34
70
  export interface SiwsPlugin {
@@ -42,6 +78,8 @@ export interface SiwsPluginOptions {
42
78
  /** The `fetch` the page asks the issuer with: the global one by default. */
43
79
  fetch?: typeof fetch;
44
80
  }
81
+ /** How long `prepare()` keeps a request fresh with no sign-in and no other `prepare()`. */
82
+ export declare const PREPARED_FOR_MS: number;
45
83
  /** The SIWS plugin's browser half: a wallet's sign-in from the app's own page, through the route mount. */
46
84
  export declare function siwsPlugin(options?: SiwsPluginOptions): SiwsPlugin;
47
85
  //#endregion
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SynqError, problemError } from "@synqauth/js";
2
- import { encodeSiws, requestSignIn, signWithWallet } from "@synqauth/solana";
2
+ import { encodeSiws, isFreshRequest, prepareSignIn, requestSignIn, signWithWallet } from "@synqauth/solana";
3
3
  //#region src/index.ts
4
4
  /** The plugin's id: its server half's routes are under it, and its actions under it in `usePlugin`. */
5
5
  const SIWS_PLUGIN_ID = "siws";
@@ -14,43 +14,156 @@ async function mountJson(request, path, init) {
14
14
  if (!response.ok) throw problemError(response.status, json !== null && typeof json === "object" ? json : {});
15
15
  return json;
16
16
  }
17
+ /** The issuer and app to ask for a request: the mount's own route, else an older mount's server half. */
18
+ async function requestTarget(request) {
19
+ try {
20
+ return await mountJson(request, "sign-in/wallet");
21
+ } catch (error) {
22
+ if (!(error instanceof SynqError) || error.status !== 404) throw error;
23
+ return mountJson(request, `${SIWS_PLUGIN_ID}/config`);
24
+ }
25
+ }
26
+ /**
27
+ * Why only the hosted page goes on, where the wallet's step never ran and
28
+ * its signature is unspent: the redirect way takes the same one.
29
+ */
30
+ const NOT_RUN = /* @__PURE__ */ new Set(["own_ui_off", "paused"]);
31
+ /**
32
+ * Whether `sign-in/flow` failed where the wallet's step provably never
33
+ * reached the issuer, so the signature is unspent and the redirect way can
34
+ * take it: a mount with no such route (404), an older mount that refused
35
+ * the wallet step before asking anything (400 `invalid_request`), a mount
36
+ * that can't sign in on the app's own screens (`invalid_options`: no client
37
+ * secret), or an issuer that refused the app before any method ran
38
+ * (`unauthorized_client`: not a confidential first-party web app). Anything
39
+ * else, the issuer may have spent the signature: answered, never navigated.
40
+ */
41
+ function neverRan(error) {
42
+ if (error.status === 404) return true;
43
+ if (error.status === 400 && error.code === "invalid_request") return true;
44
+ return error.code === "invalid_options" || error.code === "unauthorized_client";
45
+ }
46
+ /** How long `prepare()` keeps a request fresh with no sign-in and no other `prepare()`. */
47
+ const PREPARED_FOR_MS = 6e5;
48
+ /** Any failure as a `SynqError`. */
49
+ function asSynqError(error) {
50
+ return error instanceof SynqError ? error : new SynqError("network_error", String(error), { cause: error });
51
+ }
17
52
  /** The SIWS plugin's browser half: a wallet's sign-in from the app's own page, through the route mount. */
18
53
  function siwsPlugin(options = {}) {
19
54
  const navigate = options.navigate ?? ((url) => {
20
55
  globalThis.location?.assign(url);
21
56
  });
57
+ let mount = null;
58
+ const prepared = prepareSignIn(async () => {
59
+ if (!mount) throw new SynqError("invalid_options", "The SIWS plugin has no route mount yet");
60
+ const { issuer, clientId } = await requestTarget(mount);
61
+ return requestSignIn({
62
+ issuer,
63
+ clientId,
64
+ fetch: options.fetch
65
+ });
66
+ });
67
+ let release = null;
68
+ let letGo;
69
+ const stopKeeping = () => {
70
+ clearTimeout(letGo);
71
+ release?.();
72
+ release = null;
73
+ };
22
74
  return {
23
75
  id: SIWS_PLUGIN_ID,
24
- actions: (request) => ({ signIn: async (wallet, signInOptions = {}) => {
25
- try {
26
- const { issuer, clientId } = await mountJson(request, `${SIWS_PLUGIN_ID}/config`);
27
- const signInRequest = await requestSignIn({
28
- issuer,
29
- clientId,
30
- fetch: options.fetch
31
- });
32
- const signed = await signWithWallet(wallet, signInRequest, signInOptions.account);
33
- const { redirect } = await mountJson(request, "sign-in", {
34
- method: "POST",
35
- headers: { "content-type": "application/json" },
36
- body: JSON.stringify({
37
- siws: encodeSiws(signed),
38
- returnTo: signInOptions.returnTo
39
- })
40
- });
41
- navigate(redirect);
42
- return {
43
- data: null,
44
- error: null
45
- };
46
- } catch (error) {
47
- return {
48
- data: null,
49
- error: error instanceof SynqError ? error : new SynqError("network_error", String(error), { cause: error })
76
+ actions: (request) => ({
77
+ prepare: async () => {
78
+ mount = request;
79
+ release ??= prepared.hold();
80
+ clearTimeout(letGo);
81
+ letGo = setTimeout(stopKeeping, PREPARED_FOR_MS);
82
+ try {
83
+ return {
84
+ data: await prepared.ready(),
85
+ error: null
86
+ };
87
+ } catch (error) {
88
+ return {
89
+ data: null,
90
+ error: asSynqError(error)
91
+ };
92
+ }
93
+ },
94
+ signIn: async (wallet, signInOptions = {}) => {
95
+ mount = request;
96
+ const { returnTo } = signInOptions;
97
+ /** The redirect way: pushed to Synq by the mount, the browser following. */
98
+ const redirectWay = async (siws) => {
99
+ const { redirect } = await mountJson(request, "sign-in", {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify({
103
+ siws,
104
+ returnTo
105
+ })
106
+ });
107
+ stopKeeping();
108
+ navigate(redirect);
109
+ return {
110
+ data: {
111
+ inPlace: false,
112
+ returnTo: returnTo ?? null
113
+ },
114
+ error: null
115
+ };
50
116
  };
117
+ try {
118
+ const passed = signInOptions.request;
119
+ const signInRequest = passed && passed === prepared.peek() ? prepared.take() ?? passed : isFreshRequest(passed) ? passed : prepared.take() ?? await prepared.next();
120
+ const signed = await signWithWallet(wallet, signInRequest, signInOptions.account);
121
+ const siws = encodeSiws(signed);
122
+ let answer;
123
+ try {
124
+ answer = await mountJson(request, "sign-in/flow", {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json" },
127
+ body: JSON.stringify({
128
+ step: "wallet",
129
+ siws,
130
+ returnTo
131
+ })
132
+ });
133
+ } catch (error) {
134
+ if (error instanceof SynqError && neverRan(error)) return await redirectWay(siws);
135
+ throw error;
136
+ }
137
+ switch (answer.status) {
138
+ case "signed-in":
139
+ stopKeeping();
140
+ if (returnTo) navigate(answer.returnTo);
141
+ return {
142
+ data: {
143
+ inPlace: true,
144
+ returnTo: answer.returnTo
145
+ },
146
+ error: null
147
+ };
148
+ case "hosted":
149
+ if (NOT_RUN.has(answer.hosted.reason)) return await redirectWay(siws);
150
+ throw new SynqError("redirect_to_web", "Only the hosted page can go on with this sign-in", { cause: answer.hosted });
151
+ case "retry": throw new SynqError(answer.problem, answer.message, {
152
+ description: answer.message,
153
+ ...answer.retryAfter === void 0 ? {} : { retryAfter: answer.retryAfter }
154
+ });
155
+ case "refused": throw new SynqError("access_denied", answer.message, { cause: { reason: answer.reason } });
156
+ case "expired": throw new SynqError("invalid_session", answer.message);
157
+ }
158
+ } catch (error) {
159
+ return {
160
+ data: null,
161
+ error: asSynqError(error)
162
+ };
163
+ }
51
164
  }
52
- } })
165
+ })
53
166
  };
54
167
  }
55
168
  //#endregion
56
- export { SIWS_PLUGIN_ID, siwsPlugin };
169
+ export { PREPARED_FOR_MS, SIWS_PLUGIN_ID, siwsPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synqauth/plugin-siws",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A Synq plugin for Sign In With Solana from your own UI.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,8 +31,8 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@synqauth/js": "0.2.0",
35
- "@synqauth/solana": "0.2.0"
34
+ "@synqauth/js": "0.3.0",
35
+ "@synqauth/solana": "0.3.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "vitest": "5.0.2"