@synqauth/plugin-passkey 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +31 -2
- package/dist/index.d.ts +73 -0
- package/dist/index.js +157 -0
- package/package.json +38 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aerosol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synqauth/plugin-passkey
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Synq plugin for passkeys from your own UI.
|
|
4
|
+
|
|
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
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Passkeys from your own UI, with every ceremony at your brand's Synq issuer, which verifies them. Nothing is verified on your server.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { passkeyPlugin } from '@synqauth/plugin-passkey'
|
|
15
|
+
import { SynqProvider, usePlugin } from '@synqauth/react'
|
|
16
|
+
|
|
17
|
+
const passkey = passkeyPlugin()
|
|
18
|
+
|
|
19
|
+
// <SynqProvider plugins={[passkey]}> ... then, in a component:
|
|
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)
|
|
24
|
+
```
|
|
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
|
+
|
|
28
|
+
`list()` and `remove(id)` go through your `@synqauth/nextjs` route mount's account API and answer `{ data, error }`.
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { AccountComponents, SynqError } from "@synqauth/js";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
/** The plugin's id: its actions are under it in `usePlugin` and the mount's plugin list. */
|
|
4
|
+
export declare const PASSKEY_PLUGIN_ID = "passkey";
|
|
5
|
+
/** A passkey of the signed-in person's, as the account API lists it. */
|
|
6
|
+
export type Passkey = AccountComponents['schemas']['Passkey'];
|
|
7
|
+
/** A call's outcome: its data, or the `SynqError` it failed with. */
|
|
8
|
+
export type PasskeyResult<T> = {
|
|
9
|
+
readonly data: T;
|
|
10
|
+
readonly error: null;
|
|
11
|
+
} | {
|
|
12
|
+
readonly data: null;
|
|
13
|
+
readonly error: SynqError;
|
|
14
|
+
};
|
|
15
|
+
/** Calls the app's route mount, from its own page: what @synqauth/react and @synqauth/nextjs/client hand a plugin. */
|
|
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
|
+
};
|
|
29
|
+
/** What the passkey plugin does from the page: where to send the person, and their passkeys. */
|
|
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>>;
|
|
42
|
+
/** Where to send someone to sign in with a passkey: the brand's page, at its passkey step. */
|
|
43
|
+
signInUrl: (options?: {
|
|
44
|
+
returnTo?: string;
|
|
45
|
+
loginHint?: string;
|
|
46
|
+
}) => string;
|
|
47
|
+
/**
|
|
48
|
+
* Where to send the signed-in person to add a passkey: a fresh sign-in,
|
|
49
|
+
* then the brand's page offers a new passkey (`synq_intent=add-passkey`).
|
|
50
|
+
*/
|
|
51
|
+
addUrl: (options?: {
|
|
52
|
+
returnTo?: string;
|
|
53
|
+
}) => string;
|
|
54
|
+
/** Their passkeys. */
|
|
55
|
+
list: () => Promise<PasskeyResult<readonly Passkey[]>>;
|
|
56
|
+
/** Removes one; a passkey that is their last way to sign in can't go (`last_sign_in_method`). */
|
|
57
|
+
remove: (id: string) => Promise<PasskeyResult<null>>;
|
|
58
|
+
}
|
|
59
|
+
/** The passkey plugin, in `SynqClientPlugin`'s shape: for `<SynqProvider plugins>` or `@synqauth/nextjs/client`. */
|
|
60
|
+
export interface PasskeyPlugin {
|
|
61
|
+
readonly id: typeof PASSKEY_PLUGIN_ID;
|
|
62
|
+
actions: (request: MountRequest) => PasskeyActions;
|
|
63
|
+
}
|
|
64
|
+
/** What `passkeyPlugin()` takes. */
|
|
65
|
+
export interface PasskeyPluginOptions {
|
|
66
|
+
/** The route mount's path, as the app's `synqAuth()` has it: `/api/auth` by default. */
|
|
67
|
+
basePath?: string;
|
|
68
|
+
}
|
|
69
|
+
/** The passkey plugin: sign-in and adding a passkey at the brand's issuer, and the person's passkeys through the route mount. */
|
|
70
|
+
export declare function passkeyPlugin(options?: PasskeyPluginOptions): PasskeyPlugin;
|
|
71
|
+
/** Calls the mount and reads its JSON; a problem it answers, or no answer, is the result's `error`. */
|
|
72
|
+
export declare function call<T>(request: MountRequest, path: string, init?: RequestInit): Promise<PasskeyResult<T>>;
|
|
73
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { SynqError, problemError } from "@synqauth/js";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/** The plugin's id: its actions are under it in `usePlugin` and the mount's plugin list. */
|
|
4
|
+
const PASSKEY_PLUGIN_ID = "passkey";
|
|
5
|
+
/** The passkey plugin: sign-in and adding a passkey at the brand's issuer, and the person's passkeys through the route mount. */
|
|
6
|
+
function passkeyPlugin(options = {}) {
|
|
7
|
+
const base = `/${(options.basePath ?? "/api/auth").replace(/^\/+|\/+$/g, "")}`;
|
|
8
|
+
const signInPath = (params) => {
|
|
9
|
+
const query = new URLSearchParams();
|
|
10
|
+
for (const [name, value] of Object.entries(params)) if (value) query.set(name, value);
|
|
11
|
+
return `${base}/sign-in?${query.toString()}`;
|
|
12
|
+
};
|
|
13
|
+
const signInUrl = ({ returnTo, loginHint } = {}) => signInPath({
|
|
14
|
+
method: "passkey",
|
|
15
|
+
returnTo,
|
|
16
|
+
login_hint: loginHint
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
id: PASSKEY_PLUGIN_ID,
|
|
20
|
+
actions: (request) => ({
|
|
21
|
+
signIn: (signInOptions = {}) => signInOnPage(request, signInOptions, signInUrl),
|
|
22
|
+
signInUrl,
|
|
23
|
+
addUrl: ({ returnTo } = {}) => signInPath({
|
|
24
|
+
intent: "add-passkey",
|
|
25
|
+
returnTo
|
|
26
|
+
}),
|
|
27
|
+
list: async () => {
|
|
28
|
+
const result = await call(request, "account/v1/passkeys");
|
|
29
|
+
if (result.error) return {
|
|
30
|
+
data: null,
|
|
31
|
+
error: result.error
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
data: result.data.data,
|
|
35
|
+
error: null
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
remove: (id) => {
|
|
39
|
+
if (!id || /^\.+$/.test(id)) return Promise.resolve({
|
|
40
|
+
data: null,
|
|
41
|
+
error: new SynqError("invalid_options", "A passkey id is needed")
|
|
42
|
+
});
|
|
43
|
+
return call(request, `account/v1/passkeys/${encodeURIComponent(id)}`, {
|
|
44
|
+
method: "DELETE",
|
|
45
|
+
headers: { "idempotency-key": crypto.randomUUID() }
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
};
|
|
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
|
+
}
|
|
126
|
+
/** Calls the mount and reads its JSON; a problem it answers, or no answer, is the result's `error`. */
|
|
127
|
+
async function call(request, path, init) {
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = await request(path, init);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return {
|
|
133
|
+
data: null,
|
|
134
|
+
error: new SynqError("network_error", `The app couldn't be reached: ${String(error)}`, { cause: error })
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const text = await response.text();
|
|
138
|
+
let json;
|
|
139
|
+
try {
|
|
140
|
+
json = text ? JSON.parse(text) : void 0;
|
|
141
|
+
} catch {
|
|
142
|
+
json = void 0;
|
|
143
|
+
}
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
const problem = json !== null && typeof json === "object" ? json : {};
|
|
146
|
+
return {
|
|
147
|
+
data: null,
|
|
148
|
+
error: problemError(response.status, problem)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
data: json ?? null,
|
|
153
|
+
error: null
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
export { PASSKEY_PLUGIN_ID, call, passkeyPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synqauth/plugin-passkey",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A Synq plugin for passkeys from your own UI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aerosol-build/synq.git",
|
|
9
|
+
"directory": "packages/plugin-passkey"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@synqauth/js": "0.2.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"vitest": "5.0.2"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsdown",
|
|
37
|
+
"lint": "eslint .",
|
|
38
|
+
"typecheck": "tsc",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
}
|
|
6
41
|
}
|