-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathSDK.ts
317 lines (280 loc) · 9.64 KB
/
SDK.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
// This constant is set by webpack during the build process
declare var VERSION: string
import {
base64URLToArrayBuffer,
arrayBufferToBase64URL,
} from './utils'
import {
authenticationResponseToJSON,
registrationResponseToJSON,
} from './toJSON'
import {
parseCreateOptions,
parseRequestOptions,
} from './fromJSON'
/**
* API formats
*/
type Result<T, E> =
| { ok: true, data: T }
| { ok: false, error: E, more?: unknown }
type WebAuthnError =
| 'webauthn_unavailable'
| 'timeout'
| 'network_error'
| 'bad_request'
| 'server_error'
| 'canceled_by_user'
| 'invalid_domain'
| 'browser_bug?'
| 'api_unsupported_in_browser'
| 'unexpected'
export type AuthResponse = Result<{ token: string }, WebAuthnError>
export type RegisterResponse = Result<{ token: string }, WebAuthnError>
type UserIdOrUsername =
| { id: string }
| { username: string }
type OptionalUserIdOrUsername = UserIdOrUsername | undefined
export type UserAuthenticationInfo = UserIdOrUsername
export type UserRegistrationInfo = {
username: string
displayName?: string
}
class SDK {
private apiKey: string
private host: string
private abortSignals: AbortController[] = []
constructor(publicKey: string, host: string = 'https://api.snapauth.app') {
this.apiKey = publicKey
this.host = host
window.addEventListener('beforeunload', this.cancelExistingRequests)
}
get isWebAuthnAvailable() {
return !!window.PublicKeyCredential
}
/**
* Browser support utilities
*/
async isConditionalCreateAvailable(): Promise<boolean> {
if (!window.PublicKeyCredential) {
return false
}
if (window.PublicKeyCredential.getClientCapabilities) {
const cc = await window.PublicKeyCredential.getClientCapabilities()
// Cast unexpected undefines to false
return cc.conditionalCreate === true
}
return false
}
async isConditionalGetAvailable(): Promise<boolean> {
if (!window.PublicKeyCredential) {
return false
}
// Modern/upcoming API
if (window.PublicKeyCredential.getClientCapabilities) {
// Note: the spec says `conditionalGet`; Safari (only browser as of
// writing that has any support for this API) incorrectly sends
// `conditionalMediation`. Since this can fall back, look only at the
// correct name.
// https://bugs.webkit.org/show_bug.cgi?id=275765
const cc = await window.PublicKeyCredential.getClientCapabilities()
if (cc.conditionalGet !== undefined) {
return cc.conditionalGet
}
}
// More commonly availalble (but presumed legacy) API
if (window.PublicKeyCredential.isConditionalMediationAvailable) {
return await window.PublicKeyCredential.isConditionalMediationAvailable()
}
return false
}
/**
* Core async APIs
*/
async startRegister(user: UserRegistrationInfo): Promise<RegisterResponse> {
if (!this.isWebAuthnAvailable) {
return { ok: false, error: 'webauthn_unavailable' }
}
return await this.doRegister(user, false)
}
async startAuth(user: UserAuthenticationInfo): Promise<AuthResponse> {
if (!this.isWebAuthnAvailable) {
return { ok: false, error: 'webauthn_unavailable' }
}
return await this.doAuth(user)
}
/**
* Conditional mediation (background) APIs
*/
async autofill(): Promise<AuthResponse> {
// TODO: warn if no <input autocomplete="webauthn"> is found?
if (!(await this.isConditionalGetAvailable())) {
return { ok: false, error: 'api_unsupported_in_browser' }
}
return await this.doAuth(undefined)
}
async upgradeToPasskey(user: UserRegistrationInfo): Promise<RegisterResponse> {
if (!(await this.isConditionalCreateAvailable())) {
return { ok: false, error: 'api_unsupported_in_browser' }
}
return await this.doRegister(user, true)
}
/**
* @deprecated use `await autofill()` instead, and ignore non-successful
* responses. This method will be removed prior to 1.0.
*/
async handleAutofill(callback: (arg0: AuthResponse) => void) {
// TODO: await autofill(), callback(res) if ok
const result = await this.autofill()
if (result.ok) {
callback(result)
}
}
/**
* Internal utilities
*/
private async doRegister(user: UserRegistrationInfo, upgrade: boolean): Promise<RegisterResponse> {
// User info is client-only during this stage of registration
const res = await this.api('/attestation/options', {
upgrade,
}) as Result<CredentialCreationOptionsJSON, WebAuthnError>
if (!res.ok) {
return res
}
const options = parseCreateOptions(user, res.data)
// If you do this inside the try/catch it seems to fail. Some sort of race
// condition w/ the other request being canceled AFAICT. Doesn't make total
// sense to me and may be a browser specific issue.
const signal = this.cancelExistingRequests()
try {
options.signal = signal
const credential = await navigator.credentials.create(options)
this.mustBePublicKeyCredential(credential)
const json = registrationResponseToJSON(credential)
return await this.api('/attestation/process', {
credential: json as unknown as JsonEncodable,
}) as RegisterResponse
} catch (error) {
return error instanceof Error ? this.convertCredentialsError(error) : this.genericError(error)
}
}
private async doAuth(user: UserIdOrUsername|undefined): Promise<AuthResponse> {
// Get the remotely-built WebAuthn options
const res = await this.api('/assertion/options', { user }) as Result<CredentialRequestOptionsJSON, WebAuthnError>
if (!res.ok) {
return res
}
const options = parseRequestOptions(res.data)
const signal = this.cancelExistingRequests()
try {
options.signal = signal
const credential = await navigator.credentials.get(options)
this.mustBePublicKeyCredential(credential)
const json = authenticationResponseToJSON(credential)
// @ts-ignore
return await this.api('/assertion/process', {
credential: json,
user,
})
} catch (error) {
return error instanceof Error ? this.convertCredentialsError(error) : this.genericError(error)
}
}
/**
* API wrapper. Catches and foramts network errors
*/
private async api(path: string, body: JsonEncodable): Promise<Result<any, WebAuthnError>> {
const headers = new Headers({
Accept: 'application/json',
'Content-type': 'application/json',
Authorization: `Basic ${btoa(this.apiKey + ':')}`,
'X-SDK': `js/${VERSION}`,
})
const request = new Request(this.host + path, {
body: JSON.stringify(body),
headers,
method: 'POST',
signal: AbortSignal.timeout(5000), // 5 second timeout
})
try {
const response = await fetch(request)
if (!response.ok) {
return {
ok: false,
error: (response.status >= 500 ? 'server_error' : 'bad_request'),
more: [response.status, response.statusText],
}
}
const parsed = await response.json()
return { ok: true, data: parsed.result }
} catch (error) {
return error instanceof Error ? this.convertNetworkError(error) : this.genericError(error)
}
}
/**
* @internal - type refinement tool
*/
private mustBePublicKeyCredential(credential: Credential|null): asserts credential is PublicKeyCredential {
if (credential === null) {
throw new TypeError('Not a credential')
} else if (credential.type !== 'public-key') {
throw new TypeError('Unexpected credential type ' + credential.type)
}
}
private genericError<T>(error: unknown): Result<T, WebAuthnError> {
return { ok: false, error: 'unexpected', more: error }
}
private convertCredentialsError<T>(error: Error): Result<T, WebAuthnError> {
// rpId mismatch (maybe others?)
if (error.name === 'SecurityError') {
return formatError('invalid_domain', error)
}
if (error.name === 'AbortError' || error.name === 'NotAllowedError') {
// Either cancel or timeout. There's no reliable way to know which right
// now, it's super stringy.
return formatError('canceled_by_user', error)
}
// Failed mustBePublicKeyCredential (most likely)
if (error.name === 'TypeError') {
return formatError('browser_bug?', error)
}
console.error('Unhandled error type converting credentials', error)
return formatError('unexpected', error)
}
private convertNetworkError<T>(error: Error): Result<T, WebAuthnError> {
// Handle known timeout formats
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
return formatError('timeout', error)
}
// Fall back to a generic network error. This tends to be stuff like
// unresolvable hosts, etc. Log this one as it's pretty weird.
console.error('Non-timeout network error', error)
return formatError('network_error', error)
}
/**
* This is primarily to deal with inconsistent browser behavior around
* conditional mediation. Safari (and FF?) permit having a CM request pending
* while starting a new modal request. If you try to do the same in Chrome,
* it errors out indicating that another request is running.
*
* So now this will try to cancel any pending request when a new one starts.
*/
private cancelExistingRequests(): AbortSignal {
this.abortSignals.forEach(signal => {
signal.abort('Starting new request')
})
const ac = new AbortController()
this.abortSignals = [ac]
return ac.signal
}
}
const formatError = <T>(error: WebAuthnError, obj: Error): Result<T, WebAuthnError> => ({
ok: false,
error,
more: {
raw: obj,
name: obj.name,
message: obj.message,
}
})
export default SDK