@sentropic/auth-ui 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 +163 -0
- package/dist/contracts.d.ts +7 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +7 -0
- package/dist/contracts.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +180 -0
- package/dist/labels.js.map +1 -0
- package/dist/transport-fetch.d.ts +25 -0
- package/dist/transport-fetch.d.ts.map +1 -0
- package/dist/transport-fetch.js +98 -0
- package/dist/transport-fetch.js.map +1 -0
- package/dist/transport-types.d.ts +77 -0
- package/dist/transport-types.d.ts.map +1 -0
- package/dist/transport-types.js +2 -0
- package/dist/transport-types.js.map +1 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +29 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/webauthn.d.ts +26 -0
- package/dist/webauthn.d.ts.map +1 -0
- package/dist/webauthn.js +76 -0
- package/dist/webauthn.js.map +1 -0
- package/package.json +86 -0
- package/src/components/AuthDevicePair.svelte +173 -0
- package/src/components/AuthDevicePair.svelte.d.ts +17 -0
- package/src/components/AuthDevices.svelte +313 -0
- package/src/components/AuthDevices.svelte.d.ts +18 -0
- package/src/components/AuthLogin.svelte +222 -0
- package/src/components/AuthLogin.svelte.d.ts +18 -0
- package/src/components/AuthMagicLinkVerify.svelte +165 -0
- package/src/components/AuthMagicLinkVerify.svelte.d.ts +20 -0
- package/src/components/AuthRegister.svelte +394 -0
- package/src/components/AuthRegister.svelte.d.ts +25 -0
- package/src/contracts.ts +6 -0
- package/src/errors.ts +18 -0
- package/src/index.ts +2 -0
- package/src/labels.ts +186 -0
- package/src/transport-fetch.ts +170 -0
- package/src/transport-types.ts +105 -0
- package/src/transport.ts +33 -0
- package/src/types.ts +153 -0
- package/src/webauthn.ts +133 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createAuthUiError } from './errors.js';
|
|
2
|
+
import type {
|
|
3
|
+
ApproveDevicePairingInput,
|
|
4
|
+
ApproveDevicePairingResult,
|
|
5
|
+
AuthUiTransport,
|
|
6
|
+
CreatePasskeyAuthenticationOptionsInput,
|
|
7
|
+
CreatePasskeyAuthenticationOptionsResult,
|
|
8
|
+
CreatePasskeyRegistrationOptionsInput,
|
|
9
|
+
CreatePasskeyRegistrationOptionsResult,
|
|
10
|
+
ListCredentialsResult,
|
|
11
|
+
RenameCredentialInput,
|
|
12
|
+
RequestEmailCodeInput,
|
|
13
|
+
RequestEmailCodeResult,
|
|
14
|
+
RevokeCredentialInput,
|
|
15
|
+
VerifyEmailCodeInput,
|
|
16
|
+
VerifyEmailCodeResult,
|
|
17
|
+
VerifyMagicLinkInput,
|
|
18
|
+
VerifyPasskeyAuthenticationInput,
|
|
19
|
+
VerifyPasskeyRegistrationInput,
|
|
20
|
+
} from './transport-types.js';
|
|
21
|
+
import type { AuthUiCredential, AuthUiResult, AuthUiSession } from './types.js';
|
|
22
|
+
|
|
23
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
24
|
+
|
|
25
|
+
export interface CreateDefaultFetchTransportOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Base URL prefix mounted in front of the auth routes (e.g. "/auth", "/admin/auth").
|
|
28
|
+
* No trailing slash.
|
|
29
|
+
*/
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
fetch?: FetchLike;
|
|
32
|
+
/**
|
|
33
|
+
* Static headers merged into every request (e.g. tenant id, bearer token).
|
|
34
|
+
*/
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Invoked when an authenticated request receives a 401 response, so hosts can
|
|
38
|
+
* clear their session state and redirect to login.
|
|
39
|
+
*/
|
|
40
|
+
onUnauthorized?: () => void | Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Toggle `credentials: "include"` so the browser sends cookies. Default `true`.
|
|
43
|
+
*/
|
|
44
|
+
withCredentials?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const safeJson = async (response: Response): Promise<unknown> => {
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
if (!text) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const extractErrorMessage = (payload: unknown, fallback: string): string => {
|
|
60
|
+
if (payload && typeof payload === 'object') {
|
|
61
|
+
const record = payload as Record<string, unknown>;
|
|
62
|
+
if (typeof record.message === 'string' && record.message) {
|
|
63
|
+
return record.message;
|
|
64
|
+
}
|
|
65
|
+
if (typeof record.error === 'string' && record.error) {
|
|
66
|
+
return record.error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return fallback;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const createDefaultFetchTransport = (
|
|
73
|
+
options: CreateDefaultFetchTransportOptions,
|
|
74
|
+
): AuthUiTransport => {
|
|
75
|
+
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
76
|
+
const fetchImpl = options.fetch ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
|
|
77
|
+
const withCredentials = options.withCredentials ?? true;
|
|
78
|
+
|
|
79
|
+
if (!fetchImpl) {
|
|
80
|
+
throw createAuthUiError(
|
|
81
|
+
'invalid_input',
|
|
82
|
+
'No global fetch found; pass options.fetch when constructing the auth transport.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const request = async <T>(
|
|
87
|
+
path: string,
|
|
88
|
+
init: { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown; authenticated?: boolean },
|
|
89
|
+
): Promise<AuthUiResult<T>> => {
|
|
90
|
+
const headers: Record<string, string> = { Accept: 'application/json', ...(options.headers ?? {}) };
|
|
91
|
+
let body: BodyInit | undefined;
|
|
92
|
+
if (init.body !== undefined) {
|
|
93
|
+
headers['Content-Type'] = 'application/json';
|
|
94
|
+
body = JSON.stringify(init.body);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let response: Response;
|
|
98
|
+
try {
|
|
99
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
100
|
+
method: init.method,
|
|
101
|
+
headers,
|
|
102
|
+
body,
|
|
103
|
+
credentials: withCredentials ? 'include' : 'same-origin',
|
|
104
|
+
});
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: createAuthUiError('transport_error', 'Network request failed', { retryable: true, cause }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const payload = await safeJson(response);
|
|
113
|
+
|
|
114
|
+
if (response.status === 401 && init.authenticated) {
|
|
115
|
+
void options.onUnauthorized?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const message = extractErrorMessage(payload, `Request failed with status ${response.status}`);
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: createAuthUiError('transport_error', message, {
|
|
123
|
+
retryable: response.status >= 500,
|
|
124
|
+
cause: payload,
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { ok: true, value: payload as T };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
requestEmailCode: (input: RequestEmailCodeInput) =>
|
|
134
|
+
request<RequestEmailCodeResult>('/email/verify-request', { method: 'POST', body: input }),
|
|
135
|
+
verifyEmailCode: (input: VerifyEmailCodeInput) =>
|
|
136
|
+
request<VerifyEmailCodeResult>('/email/verify-code', { method: 'POST', body: input }),
|
|
137
|
+
verifyMagicLink: (input: VerifyMagicLinkInput) =>
|
|
138
|
+
request<AuthUiSession>('/magic-link/verify', { method: 'POST', body: input }),
|
|
139
|
+
createPasskeyRegistrationOptions: (input: CreatePasskeyRegistrationOptionsInput) =>
|
|
140
|
+
request<CreatePasskeyRegistrationOptionsResult>('/register/options', { method: 'POST', body: input }),
|
|
141
|
+
verifyPasskeyRegistration: (input: VerifyPasskeyRegistrationInput) =>
|
|
142
|
+
request<AuthUiSession>('/register/verify', { method: 'POST', body: input }),
|
|
143
|
+
createPasskeyAuthenticationOptions: (input: CreatePasskeyAuthenticationOptionsInput) =>
|
|
144
|
+
request<CreatePasskeyAuthenticationOptionsResult>('/login/options', { method: 'POST', body: input }),
|
|
145
|
+
verifyPasskeyAuthentication: (input: VerifyPasskeyAuthenticationInput) =>
|
|
146
|
+
request<AuthUiSession>('/login/verify', { method: 'POST', body: input }),
|
|
147
|
+
refreshSession: () =>
|
|
148
|
+
request<AuthUiSession>('/session/refresh', { method: 'POST', authenticated: true }),
|
|
149
|
+
logout: () => request<void>('/session', { method: 'DELETE', authenticated: true }),
|
|
150
|
+
listCredentials: () =>
|
|
151
|
+
request<ListCredentialsResult>('/credentials', { method: 'GET', authenticated: true }),
|
|
152
|
+
renameCredential: (input: RenameCredentialInput) =>
|
|
153
|
+
request<AuthUiCredential>(`/credentials/${encodeURIComponent(input.credentialId)}`, {
|
|
154
|
+
method: 'PUT',
|
|
155
|
+
body: { deviceName: input.deviceName },
|
|
156
|
+
authenticated: true,
|
|
157
|
+
}),
|
|
158
|
+
revokeCredential: (input: RevokeCredentialInput) =>
|
|
159
|
+
request<void>(`/credentials/${encodeURIComponent(input.credentialId)}`, {
|
|
160
|
+
method: 'DELETE',
|
|
161
|
+
authenticated: true,
|
|
162
|
+
}),
|
|
163
|
+
approveDevicePairing: (input: ApproveDevicePairingInput) =>
|
|
164
|
+
request<ApproveDevicePairingResult>('/device/approve', {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
body: { user_code: input.userCode, device_name: input.deviceName },
|
|
167
|
+
authenticated: true,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthenticationResponseJSON,
|
|
3
|
+
PublicKeyCredentialCreationOptionsJSON,
|
|
4
|
+
PublicKeyCredentialRequestOptionsJSON,
|
|
5
|
+
RegistrationResponseJSON,
|
|
6
|
+
} from '@simplewebauthn/browser';
|
|
7
|
+
|
|
8
|
+
import type { AuthUiCredential, AuthUiResult, AuthUiSession } from './types.js';
|
|
9
|
+
|
|
10
|
+
export interface RequestEmailCodeInput {
|
|
11
|
+
email: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RequestEmailCodeResult {
|
|
15
|
+
delivery: 'email' | 'magic_link' | string;
|
|
16
|
+
expiresAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VerifyEmailCodeInput {
|
|
20
|
+
email: string;
|
|
21
|
+
code: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface VerifyEmailCodeResult {
|
|
25
|
+
verificationToken: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VerifyMagicLinkInput {
|
|
29
|
+
token: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CreatePasskeyRegistrationOptionsInput {
|
|
33
|
+
email: string;
|
|
34
|
+
verificationToken: string;
|
|
35
|
+
deviceName?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CreatePasskeyRegistrationOptionsResult {
|
|
39
|
+
userId: string;
|
|
40
|
+
options: PublicKeyCredentialCreationOptionsJSON;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface VerifyPasskeyRegistrationInput {
|
|
44
|
+
email: string;
|
|
45
|
+
verificationToken: string;
|
|
46
|
+
userId: string;
|
|
47
|
+
credential: RegistrationResponseJSON;
|
|
48
|
+
deviceName?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CreatePasskeyAuthenticationOptionsInput {
|
|
52
|
+
email?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreatePasskeyAuthenticationOptionsResult {
|
|
56
|
+
options: PublicKeyCredentialRequestOptionsJSON;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VerifyPasskeyAuthenticationInput {
|
|
60
|
+
credential: AuthenticationResponseJSON;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RenameCredentialInput {
|
|
64
|
+
credentialId: string;
|
|
65
|
+
deviceName: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RevokeCredentialInput {
|
|
69
|
+
credentialId: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ListCredentialsResult {
|
|
73
|
+
credentials: AuthUiCredential[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ApproveDevicePairingInput {
|
|
77
|
+
userCode: string;
|
|
78
|
+
deviceName?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ApproveDevicePairingResult {
|
|
82
|
+
deviceName?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AuthUiTransport {
|
|
86
|
+
requestEmailCode(input: RequestEmailCodeInput): Promise<AuthUiResult<RequestEmailCodeResult>>;
|
|
87
|
+
verifyEmailCode(input: VerifyEmailCodeInput): Promise<AuthUiResult<VerifyEmailCodeResult>>;
|
|
88
|
+
verifyMagicLink(input: VerifyMagicLinkInput): Promise<AuthUiResult<AuthUiSession>>;
|
|
89
|
+
createPasskeyRegistrationOptions(
|
|
90
|
+
input: CreatePasskeyRegistrationOptionsInput,
|
|
91
|
+
): Promise<AuthUiResult<CreatePasskeyRegistrationOptionsResult>>;
|
|
92
|
+
verifyPasskeyRegistration(input: VerifyPasskeyRegistrationInput): Promise<AuthUiResult<AuthUiSession>>;
|
|
93
|
+
createPasskeyAuthenticationOptions(
|
|
94
|
+
input: CreatePasskeyAuthenticationOptionsInput,
|
|
95
|
+
): Promise<AuthUiResult<CreatePasskeyAuthenticationOptionsResult>>;
|
|
96
|
+
verifyPasskeyAuthentication(input: VerifyPasskeyAuthenticationInput): Promise<AuthUiResult<AuthUiSession>>;
|
|
97
|
+
refreshSession(): Promise<AuthUiResult<AuthUiSession>>;
|
|
98
|
+
logout(): Promise<AuthUiResult<void>>;
|
|
99
|
+
listCredentials(): Promise<AuthUiResult<ListCredentialsResult>>;
|
|
100
|
+
renameCredential(input: RenameCredentialInput): Promise<AuthUiResult<AuthUiCredential>>;
|
|
101
|
+
revokeCredential(input: RevokeCredentialInput): Promise<AuthUiResult<void>>;
|
|
102
|
+
approveDevicePairing(
|
|
103
|
+
input: ApproveDevicePairingInput,
|
|
104
|
+
): Promise<AuthUiResult<ApproveDevicePairingResult>>;
|
|
105
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createAuthUiError } from './errors.js';
|
|
2
|
+
import type { AuthUiTransport } from './transport-types.js';
|
|
3
|
+
|
|
4
|
+
export const assertAuthUiTransport = (candidate: unknown): AuthUiTransport => {
|
|
5
|
+
const requiredMethods: Array<keyof AuthUiTransport> = [
|
|
6
|
+
'requestEmailCode',
|
|
7
|
+
'verifyEmailCode',
|
|
8
|
+
'verifyMagicLink',
|
|
9
|
+
'createPasskeyRegistrationOptions',
|
|
10
|
+
'verifyPasskeyRegistration',
|
|
11
|
+
'createPasskeyAuthenticationOptions',
|
|
12
|
+
'verifyPasskeyAuthentication',
|
|
13
|
+
'refreshSession',
|
|
14
|
+
'logout',
|
|
15
|
+
'listCredentials',
|
|
16
|
+
'renameCredential',
|
|
17
|
+
'revokeCredential',
|
|
18
|
+
'approveDevicePairing',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
22
|
+
throw createAuthUiError('invalid_input', 'Auth UI transport must be an object');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const record = candidate as Record<string, unknown>;
|
|
26
|
+
for (const method of requiredMethods) {
|
|
27
|
+
if (typeof record[method] !== 'function') {
|
|
28
|
+
throw createAuthUiError('invalid_input', `Auth UI transport is missing method ${method}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return candidate as AuthUiTransport;
|
|
33
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export type AuthUiErrorCode =
|
|
2
|
+
| 'invalid_input'
|
|
3
|
+
| 'transport_error'
|
|
4
|
+
| 'email_code_invalid'
|
|
5
|
+
| 'magic_link_invalid'
|
|
6
|
+
| 'passkey_cancelled'
|
|
7
|
+
| 'passkey_already_registered'
|
|
8
|
+
| 'passkey_no_matching_authenticator'
|
|
9
|
+
| 'passkey_not_supported'
|
|
10
|
+
| 'passkey_registration_failed'
|
|
11
|
+
| 'passkey_authentication_failed'
|
|
12
|
+
| 'credential_not_found'
|
|
13
|
+
| 'unknown';
|
|
14
|
+
|
|
15
|
+
export interface AuthUiError extends Error {
|
|
16
|
+
readonly name: 'AuthUiError';
|
|
17
|
+
readonly code: AuthUiErrorCode;
|
|
18
|
+
readonly retryable: boolean;
|
|
19
|
+
readonly cause?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AuthUiResult<T> =
|
|
23
|
+
| { ok: true; value: T }
|
|
24
|
+
| { ok: false; error: AuthUiError };
|
|
25
|
+
|
|
26
|
+
export interface AuthUiUser {
|
|
27
|
+
id: string;
|
|
28
|
+
email: string;
|
|
29
|
+
displayName?: string | null;
|
|
30
|
+
role?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AuthUiSession {
|
|
34
|
+
user: AuthUiUser;
|
|
35
|
+
sessionToken?: string;
|
|
36
|
+
refreshToken?: string;
|
|
37
|
+
expiresAt?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AuthUiCredential {
|
|
41
|
+
id: string;
|
|
42
|
+
credentialId: string;
|
|
43
|
+
deviceName: string;
|
|
44
|
+
uv: boolean;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
lastUsedAt: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AuthUiNavigation {
|
|
50
|
+
navigate(to: string): void | Promise<void>;
|
|
51
|
+
buildLoginHref?(returnUrl?: string): string;
|
|
52
|
+
buildRegisterHref?(returnUrl?: string): string;
|
|
53
|
+
buildDevicesHref?(): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AuthUiSessionCallbacks {
|
|
57
|
+
onSession?(session: AuthUiSession): void | Promise<void>;
|
|
58
|
+
onLogout?(): void | Promise<void>;
|
|
59
|
+
onError?(error: AuthUiError): void | Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AuthUiBranding {
|
|
63
|
+
productName?: string;
|
|
64
|
+
logoUrl?: string;
|
|
65
|
+
logoAlt?: string;
|
|
66
|
+
primaryColor?: string;
|
|
67
|
+
accentColor?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AuthUiLabels {
|
|
71
|
+
loading: string;
|
|
72
|
+
save: string;
|
|
73
|
+
cancel: string;
|
|
74
|
+
emailPlaceholder: string;
|
|
75
|
+
webauthnRegisterNotice: string;
|
|
76
|
+
verifyInProgress: string;
|
|
77
|
+
redirectingDashboard: string;
|
|
78
|
+
registerDeviceNow: string;
|
|
79
|
+
loginTitle: string;
|
|
80
|
+
loginSupportedHint: string;
|
|
81
|
+
loginUnavailable: string;
|
|
82
|
+
loginUnsupportedBrowser: string;
|
|
83
|
+
loginButton: string;
|
|
84
|
+
loginButtonLoading: string;
|
|
85
|
+
loginLostDevice: string;
|
|
86
|
+
loginLostDeviceTitle: string;
|
|
87
|
+
loginNoAccount: string;
|
|
88
|
+
loginRegisterNewDevice: string;
|
|
89
|
+
loginBackToLogin: string;
|
|
90
|
+
registerTitle: string;
|
|
91
|
+
registerSubtitle: string;
|
|
92
|
+
registerUnsupportedBrowserTitle: string;
|
|
93
|
+
registerUnsupportedBrowser: string;
|
|
94
|
+
registerEmailLabel: string;
|
|
95
|
+
registerGetCode: string;
|
|
96
|
+
registerSendingCode: string;
|
|
97
|
+
registerAlreadyHaveAccount: string;
|
|
98
|
+
registerCodeLabel: string;
|
|
99
|
+
registerCodeHelp: string;
|
|
100
|
+
registerVerifyCode: string;
|
|
101
|
+
registerVerifyingCode: string;
|
|
102
|
+
registerChangeEmail: string;
|
|
103
|
+
registerCodeVerifiedTitle: string;
|
|
104
|
+
registerPasskeyButton: string;
|
|
105
|
+
registerRegistering: string;
|
|
106
|
+
registerSuccessTitle: string;
|
|
107
|
+
registerSuccessCodeSent: string;
|
|
108
|
+
registerSuccessRedirecting: string;
|
|
109
|
+
registerErrorInvalidEmail: string;
|
|
110
|
+
registerErrorSendCode: string;
|
|
111
|
+
magicLinkTitle: string;
|
|
112
|
+
magicLinkVerifying: string;
|
|
113
|
+
magicLinkSuccess: string;
|
|
114
|
+
magicLinkSuccessTitle: string;
|
|
115
|
+
magicLinkErrorTitle: string;
|
|
116
|
+
magicLinkBackToLogin: string;
|
|
117
|
+
magicLinkErrorMissingToken: string;
|
|
118
|
+
magicLinkErrorVerifyFailed: string;
|
|
119
|
+
devicesTitle: string;
|
|
120
|
+
devicesSubtitle: string;
|
|
121
|
+
devicesEmpty: string;
|
|
122
|
+
devicesRegister: string;
|
|
123
|
+
devicesAddNew: string;
|
|
124
|
+
devicesRename: string;
|
|
125
|
+
devicesRevoke: string;
|
|
126
|
+
devicesUvEnabled: string;
|
|
127
|
+
devicesAddedOn: string;
|
|
128
|
+
devicesLastUsed: string;
|
|
129
|
+
devicesConfirmRevoke: string;
|
|
130
|
+
devicesErrorLoad: string;
|
|
131
|
+
devicesErrorUpdate: string;
|
|
132
|
+
devicesErrorRevoke: string;
|
|
133
|
+
devicePairTitle: string;
|
|
134
|
+
devicePairSubtitle: string;
|
|
135
|
+
devicePairCodeLabel: string;
|
|
136
|
+
devicePairCodePlaceholder: string;
|
|
137
|
+
devicePairDeviceNameLabel: string;
|
|
138
|
+
devicePairDeviceNamePlaceholder: string;
|
|
139
|
+
devicePairConfirm: string;
|
|
140
|
+
devicePairConfirming: string;
|
|
141
|
+
devicePairSuccess: string;
|
|
142
|
+
devicePairBack: string;
|
|
143
|
+
devicePairErrorCodeRequired: string;
|
|
144
|
+
devicePairErrorNotFound: string;
|
|
145
|
+
devicePairErrorGeneric: string;
|
|
146
|
+
passkeyNotSupported: string;
|
|
147
|
+
passkeyCancelled: string;
|
|
148
|
+
passkeyAlreadyRegistered: string;
|
|
149
|
+
passkeyNoMatchingAuthenticator: string;
|
|
150
|
+
passkeyAuthenticatorNotSupported: string;
|
|
151
|
+
passkeyRegistrationCancelled: string;
|
|
152
|
+
passkeyAuthenticationCancelled: string;
|
|
153
|
+
}
|
package/src/webauthn.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { startAuthentication as defaultStartAuthentication, startRegistration as defaultStartRegistration } from '@simplewebauthn/browser';
|
|
2
|
+
import type {
|
|
3
|
+
AuthenticationResponseJSON,
|
|
4
|
+
PublicKeyCredentialCreationOptionsJSON,
|
|
5
|
+
PublicKeyCredentialRequestOptionsJSON,
|
|
6
|
+
RegistrationResponseJSON,
|
|
7
|
+
} from '@simplewebauthn/browser';
|
|
8
|
+
|
|
9
|
+
import { createAuthUiError, createDefaultAuthUiLabels, type AuthUiError, type AuthUiLabels } from './contracts.js';
|
|
10
|
+
|
|
11
|
+
export interface AuthUiWebAuthnBrowser {
|
|
12
|
+
PublicKeyCredential?: {
|
|
13
|
+
new (...args: never[]): unknown;
|
|
14
|
+
isUserVerifyingPlatformAuthenticatorAvailable?: () => Promise<boolean>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StartPasskeyRegistrationOptions {
|
|
19
|
+
browser?: AuthUiWebAuthnBrowser;
|
|
20
|
+
startRegistration?: (input: { optionsJSON: PublicKeyCredentialCreationOptionsJSON }) => Promise<RegistrationResponseJSON>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StartPasskeyAuthenticationOptions {
|
|
24
|
+
browser?: AuthUiWebAuthnBrowser;
|
|
25
|
+
startAuthentication?: (input: { optionsJSON: PublicKeyCredentialRequestOptionsJSON }) => Promise<AuthenticationResponseJSON>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const getDefaultBrowser = (): AuthUiWebAuthnBrowser | undefined =>
|
|
29
|
+
typeof window === 'undefined' ? undefined : (window as unknown as AuthUiWebAuthnBrowser);
|
|
30
|
+
|
|
31
|
+
export const isWebAuthnSupported = (browser: AuthUiWebAuthnBrowser | undefined = getDefaultBrowser()): boolean =>
|
|
32
|
+
typeof browser?.PublicKeyCredential === 'function';
|
|
33
|
+
|
|
34
|
+
export const isPlatformAuthenticatorAvailable = async (
|
|
35
|
+
browser: AuthUiWebAuthnBrowser | undefined = getDefaultBrowser(),
|
|
36
|
+
): Promise<boolean> => {
|
|
37
|
+
if (!isWebAuthnSupported(browser)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return Boolean(await browser?.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable?.());
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const startPasskeyRegistration = async (
|
|
49
|
+
options: PublicKeyCredentialCreationOptionsJSON,
|
|
50
|
+
deps: StartPasskeyRegistrationOptions = {},
|
|
51
|
+
): Promise<RegistrationResponseJSON> => {
|
|
52
|
+
if (!isWebAuthnSupported(deps.browser)) {
|
|
53
|
+
throw createAuthUiError('passkey_not_supported', 'WebAuthn is not supported in this browser');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
return await (deps.startRegistration ?? defaultStartRegistration)({ optionsJSON: options });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw mapWebAuthnError(error, 'registration');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const startPasskeyAuthentication = async (
|
|
64
|
+
options: PublicKeyCredentialRequestOptionsJSON,
|
|
65
|
+
deps: StartPasskeyAuthenticationOptions = {},
|
|
66
|
+
): Promise<AuthenticationResponseJSON> => {
|
|
67
|
+
if (!isWebAuthnSupported(deps.browser)) {
|
|
68
|
+
throw createAuthUiError('passkey_not_supported', 'WebAuthn is not supported in this browser');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await (deps.startAuthentication ?? defaultStartAuthentication)({ optionsJSON: options });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw mapWebAuthnError(error, 'authentication');
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const getWebAuthnErrorMessage = (
|
|
79
|
+
error: unknown,
|
|
80
|
+
labels: Partial<AuthUiLabels> = createDefaultAuthUiLabels(),
|
|
81
|
+
): string => {
|
|
82
|
+
if (typeof error === 'string') {
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const code = (error as Partial<AuthUiError> | undefined)?.code;
|
|
87
|
+
const message = (error as { message?: string } | undefined)?.message ?? 'Unknown error';
|
|
88
|
+
|
|
89
|
+
switch (code) {
|
|
90
|
+
case 'passkey_cancelled':
|
|
91
|
+
return labels.passkeyCancelled ?? message;
|
|
92
|
+
case 'passkey_already_registered':
|
|
93
|
+
return labels.passkeyAlreadyRegistered ?? message;
|
|
94
|
+
case 'passkey_no_matching_authenticator':
|
|
95
|
+
return labels.passkeyNoMatchingAuthenticator ?? message;
|
|
96
|
+
case 'passkey_not_supported':
|
|
97
|
+
return labels.passkeyNotSupported ?? message;
|
|
98
|
+
default:
|
|
99
|
+
return message;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const mapWebAuthnError = (error: unknown, ceremony: 'registration' | 'authentication'): AuthUiError => {
|
|
104
|
+
const name = (error as { name?: string } | undefined)?.name;
|
|
105
|
+
const message = (error as { message?: string } | undefined)?.message ?? 'Unknown error';
|
|
106
|
+
|
|
107
|
+
if (name === 'NotAllowedError') {
|
|
108
|
+
return createAuthUiError(
|
|
109
|
+
'passkey_cancelled',
|
|
110
|
+
ceremony === 'registration' ? 'Registration cancelled by user' : 'Authentication cancelled by user',
|
|
111
|
+
{ retryable: true, cause: error },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (name === 'InvalidStateError') {
|
|
116
|
+
const code = ceremony === 'registration' ? 'passkey_already_registered' : 'passkey_no_matching_authenticator';
|
|
117
|
+
const fallback = ceremony === 'registration' ? 'This authenticator is already registered' : 'No matching authenticator found';
|
|
118
|
+
return createAuthUiError(code, fallback, { retryable: ceremony !== 'registration', cause: error });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (name === 'NotSupportedError') {
|
|
122
|
+
return createAuthUiError('passkey_not_supported', 'This authenticator is not supported', {
|
|
123
|
+
retryable: false,
|
|
124
|
+
cause: error,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return createAuthUiError(
|
|
129
|
+
ceremony === 'registration' ? 'passkey_registration_failed' : 'passkey_authentication_failed',
|
|
130
|
+
`${ceremony === 'registration' ? 'Registration' : 'Authentication'} failed: ${message}`,
|
|
131
|
+
{ retryable: true, cause: error },
|
|
132
|
+
);
|
|
133
|
+
};
|