passkey-magic 0.1.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 +302 -0
- package/dist/adapters/memory.d.mts +10 -0
- package/dist/adapters/memory.mjs +142 -0
- package/dist/adapters/unstorage.d.mts +30 -0
- package/dist/adapters/unstorage.mjs +238 -0
- package/dist/better-auth/client.d.mts +9 -0
- package/dist/better-auth/client.mjs +7 -0
- package/dist/better-auth/index.d.mts +2 -0
- package/dist/better-auth/index.mjs +4044 -0
- package/dist/client/index.d.mts +167 -0
- package/dist/client/index.mjs +166 -0
- package/dist/crypto-KHRNe6EL.mjs +45 -0
- package/dist/index-BoHvgaqz.d.mts +2816 -0
- package/dist/index-Cqqpr_uS.d.mts +176 -0
- package/dist/nitro/index.d.mts +89 -0
- package/dist/nitro/index.mjs +117 -0
- package/dist/server/index.d.mts +3 -0
- package/dist/server/index.mjs +3 -0
- package/dist/server-BXkm8lU0.mjs +823 -0
- package/dist/types-BjM1f6uu.d.mts +249 -0
- package/package.json +74 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { c as Credential, d as QRSessionStatus, h as User, p as Session, s as ClientConfig } from "../types-BjM1f6uu.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/client/index.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Client-side auth interface. All methods delegate to the server
|
|
6
|
+
* via the `request` function you provide in config.
|
|
7
|
+
*/
|
|
8
|
+
interface PasskeyMagicClient {
|
|
9
|
+
/** Check if the browser supports WebAuthn. */
|
|
10
|
+
supportsPasskeys(): boolean;
|
|
11
|
+
/** Check if the browser supports WebAuthn conditional UI (autofill). */
|
|
12
|
+
supportsAutofill(): Promise<boolean>;
|
|
13
|
+
/** Check if a platform authenticator (Touch ID, Windows Hello) is available. */
|
|
14
|
+
hasPlatformAuthenticator(): Promise<boolean>;
|
|
15
|
+
/** Register a new passkey and create an account. */
|
|
16
|
+
registerPasskey(params?: {
|
|
17
|
+
userId?: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
userName?: string;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
method: 'passkey';
|
|
22
|
+
user: {
|
|
23
|
+
id: string;
|
|
24
|
+
email?: string;
|
|
25
|
+
};
|
|
26
|
+
session: {
|
|
27
|
+
token: string;
|
|
28
|
+
expiresAt: string;
|
|
29
|
+
};
|
|
30
|
+
credential: {
|
|
31
|
+
id: string;
|
|
32
|
+
};
|
|
33
|
+
}>;
|
|
34
|
+
/** Sign in with an existing passkey. */
|
|
35
|
+
signInWithPasskey(params?: {
|
|
36
|
+
userId?: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
method: 'passkey';
|
|
39
|
+
user: {
|
|
40
|
+
id: string;
|
|
41
|
+
email?: string;
|
|
42
|
+
};
|
|
43
|
+
session: {
|
|
44
|
+
token: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
};
|
|
47
|
+
}>;
|
|
48
|
+
/** Add a passkey to the current account (requires auth). */
|
|
49
|
+
addPasskey(params?: {
|
|
50
|
+
userName?: string;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
credential: {
|
|
53
|
+
id: string;
|
|
54
|
+
};
|
|
55
|
+
}>;
|
|
56
|
+
/** Update a passkey label. */
|
|
57
|
+
updateCredential(params: {
|
|
58
|
+
credentialId: string;
|
|
59
|
+
label: string;
|
|
60
|
+
}): Promise<void>;
|
|
61
|
+
/** Remove a passkey. */
|
|
62
|
+
removeCredential(credentialId: string): Promise<void>;
|
|
63
|
+
/** List all passkeys for the current user. */
|
|
64
|
+
listCredentials(): Promise<{
|
|
65
|
+
credentials: Credential[];
|
|
66
|
+
}>;
|
|
67
|
+
/** Create a new QR login session on the server. */
|
|
68
|
+
createQRSession(): Promise<{
|
|
69
|
+
sessionId: string;
|
|
70
|
+
}>;
|
|
71
|
+
/** Render a URL as an SVG QR code using uqr. */
|
|
72
|
+
renderQR(url: string, opts?: {
|
|
73
|
+
border?: number;
|
|
74
|
+
}): string;
|
|
75
|
+
/** Render a URL as a text QR code using uqr. */
|
|
76
|
+
renderQRText(url: string, opts?: {
|
|
77
|
+
border?: number;
|
|
78
|
+
}): string;
|
|
79
|
+
/**
|
|
80
|
+
* Poll a QR session for status changes.
|
|
81
|
+
* Yields status updates until `authenticated` or `expired`.
|
|
82
|
+
*/
|
|
83
|
+
pollQRSession(sessionId: string, opts?: {
|
|
84
|
+
interval?: number;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
}): AsyncIterable<QRSessionStatus>;
|
|
87
|
+
/** Complete a QR session from the scanning device (authenticates with passkey). */
|
|
88
|
+
completeQRSession(params: {
|
|
89
|
+
sessionId: string;
|
|
90
|
+
}): Promise<void>;
|
|
91
|
+
/** Request a magic link email. */
|
|
92
|
+
requestMagicLink(params: {
|
|
93
|
+
email: string;
|
|
94
|
+
}): Promise<{
|
|
95
|
+
sent: true;
|
|
96
|
+
}>;
|
|
97
|
+
/** Verify a magic link token and create a session. */
|
|
98
|
+
verifyMagicLink(params: {
|
|
99
|
+
token: string;
|
|
100
|
+
}): Promise<{
|
|
101
|
+
method: 'magic-link';
|
|
102
|
+
user: {
|
|
103
|
+
id: string;
|
|
104
|
+
email?: string;
|
|
105
|
+
};
|
|
106
|
+
session: {
|
|
107
|
+
token: string;
|
|
108
|
+
expiresAt: string;
|
|
109
|
+
};
|
|
110
|
+
isNewUser: boolean;
|
|
111
|
+
}>;
|
|
112
|
+
/** Validate the current session. Returns null if invalid/expired. */
|
|
113
|
+
getSession(token: string): Promise<{
|
|
114
|
+
user: User;
|
|
115
|
+
session: Session;
|
|
116
|
+
} | null>;
|
|
117
|
+
/** List all active sessions. */
|
|
118
|
+
listSessions(): Promise<{
|
|
119
|
+
sessions: Session[];
|
|
120
|
+
}>;
|
|
121
|
+
/** Revoke the current session (logout). */
|
|
122
|
+
revokeSession(): Promise<void>;
|
|
123
|
+
/** Revoke a specific session by ID. */
|
|
124
|
+
revokeSessionById(sessionId: string): Promise<void>;
|
|
125
|
+
/** Revoke all sessions (logout everywhere). */
|
|
126
|
+
revokeAllSessions(): Promise<void>;
|
|
127
|
+
/** Get the current user profile. */
|
|
128
|
+
getAccount(): Promise<{
|
|
129
|
+
user: User;
|
|
130
|
+
}>;
|
|
131
|
+
/** Check if an email is available. */
|
|
132
|
+
isEmailAvailable(email: string): Promise<boolean>;
|
|
133
|
+
/** Link an email to the current account. */
|
|
134
|
+
linkEmail(email: string): Promise<{
|
|
135
|
+
user: User;
|
|
136
|
+
}>;
|
|
137
|
+
/** Unlink the email from the current account. */
|
|
138
|
+
unlinkEmail(): Promise<{
|
|
139
|
+
user: User;
|
|
140
|
+
}>;
|
|
141
|
+
/** Delete the current account and all data. */
|
|
142
|
+
deleteAccount(): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Create a passkey-magic client.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* const client = createClient({
|
|
150
|
+
* request: async (endpoint, body) => {
|
|
151
|
+
* const res = await fetch(`/api/auth${endpoint}`, {
|
|
152
|
+
* method: body ? 'POST' : 'GET',
|
|
153
|
+
* headers: {
|
|
154
|
+
* 'Content-Type': 'application/json',
|
|
155
|
+
* 'Authorization': `Bearer ${sessionToken}`,
|
|
156
|
+
* },
|
|
157
|
+
* body: body ? JSON.stringify(body) : undefined,
|
|
158
|
+
* })
|
|
159
|
+
* if (!res.ok) throw new Error((await res.json()).error)
|
|
160
|
+
* return res.json()
|
|
161
|
+
* }
|
|
162
|
+
* })
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
declare function createClient(config: ClientConfig): PasskeyMagicClient;
|
|
166
|
+
//#endregion
|
|
167
|
+
export { type ClientConfig, PasskeyMagicClient, type QRSessionStatus, createClient };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, platformAuthenticatorIsAvailable, startAuthentication, startRegistration } from "@simplewebauthn/browser";
|
|
2
|
+
import { renderSVG, renderUnicodeCompact } from "uqr";
|
|
3
|
+
//#region src/client/passkey.ts
|
|
4
|
+
function createClientPasskeyManager(config) {
|
|
5
|
+
return {
|
|
6
|
+
supportsPasskeys() {
|
|
7
|
+
return browserSupportsWebAuthn();
|
|
8
|
+
},
|
|
9
|
+
supportsAutofill() {
|
|
10
|
+
return browserSupportsWebAuthnAutofill();
|
|
11
|
+
},
|
|
12
|
+
hasPlatformAuthenticator() {
|
|
13
|
+
return platformAuthenticatorIsAvailable();
|
|
14
|
+
},
|
|
15
|
+
async register(params) {
|
|
16
|
+
const { options, userId } = await config.request("/passkey/register/options", params ?? {});
|
|
17
|
+
const response = await startRegistration({ optionsJSON: options });
|
|
18
|
+
return config.request("/passkey/register/verify", {
|
|
19
|
+
userId,
|
|
20
|
+
response
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
async authenticate(params) {
|
|
24
|
+
const { options } = await config.request("/passkey/authenticate/options", params ?? {});
|
|
25
|
+
const response = await startAuthentication({ optionsJSON: options });
|
|
26
|
+
return config.request("/passkey/authenticate/verify", { response });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/client/qr.ts
|
|
32
|
+
function createClientQRManager(config) {
|
|
33
|
+
return {
|
|
34
|
+
async createSession() {
|
|
35
|
+
return config.request("/qr/create", {});
|
|
36
|
+
},
|
|
37
|
+
renderSVG(url, opts) {
|
|
38
|
+
return renderSVG(url, { border: opts?.border ?? 2 });
|
|
39
|
+
},
|
|
40
|
+
renderText(url, opts) {
|
|
41
|
+
return renderUnicodeCompact(url, { border: opts?.border ?? 1 });
|
|
42
|
+
},
|
|
43
|
+
async *pollSession(sessionId, opts) {
|
|
44
|
+
const interval = opts?.interval ?? 2e3;
|
|
45
|
+
const signal = opts?.signal;
|
|
46
|
+
while (!signal?.aborted) {
|
|
47
|
+
const status = await config.request(`/qr/${sessionId}/status`);
|
|
48
|
+
yield status;
|
|
49
|
+
if (status.state === "authenticated" || status.state === "expired") return;
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
const timer = setTimeout(resolve, interval);
|
|
52
|
+
signal?.addEventListener("abort", () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(signal.reason);
|
|
55
|
+
}, { once: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async completeSession({ sessionId }) {
|
|
60
|
+
await config.request(`/qr/${sessionId}/scanned`, {});
|
|
61
|
+
const { startAuthentication } = await import("@simplewebauthn/browser");
|
|
62
|
+
const { options } = await config.request("/passkey/authenticate/options", {});
|
|
63
|
+
const response = await startAuthentication({ optionsJSON: options });
|
|
64
|
+
await config.request(`/qr/${sessionId}/complete`, { response });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/client/magic-link.ts
|
|
70
|
+
function createClientMagicLinkManager(config) {
|
|
71
|
+
return {
|
|
72
|
+
async send({ email }) {
|
|
73
|
+
return config.request("/magic-link/send", { email });
|
|
74
|
+
},
|
|
75
|
+
async verify({ token }) {
|
|
76
|
+
return config.request("/magic-link/verify", { token });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/client/index.ts
|
|
82
|
+
/**
|
|
83
|
+
* Create a passkey-magic client.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* const client = createClient({
|
|
88
|
+
* request: async (endpoint, body) => {
|
|
89
|
+
* const res = await fetch(`/api/auth${endpoint}`, {
|
|
90
|
+
* method: body ? 'POST' : 'GET',
|
|
91
|
+
* headers: {
|
|
92
|
+
* 'Content-Type': 'application/json',
|
|
93
|
+
* 'Authorization': `Bearer ${sessionToken}`,
|
|
94
|
+
* },
|
|
95
|
+
* body: body ? JSON.stringify(body) : undefined,
|
|
96
|
+
* })
|
|
97
|
+
* if (!res.ok) throw new Error((await res.json()).error)
|
|
98
|
+
* return res.json()
|
|
99
|
+
* }
|
|
100
|
+
* })
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
function createClient(config) {
|
|
104
|
+
const passkey = createClientPasskeyManager(config);
|
|
105
|
+
const qr = createClientQRManager(config);
|
|
106
|
+
const magicLink = createClientMagicLinkManager(config);
|
|
107
|
+
return {
|
|
108
|
+
supportsPasskeys: () => passkey.supportsPasskeys(),
|
|
109
|
+
supportsAutofill: () => passkey.supportsAutofill(),
|
|
110
|
+
hasPlatformAuthenticator: () => passkey.hasPlatformAuthenticator(),
|
|
111
|
+
registerPasskey: (params) => passkey.register(params),
|
|
112
|
+
signInWithPasskey: (params) => passkey.authenticate(params),
|
|
113
|
+
async addPasskey(params) {
|
|
114
|
+
const { options } = await config.request("/passkey/add/options", params ?? {});
|
|
115
|
+
const { startRegistration } = await import("@simplewebauthn/browser");
|
|
116
|
+
const response = await startRegistration({ optionsJSON: options });
|
|
117
|
+
return config.request("/passkey/add/verify", { response });
|
|
118
|
+
},
|
|
119
|
+
async updateCredential({ credentialId, label }) {
|
|
120
|
+
await config.request(`/account/credentials/${credentialId}`, { label });
|
|
121
|
+
},
|
|
122
|
+
async removeCredential(credentialId) {
|
|
123
|
+
await config.request(`/account/credentials/${credentialId}/delete`, {});
|
|
124
|
+
},
|
|
125
|
+
listCredentials: () => config.request("/account/credentials"),
|
|
126
|
+
createQRSession: () => qr.createSession(),
|
|
127
|
+
renderQR: (url, opts) => qr.renderSVG(url, opts),
|
|
128
|
+
renderQRText: (url, opts) => qr.renderText(url, opts),
|
|
129
|
+
pollQRSession: (id, opts) => qr.pollSession(id, opts),
|
|
130
|
+
completeQRSession: (params) => qr.completeSession(params),
|
|
131
|
+
requestMagicLink: (params) => magicLink.send(params),
|
|
132
|
+
verifyMagicLink: (params) => magicLink.verify(params),
|
|
133
|
+
async getSession(token) {
|
|
134
|
+
try {
|
|
135
|
+
return await config.request("/session");
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
listSessions: () => config.request("/account/sessions"),
|
|
141
|
+
async revokeSession() {
|
|
142
|
+
await config.request("/session/revoke", {});
|
|
143
|
+
},
|
|
144
|
+
async revokeSessionById(sessionId) {
|
|
145
|
+
await config.request(`/account/sessions/${sessionId}/delete`, {});
|
|
146
|
+
},
|
|
147
|
+
async revokeAllSessions() {
|
|
148
|
+
await config.request("/account/sessions/delete-all", {});
|
|
149
|
+
},
|
|
150
|
+
getAccount: () => config.request("/account"),
|
|
151
|
+
async isEmailAvailable(email) {
|
|
152
|
+
return (await config.request("/account/email-available", { email })).available;
|
|
153
|
+
},
|
|
154
|
+
async linkEmail(email) {
|
|
155
|
+
return config.request("/account/link-email", { email });
|
|
156
|
+
},
|
|
157
|
+
async unlinkEmail() {
|
|
158
|
+
return config.request("/account/unlink-email", {});
|
|
159
|
+
},
|
|
160
|
+
async deleteAccount() {
|
|
161
|
+
await config.request("/account/delete", {});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
export { createClient };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/crypto.ts
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
/** Generate a random UUID v4 using Web Crypto. */
|
|
4
|
+
function generateId() {
|
|
5
|
+
return crypto.randomUUID();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Generate a cryptographically random URL-safe token.
|
|
9
|
+
* @param bytes - Number of random bytes (default 32 = 256 bits of entropy).
|
|
10
|
+
*/
|
|
11
|
+
function generateToken(bytes = 32) {
|
|
12
|
+
const buf = new Uint8Array(bytes);
|
|
13
|
+
crypto.getRandomValues(buf);
|
|
14
|
+
return base64url(buf);
|
|
15
|
+
}
|
|
16
|
+
/** Encode a Uint8Array to a base64url string (no padding). */
|
|
17
|
+
function base64url(buffer) {
|
|
18
|
+
let binary = "";
|
|
19
|
+
for (let i = 0; i < buffer.length; i++) binary += String.fromCharCode(buffer[i]);
|
|
20
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
21
|
+
}
|
|
22
|
+
/** SHA-256 hash a string and return the digest as base64url. */
|
|
23
|
+
async function hashToken(token) {
|
|
24
|
+
const data = encoder.encode(token);
|
|
25
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
26
|
+
return base64url(new Uint8Array(hash));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
30
|
+
* Returns `true` if both strings are equal. Both strings are
|
|
31
|
+
* hashed first so length differences don't leak timing info.
|
|
32
|
+
*/
|
|
33
|
+
async function timingSafeEqual(a, b) {
|
|
34
|
+
const [hashA, hashB] = await Promise.all([hashToken(a), hashToken(b)]);
|
|
35
|
+
if (hashA.length !== hashB.length) return false;
|
|
36
|
+
let result = 0;
|
|
37
|
+
for (let i = 0; i < hashA.length; i++) result |= hashA.charCodeAt(i) ^ hashB.charCodeAt(i);
|
|
38
|
+
return result === 0;
|
|
39
|
+
}
|
|
40
|
+
/** Basic email format validation. */
|
|
41
|
+
function isValidEmail(email) {
|
|
42
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { timingSafeEqual as i, generateToken as n, isValidEmail as r, generateId as t };
|