@synqauth/plugin-passkey 0.0.0 → 0.2.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
@@ -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,14 +10,16 @@ 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
@@ -46,6 +48,81 @@ function passkeyPlugin(options = {}) {
46
48
  })
47
49
  };
48
50
  }
51
+ /** A passkey on the page through the mount's `sign-in/flow`, or the hosted page's passkey step. */
52
+ async function signInOnPage(request, options, signInUrl) {
53
+ const hosted = () => ({
54
+ data: {
55
+ signedIn: false,
56
+ url: signInUrl(options)
57
+ },
58
+ error: null
59
+ });
60
+ const browser = globalThis;
61
+ const statics = browser.PublicKeyCredential;
62
+ const credentials = browser.navigator?.credentials;
63
+ if (!statics?.getClientCapabilities || !statics.parseRequestOptionsFromJSON || !credentials) return hosted();
64
+ try {
65
+ if ((await statics.getClientCapabilities()).relatedOrigins !== true) return hosted();
66
+ } catch {
67
+ return hosted();
68
+ }
69
+ const step = (body) => call(request, "sign-in/flow", {
70
+ method: "POST",
71
+ headers: { "content-type": "application/json" },
72
+ body: JSON.stringify({
73
+ ...body,
74
+ ...options.returnTo ? { returnTo: options.returnTo } : {}
75
+ })
76
+ });
77
+ const asked = await step({ step: "passkey-options" });
78
+ if (asked.error) return asked.error.status === 404 ? hosted() : {
79
+ data: null,
80
+ error: asked.error
81
+ };
82
+ if (asked.data.status !== "passkey") return answered(asked.data, hosted);
83
+ let credential;
84
+ try {
85
+ credential = await credentials.get({ publicKey: statics.parseRequestOptionsFromJSON(asked.data.passkey.options) });
86
+ } catch {
87
+ credential = null;
88
+ }
89
+ if (!credential) return {
90
+ data: null,
91
+ error: new SynqError("passkey_cancelled", "No passkey was used")
92
+ };
93
+ const taken = await step({
94
+ step: "passkey",
95
+ credential: credential.toJSON()
96
+ });
97
+ if (taken.error) return {
98
+ data: null,
99
+ error: taken.error
100
+ };
101
+ return answered(taken.data, hosted);
102
+ }
103
+ /** What the mount answered, as `signIn()` answers it. */
104
+ function answered(answer, hosted) {
105
+ switch (answer.status) {
106
+ case "signed-in": return {
107
+ data: {
108
+ signedIn: true,
109
+ returnTo: answer.returnTo
110
+ },
111
+ error: null
112
+ };
113
+ case "passkey":
114
+ case "retry":
115
+ case "refused":
116
+ case "expired": {
117
+ const code = answer.status === "passkey" ? answer.problem ?? "passkey_failed" : answer.status === "retry" ? answer.problem : answer.status === "refused" ? answer.reason : "invalid_session";
118
+ return {
119
+ data: null,
120
+ error: new SynqError(code, answer.message ?? code, { ...answer.status === "retry" && answer.retryAfter !== void 0 ? { retryAfter: answer.retryAfter } : {} })
121
+ };
122
+ }
123
+ default: return hosted();
124
+ }
125
+ }
49
126
  /** Calls the mount and reads its JSON; a problem it answers, or no answer, is the result's `error`. */
50
127
  async function call(request, path, init) {
51
128
  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.2.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.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "vitest": "5.0.2"