@synqauth/plugin-passkey 0.0.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 passkeys 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
 
@@ -17,10 +17,14 @@ import { SynqProvider, usePlugin } from '@synqauth/react'
17
17
  const passkey = passkeyPlugin()
18
18
 
19
19
  // <SynqProvider plugins={[passkey]}> ... then, in a component:
20
- const { signInUrl, addUrl, list, remove } = usePlugin(passkey)
21
- location.assign(signInUrl({ returnTo: '/account' }))
20
+ const { signIn, signInUrl, addUrl, list, remove } = usePlugin(passkey)
21
+
22
+ const { data, error } = await signIn({ returnTo: '/account' })
23
+ if (data) location.assign(data.signedIn ? data.returnTo : data.url)
22
24
  ```
23
25
 
26
+ `signIn()` asks for the passkey right on your page when the app has own-UI sign-in on and the browser can use the brand's passkeys there. Otherwise it answers the URL of the hosted page's passkey step, the same URL `signInUrl()` gives.
27
+
24
28
  `list()` and `remove(id)` go through your `@synqauth/nextjs` route mount's account API and answer `{ data, error }`.
25
29
 
26
30
  ## License
package/dist/index.d.ts CHANGED
@@ -14,8 +14,31 @@ export type PasskeyResult<T> = {
14
14
  };
15
15
  /** Calls the app's route mount, from its own page: what @synqauth/react and @synqauth/nextjs/client hand a plugin. */
16
16
  export type MountRequest = (path: string, init?: RequestInit) => Promise<Response>;
17
+ /** How `signIn()` went: signed in on the page, or the hosted page to go to. */
18
+ export type PasskeySignIn =
19
+ /** Signed in: go to `returnTo` (or refresh the page's session). */
20
+ {
21
+ readonly signedIn: true;
22
+ readonly returnTo: string;
23
+ } |
24
+ /** Go to `url`: the brand's page at its passkey step, which takes them in this browser. */
25
+ {
26
+ readonly signedIn: false;
27
+ readonly url: string;
28
+ };
17
29
  /** What the passkey plugin does from the page: where to send the person, and their passkeys. */
18
30
  export interface PasskeyActions {
31
+ /**
32
+ * Signs in with a passkey on the app's own page where the app has
33
+ * own-UI sign-in on and the browser takes the issuer's passkeys here;
34
+ * anywhere else, answers the hosted page's passkey step to go to. A
35
+ * prompt the person closed is the error `passkey_cancelled`; a passkey
36
+ * Synq didn't take is its problem (`passkey_unknown`, `passkey_failed`…).
37
+ */
38
+ signIn: (options?: {
39
+ returnTo?: string;
40
+ loginHint?: string;
41
+ }) => Promise<PasskeyResult<PasskeySignIn>>;
19
42
  /** Where to send someone to sign in with a passkey: the brand's page, at its passkey step. */
20
43
  signInUrl: (options?: {
21
44
  returnTo?: string;
package/dist/index.js CHANGED
@@ -10,26 +10,43 @@ function passkeyPlugin(options = {}) {
10
10
  for (const [name, value] of Object.entries(params)) if (value) query.set(name, value);
11
11
  return `${base}/sign-in?${query.toString()}`;
12
12
  };
13
+ const signInUrl = ({ returnTo, loginHint } = {}) => signInPath({
14
+ method: "passkey",
15
+ returnTo,
16
+ login_hint: loginHint
17
+ });
13
18
  return {
14
19
  id: PASSKEY_PLUGIN_ID,
15
20
  actions: (request) => ({
16
- signInUrl: ({ returnTo, loginHint } = {}) => signInPath({
17
- method: "passkey",
18
- returnTo,
19
- login_hint: loginHint
20
- }),
21
+ signIn: (signInOptions = {}) => signInOnPage(request, signInOptions, signInUrl),
22
+ signInUrl,
21
23
  addUrl: ({ returnTo } = {}) => signInPath({
22
24
  intent: "add-passkey",
23
25
  returnTo
24
26
  }),
25
27
  list: async () => {
26
- const result = await call(request, "account/v1/passkeys");
27
- if (result.error) return {
28
- data: null,
29
- error: result.error
30
- };
28
+ const passkeys = [];
29
+ const followed = /* @__PURE__ */ new Set();
30
+ let cursor = null;
31
+ do {
32
+ const query = new URLSearchParams({ limit: "200" });
33
+ if (cursor) query.set("cursor", cursor);
34
+ const result = await call(request, `account/v1/passkeys?${query.toString()}`);
35
+ if (result.error) return {
36
+ data: null,
37
+ error: result.error
38
+ };
39
+ passkeys.push(...result.data.data);
40
+ const next = result.data.next_cursor ?? null;
41
+ if (next !== null && (next === "" || followed.has(next))) return {
42
+ data: null,
43
+ error: new SynqError("invalid_response", "account/v1/passkeys answered a next_cursor that can't be followed")
44
+ };
45
+ if (next !== null) followed.add(next);
46
+ cursor = next;
47
+ } while (cursor !== null);
31
48
  return {
32
- data: result.data.data,
49
+ data: passkeys,
33
50
  error: null
34
51
  };
35
52
  },
@@ -46,6 +63,81 @@ function passkeyPlugin(options = {}) {
46
63
  })
47
64
  };
48
65
  }
66
+ /** A passkey on the page through the mount's `sign-in/flow`, or the hosted page's passkey step. */
67
+ async function signInOnPage(request, options, signInUrl) {
68
+ const hosted = () => ({
69
+ data: {
70
+ signedIn: false,
71
+ url: signInUrl(options)
72
+ },
73
+ error: null
74
+ });
75
+ const browser = globalThis;
76
+ const statics = browser.PublicKeyCredential;
77
+ const credentials = browser.navigator?.credentials;
78
+ if (!statics?.getClientCapabilities || !statics.parseRequestOptionsFromJSON || !credentials) return hosted();
79
+ try {
80
+ if ((await statics.getClientCapabilities()).relatedOrigins !== true) return hosted();
81
+ } catch {
82
+ return hosted();
83
+ }
84
+ const step = (body) => call(request, "sign-in/flow", {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({
88
+ ...body,
89
+ ...options.returnTo ? { returnTo: options.returnTo } : {}
90
+ })
91
+ });
92
+ const asked = await step({ step: "passkey-options" });
93
+ if (asked.error) return asked.error.status === 404 ? hosted() : {
94
+ data: null,
95
+ error: asked.error
96
+ };
97
+ if (asked.data.status !== "passkey") return answered(asked.data, hosted);
98
+ let credential;
99
+ try {
100
+ credential = await credentials.get({ publicKey: statics.parseRequestOptionsFromJSON(asked.data.passkey.options) });
101
+ } catch {
102
+ credential = null;
103
+ }
104
+ if (!credential) return {
105
+ data: null,
106
+ error: new SynqError("passkey_cancelled", "No passkey was used")
107
+ };
108
+ const taken = await step({
109
+ step: "passkey",
110
+ credential: credential.toJSON()
111
+ });
112
+ if (taken.error) return {
113
+ data: null,
114
+ error: taken.error
115
+ };
116
+ return answered(taken.data, hosted);
117
+ }
118
+ /** What the mount answered, as `signIn()` answers it. */
119
+ function answered(answer, hosted) {
120
+ switch (answer.status) {
121
+ case "signed-in": return {
122
+ data: {
123
+ signedIn: true,
124
+ returnTo: answer.returnTo
125
+ },
126
+ error: null
127
+ };
128
+ case "passkey":
129
+ case "retry":
130
+ case "refused":
131
+ case "expired": {
132
+ const code = answer.status === "passkey" ? answer.problem ?? "passkey_failed" : answer.status === "retry" ? answer.problem : answer.status === "refused" ? answer.reason : "invalid_session";
133
+ return {
134
+ data: null,
135
+ error: new SynqError(code, answer.message ?? code, { ...answer.status === "retry" && answer.retryAfter !== void 0 ? { retryAfter: answer.retryAfter } : {} })
136
+ };
137
+ }
138
+ default: return hosted();
139
+ }
140
+ }
49
141
  /** Calls the mount and reads its JSON; a problem it answers, or no answer, is the result's `error`. */
50
142
  async function call(request, path, init) {
51
143
  let response;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synqauth/plugin-passkey",
3
- "version": "0.0.0",
3
+ "version": "0.3.0",
4
4
  "description": "A Synq plugin for passkeys from your own UI.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@synqauth/js": "0.0.0"
30
+ "@synqauth/js": "0.3.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "vitest": "5.0.2"