@omelhorsite/sdk 0.2.0 → 0.3.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/dist/index.js +4939 -552
- package/dist/types/client.d.ts +60 -3
- package/dist/types/http.d.ts +444 -19
- package/dist/types/index.d.ts +4 -1
- package/dist/types/resources/account.d.ts +66 -3
- package/dist/types/resources/admin.d.ts +1837 -0
- package/dist/types/resources/auth/index.d.ts +39 -0
- package/dist/types/resources/auth/passkeys.d.ts +652 -0
- package/dist/types/resources/auth/sessions.d.ts +847 -0
- package/dist/types/resources/chests.d.ts +54 -3
- package/dist/types/resources/content.d.ts +2970 -0
- package/dist/types/resources/dynamicQrs.d.ts +39 -3
- package/dist/types/resources/forms.d.ts +176 -35
- package/dist/types/resources/index.d.ts +19 -8
- package/dist/types/resources/ipLookup.d.ts +20 -4
- package/dist/types/resources/jobs.d.ts +62 -21
- package/dist/types/resources/library.d.ts +1435 -0
- package/dist/types/resources/linkTrees.d.ts +142 -30
- package/dist/types/resources/media.d.ts +351 -0
- package/dist/types/resources/movies.d.ts +1186 -0
- package/dist/types/resources/music/artists.d.ts +1066 -0
- package/dist/types/resources/music/imports.d.ts +940 -0
- package/dist/types/resources/music/index.d.ts +61 -0
- package/dist/types/resources/music/playlists.d.ts +1026 -0
- package/dist/types/resources/music/social.d.ts +1132 -0
- package/dist/types/resources/music/songs.d.ts +1183 -0
- package/dist/types/resources/notepads.d.ts +4 -1
- package/dist/types/resources/quotas.d.ts +7 -1
- package/dist/types/resources/realtime.d.ts +855 -0
- package/dist/types/resources/shortLinks.d.ts +45 -4
- package/dist/types/resources/social.d.ts +1330 -0
- package/dist/types/resources/storage/upload.d.ts +158 -11
- package/dist/types/resources/storage.d.ts +88 -22
- package/dist/types/resources/tickets.d.ts +82 -3
- package/dist/types/resources/tools/backgroundRemoval.d.ts +18 -3
- package/dist/types/resources/tools/captions.d.ts +448 -21
- package/dist/types/resources/tools/downloader.d.ts +21 -0
- package/dist/types/resources/tools/index.d.ts +57 -15
- package/dist/types/resources/tools/jumpstyle.d.ts +50 -17
- package/dist/types/resources/tools/transcription.d.ts +35 -13
- package/dist/types/resources/tools/upscale.d.ts +23 -3
- package/dist/types/resources/tools/vocalSeparation.d.ts +30 -13
- package/dist/types/types.d.ts +249 -17
- package/package.json +2 -1
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `passkeys` namespace: WebAuthn credentials, and the two ceremonies that
|
|
3
|
+
* create and spend them.
|
|
4
|
+
*
|
|
5
|
+
* Six routes hang off `/webauthn_credentials`, and they split in two:
|
|
6
|
+
*
|
|
7
|
+
* | route | credential | ceiling |
|
|
8
|
+
* | --------------------------- | ---------- | ------------------ |
|
|
9
|
+
* | `GET /` | required | general 600/min |
|
|
10
|
+
* | `DELETE /:id` | required | general 600/min |
|
|
11
|
+
* | `POST /registration_options` | required | general 600/min |
|
|
12
|
+
* | `POST /registration` | required | general 600/min |
|
|
13
|
+
* | `POST /authentication_options` | **none** | **20/min per IP** |
|
|
14
|
+
* | `POST /authentication` | **none** | **20/min per IP** |
|
|
15
|
+
*
|
|
16
|
+
* The last two share ONE bucket, not one each: `rack_attack.rb` throttles on
|
|
17
|
+
* `canonical_path(req).start_with?("/webauthn_credentials/authentication")`,
|
|
18
|
+
* and `/authentication_options` starts with that string. Twenty POSTs a minute
|
|
19
|
+
* from an IP covers both halves of every login attempt, so a NATed office gets
|
|
20
|
+
* ten sign-ins a minute between them. Budget accordingly, and see the note on
|
|
21
|
+
* {@link PasskeysNamespace.authenticate} for why neither of those two methods
|
|
22
|
+
* retries a `429` on its own.
|
|
23
|
+
*
|
|
24
|
+
* ## The payloads go over the wire verbatim, and that is load-bearing
|
|
25
|
+
*
|
|
26
|
+
* A WebAuthn ceremony payload is a nested record of base64url strings that the
|
|
27
|
+
* authenticator signed. Change one byte of it, anywhere, and the signature no
|
|
28
|
+
* longer matches something the server can reconstruct - and the server does not
|
|
29
|
+
* say so in those words. It says `500`, or it says "Passkey could not be
|
|
30
|
+
* verified", two minutes after the user pressed their fingerprint.
|
|
31
|
+
*
|
|
32
|
+
* The SDK's one body-shaped rewrite is the null sentinel, and this namespace is
|
|
33
|
+
* out of its reach BY CONSTRUCTION rather than by opting out: `NULL_SENTINEL`
|
|
34
|
+
* is written by `encodeQuery` in `http.ts` and by nothing else. A JSON body
|
|
35
|
+
* goes through `JSON.stringify` untouched (`ApiClient#buildInit`), and
|
|
36
|
+
* `buildFormData` omits a null field rather than sentinelling it. So the escape
|
|
37
|
+
* hatch is simply this: a ceremony travels in the BODY, never in `query`, and
|
|
38
|
+
* every method here passes the caller's object straight to `http.post` with no
|
|
39
|
+
* copy, no reshaping, and no key filtering. `test/passkeys.test.ts` pins the
|
|
40
|
+
* serialised body byte for byte, including a `null` that must survive as
|
|
41
|
+
* `null`.
|
|
42
|
+
*
|
|
43
|
+
* The corollary is that nothing here normalises for you either. If your
|
|
44
|
+
* platform is loose about base64 padding, run the credential through
|
|
45
|
+
* {@link normalizePasskeyRegistrationCredential} /
|
|
46
|
+
* {@link normalizePasskeyAssertionCredential} BEFORE calling, at the call site,
|
|
47
|
+
* where the change is visible. See {@link passkeyBase64Url} for why React
|
|
48
|
+
* Native needs that and a browser does not.
|
|
49
|
+
*
|
|
50
|
+
* ## What this namespace does not do
|
|
51
|
+
*
|
|
52
|
+
* It never calls `navigator.credentials` and never touches a native passkey
|
|
53
|
+
* module. That is the host's job, in the host's runtime, and it is the half
|
|
54
|
+
* that differs between the three clients:
|
|
55
|
+
*
|
|
56
|
+
* - browser: `@simplewebauthn/browser`'s `startRegistration` /
|
|
57
|
+
* `startAuthentication`, which take `optionsJSON` in the exact shape
|
|
58
|
+
* {@link PasskeyRegistrationOptions} / {@link PasskeyAuthenticationOptions}
|
|
59
|
+
* describe and hand back the exact shape the credential types describe;
|
|
60
|
+
* - React Native: `react-native-passkeys`, loaded lazily because
|
|
61
|
+
* `requireNativeModule` throws at import time when the module is not linked
|
|
62
|
+
* (Expo Go, a stale dev client);
|
|
63
|
+
* - Bun / a Worker: there is no authenticator. `list` and `remove` work,
|
|
64
|
+
* the ceremonies cannot.
|
|
65
|
+
*
|
|
66
|
+
* The SDK owns the transport and the types on both sides of that call.
|
|
67
|
+
*/
|
|
68
|
+
import { Resource } from "../../http";
|
|
69
|
+
import type { AccountSession } from "../account";
|
|
70
|
+
import type { BaseRecord, Id, RequestOptions, Timestamp } from "../../types";
|
|
71
|
+
/**
|
|
72
|
+
* One registered passkey, as `WebauthnCredentialBlueprint` renders it.
|
|
73
|
+
*
|
|
74
|
+
* The blueprint declares two fields and inherits three from
|
|
75
|
+
* `ApplicationBlueprint`, so the payload is exactly `id`, `created_at`,
|
|
76
|
+
* `updated_at`, `nickname`, `last_used_at`. Its `:extended` view is an empty
|
|
77
|
+
* block, which in Blueprinter INHERITS the default view rather than replacing
|
|
78
|
+
* it, so `:extended` and the default are the same five fields here: the row
|
|
79
|
+
* `POST /registration` answers with and the rows `GET /` answers with are
|
|
80
|
+
* identical, despite the controller asking for different views.
|
|
81
|
+
*
|
|
82
|
+
* Nothing about the credential itself is ever rendered. `external_id`,
|
|
83
|
+
* `public_key` and `sign_count` stay on the server; there is no field here that
|
|
84
|
+
* identifies the authenticator, so a UI cannot say "your YubiKey" or "this
|
|
85
|
+
* phone" unless the user typed a nickname.
|
|
86
|
+
*
|
|
87
|
+
* `id` is a STRING (`create_table :webauthn_credentials, id: :string`), like
|
|
88
|
+
* users and sessions and unlike songs and playlists. Never do arithmetic on it.
|
|
89
|
+
*/
|
|
90
|
+
export interface Passkey extends BaseRecord {
|
|
91
|
+
/**
|
|
92
|
+
* User-chosen label, or `null`.
|
|
93
|
+
*
|
|
94
|
+
* `normalizes :nickname, with: ->(n) { n.to_s.strip.presence }` runs on the
|
|
95
|
+
* model, so `" "` is stored as `null` and comes back as `null`, not as the
|
|
96
|
+
* spaces that were sent. A UI that echoes what it just submitted will show
|
|
97
|
+
* the wrong thing; read the answer instead.
|
|
98
|
+
*/
|
|
99
|
+
readonly nickname: string | null;
|
|
100
|
+
/**
|
|
101
|
+
* When this passkey last completed a sign-in, or `null` if it never has.
|
|
102
|
+
*
|
|
103
|
+
* Written by `touch_usage!` through `update_columns`, which skips validations
|
|
104
|
+
* AND callbacks but does bump `updated_at` by hand, so a passkey's
|
|
105
|
+
* `updated_at` tracks its last use rather than its last edit. There is no
|
|
106
|
+
* edit: the record has no update route.
|
|
107
|
+
*/
|
|
108
|
+
readonly last_used_at: Timestamp | null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* What `POST /webauthn_credentials/authentication` answers with: the session,
|
|
112
|
+
* plus the token, exactly as `POST /sessions` answers a password sign-in.
|
|
113
|
+
*
|
|
114
|
+
* `SessionBlueprint`'s `:token` view adds one field to the base view, so this
|
|
115
|
+
* is an {@link AccountSession} with a `token`. That token is the new
|
|
116
|
+
* credential, and it is shown ONCE. Persist it here or lose it.
|
|
117
|
+
*
|
|
118
|
+
* A browser gets the same token a second way, as an httpOnly cookie the
|
|
119
|
+
* controller sets alongside the body (`set_session_cookie`). That is why the
|
|
120
|
+
* web client can ignore `token` entirely and still be signed in after the
|
|
121
|
+
* post-login reload: it authenticates by cookie and deliberately never stores a
|
|
122
|
+
* token where a script could read it. A native or CLI client does the opposite
|
|
123
|
+
* and reads `token`.
|
|
124
|
+
*/
|
|
125
|
+
export interface PasskeySession extends AccountSession {
|
|
126
|
+
/** The bearer token for the new session. Rendered once, here, and never again. */
|
|
127
|
+
readonly token: string;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* An `ArrayBuffer` after the only encoding this API speaks: unpadded base64url.
|
|
131
|
+
*
|
|
132
|
+
* `config.encoding = :base64url` in `config/initializers/webauthn.rb` decides
|
|
133
|
+
* this for BOTH directions. Everything the server sends
|
|
134
|
+
* (`WebAuthn::Encoders::Base64UrlEncoder#encode`) has its `=` padding chomped
|
|
135
|
+
* off, and everything it reads back it decodes the same way.
|
|
136
|
+
*
|
|
137
|
+
* A type alias, not a branded type, because a brand would force every caller to
|
|
138
|
+
* cast the strings their platform just handed them and would buy nothing: the
|
|
139
|
+
* check that matters happens on the server, over bytes. {@link passkeyBase64Url}
|
|
140
|
+
* is the runtime check, for callers who want one.
|
|
141
|
+
*/
|
|
142
|
+
export type PasskeyBase64Url = string;
|
|
143
|
+
/** The three user-verification levels WebAuthn defines. */
|
|
144
|
+
export type PasskeyUserVerification = "required" | "preferred" | "discouraged";
|
|
145
|
+
/**
|
|
146
|
+
* How an authenticator is reachable. The registered set is small, but the spec
|
|
147
|
+
* grows it and a platform may report one this SDK has not heard of, so unknown
|
|
148
|
+
* strings are accepted rather than rejected: the server stores transports as
|
|
149
|
+
* opaque strings and never matches on them.
|
|
150
|
+
*/
|
|
151
|
+
export type PasskeyTransport = "usb" | "nfc" | "ble" | "smart-card" | "hybrid" | "internal" | (string & {});
|
|
152
|
+
/**
|
|
153
|
+
* One credential the ceremony should exclude (registration) or allow
|
|
154
|
+
* (authentication).
|
|
155
|
+
*
|
|
156
|
+
* The server builds these with `as_public_key_descriptors`, which emits `type`
|
|
157
|
+
* and `id` and NEVER `transports`, so a descriptor that arrives from this API
|
|
158
|
+
* has exactly two keys. `transports` is here for the other direction, where a
|
|
159
|
+
* platform reports it.
|
|
160
|
+
*/
|
|
161
|
+
export interface PasskeyCredentialDescriptor {
|
|
162
|
+
readonly type: "public-key";
|
|
163
|
+
/** The credential id, base64url. Matches `webauthn_credentials.external_id`. */
|
|
164
|
+
readonly id: PasskeyBase64Url;
|
|
165
|
+
readonly transports?: readonly PasskeyTransport[];
|
|
166
|
+
}
|
|
167
|
+
/** The relying party, from `WebAuthn.configure`: `"O Melhor Site"` at `omelhorsite.pt`. */
|
|
168
|
+
export interface PasskeyRelyingParty {
|
|
169
|
+
readonly name: string;
|
|
170
|
+
/**
|
|
171
|
+
* The registrable domain that owns the credential. Always sent: the
|
|
172
|
+
* initializer sets `rp_id`, and `RPEntity` falls back to it when the caller
|
|
173
|
+
* gives none.
|
|
174
|
+
*
|
|
175
|
+
* It is the domain of the UI, NOT of the API. Passkeys minted here are scoped
|
|
176
|
+
* to `omelhorsite.pt` and its subdomains; `backend.omelhorsite.pt` serves the
|
|
177
|
+
* ceremony but is not where it runs.
|
|
178
|
+
*/
|
|
179
|
+
readonly id: string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The account the passkey will belong to.
|
|
183
|
+
*
|
|
184
|
+
* `id` is `users.webauthn_id`, a server-minted opaque handle that exists for
|
|
185
|
+
* exactly this purpose, assigned lazily on the first call to
|
|
186
|
+
* {@link PasskeysNamespace.registrationOptions}. It is NOT `users.id`, and the
|
|
187
|
+
* distinction is the point: the value is stored on the authenticator, is
|
|
188
|
+
* readable by anyone who gets hold of the device, and must therefore say
|
|
189
|
+
* nothing about the account. `name` and `displayName`, by contrast, ARE the
|
|
190
|
+
* email and the real name, because the OS shows them in the account picker.
|
|
191
|
+
*/
|
|
192
|
+
export interface PasskeyUserEntity {
|
|
193
|
+
readonly id: PasskeyBase64Url;
|
|
194
|
+
/** The account's email address. Shown by the OS. */
|
|
195
|
+
readonly name: string;
|
|
196
|
+
/** The account's display name. Shown by the OS. */
|
|
197
|
+
readonly displayName: string;
|
|
198
|
+
}
|
|
199
|
+
/** One COSE algorithm the relying party will accept, by its registered id. */
|
|
200
|
+
export interface PasskeyCredentialParameter {
|
|
201
|
+
readonly type: "public-key";
|
|
202
|
+
/** COSE identifier: `-7` ES256, `-37` PS256, `-257` RS256, in that order. */
|
|
203
|
+
readonly alg: number;
|
|
204
|
+
}
|
|
205
|
+
/** Constraints on which authenticator may answer, and how. */
|
|
206
|
+
export interface PasskeyAuthenticatorSelection {
|
|
207
|
+
readonly authenticatorAttachment?: "platform" | "cross-platform";
|
|
208
|
+
readonly residentKey?: "required" | "preferred" | "discouraged";
|
|
209
|
+
readonly requireResidentKey?: boolean;
|
|
210
|
+
readonly userVerification?: PasskeyUserVerification;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* `POST /webauthn_credentials/registration_options` - the arguments for
|
|
214
|
+
* `navigator.credentials.create()`, camelCased and base64url-encoded by the
|
|
215
|
+
* gem's `JSONSerializer`.
|
|
216
|
+
*
|
|
217
|
+
* Every optional field below is optional because `JSONSerializer#to_hash` drops
|
|
218
|
+
* a falsy attribute, not because this deployment sometimes omits it. What this
|
|
219
|
+
* deployment actually sends, verified against
|
|
220
|
+
* `WebAuthn::PublicKeyCredential::CreationOptions`:
|
|
221
|
+
*
|
|
222
|
+
* - `challenge`, `timeout` (120000), `extensions` (`{}`), `rp`, `user`,
|
|
223
|
+
* `pubKeyCredParams` (ES256, PS256, RS256), `authenticatorSelection`
|
|
224
|
+
* (`residentKey` and `userVerification`, both `"preferred"`), and
|
|
225
|
+
* `excludeCredentials`, which is `[]` for an account with no passkeys yet
|
|
226
|
+
* rather than absent;
|
|
227
|
+
* - NOT `attestation`. The controller passes none, so the key is dropped
|
|
228
|
+
* entirely and the platform applies its own default (`"none"`).
|
|
229
|
+
*
|
|
230
|
+
* Pass this object to the platform whole. Do not rebuild it field by field
|
|
231
|
+
* unless your platform needs you to: the app does exactly that, and its reason
|
|
232
|
+
* is a native Record decoder that trips on `extensions: {}`, which is a
|
|
233
|
+
* property of `react-native-passkeys` rather than of WebAuthn.
|
|
234
|
+
*/
|
|
235
|
+
export interface PasskeyRegistrationOptions {
|
|
236
|
+
readonly challenge: PasskeyBase64Url;
|
|
237
|
+
/** Milliseconds the platform may keep its sheet open. 120000. See the TTL warning on {@link PasskeysNamespace.registrationOptions}. */
|
|
238
|
+
readonly timeout?: number;
|
|
239
|
+
readonly extensions?: Record<string, unknown>;
|
|
240
|
+
readonly rp: PasskeyRelyingParty;
|
|
241
|
+
readonly user: PasskeyUserEntity;
|
|
242
|
+
readonly pubKeyCredParams: readonly PasskeyCredentialParameter[];
|
|
243
|
+
readonly attestation?: string;
|
|
244
|
+
readonly authenticatorSelection?: PasskeyAuthenticatorSelection;
|
|
245
|
+
/**
|
|
246
|
+
* The passkeys this account already has, so the authenticator refuses to
|
|
247
|
+
* enrol itself twice. Present but empty for a first passkey.
|
|
248
|
+
*
|
|
249
|
+
* This is what makes "register" idempotent from the user's point of view, and
|
|
250
|
+
* it is enforced by the AUTHENTICATOR, not by the server: re-registering an
|
|
251
|
+
* excluded device fails in the OS sheet, so the caller sees a platform error
|
|
252
|
+
* and never an HTTP one.
|
|
253
|
+
*/
|
|
254
|
+
readonly excludeCredentials?: readonly PasskeyCredentialDescriptor[];
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* `options` from `POST /webauthn_credentials/authentication_options` - the
|
|
258
|
+
* arguments for `navigator.credentials.get()`.
|
|
259
|
+
*
|
|
260
|
+
* `allowCredentials` is always present and always `[]`: the controller calls
|
|
261
|
+
* `options_for_get(user_verification: "preferred")` with no allow list, and
|
|
262
|
+
* `RequestOptions#allow_credentials` falls back to `[]` rather than to nil. An
|
|
263
|
+
* empty allow list IS the feature - it makes the ceremony discoverable, so the
|
|
264
|
+
* OS offers whatever passkeys it holds for the domain and the user picks an
|
|
265
|
+
* account. That is why sign-in needs no email field.
|
|
266
|
+
*
|
|
267
|
+
* `rpId` is always present too (`RequestOptions` defaults it from the
|
|
268
|
+
* relying party), which matters because `react-native-passkeys` requires it.
|
|
269
|
+
*/
|
|
270
|
+
export interface PasskeyAuthenticationOptions {
|
|
271
|
+
readonly challenge: PasskeyBase64Url;
|
|
272
|
+
readonly timeout?: number;
|
|
273
|
+
readonly extensions?: Record<string, unknown>;
|
|
274
|
+
/** Empty, and deliberately so: an empty allow list means discoverable login. */
|
|
275
|
+
readonly allowCredentials?: readonly PasskeyCredentialDescriptor[];
|
|
276
|
+
/** `"omelhorsite.pt"` in production, `"localhost"` in development. */
|
|
277
|
+
readonly rpId: string;
|
|
278
|
+
readonly userVerification?: PasskeyUserVerification;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The whole answer from `POST /webauthn_credentials/authentication_options`:
|
|
282
|
+
* the ceremony arguments plus the handle that identifies the challenge.
|
|
283
|
+
*
|
|
284
|
+
* The handle exists because the login ceremony has no session to key a
|
|
285
|
+
* challenge against. The server caches the challenge under
|
|
286
|
+
* `webauthn:auth:<handle>` and hands you the handle; you give it back with the
|
|
287
|
+
* assertion. Treat it as a single-use nonce: it is a `SecureRandom.uuid`, it
|
|
288
|
+
* lives two minutes, and {@link PasskeysNamespace.authenticate} spends it.
|
|
289
|
+
*/
|
|
290
|
+
export interface PasskeyAuthenticationChallenge {
|
|
291
|
+
readonly handle: string;
|
|
292
|
+
readonly options: PasskeyAuthenticationOptions;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* What the authenticator produced during registration, ready to be posted back.
|
|
296
|
+
*
|
|
297
|
+
* `id` and `rawId` are BOTH required, and both must be the same credential id
|
|
298
|
+
* in base64url. This is not belt-and-braces, it is
|
|
299
|
+
* `WebAuthn::PublicKeyCredential#valid_id?`, which decodes each one separately
|
|
300
|
+
* and compares the BYTES. Send them out of step and see the warning on
|
|
301
|
+
* {@link PasskeysNamespace.register}: the mismatch raises a bare `RuntimeError`
|
|
302
|
+
* inside the gem, which is not a `WebAuthn::Error`, so the controller's rescue
|
|
303
|
+
* does not catch it and you get a `500`.
|
|
304
|
+
*
|
|
305
|
+
* The gem reads `clientDataJSON`, `attestationObject` and `transports` from
|
|
306
|
+
* `response` and ignores anything else in it, so a browser's `getPublicKey()` /
|
|
307
|
+
* `publicKey` extras are harmless. They are also pointless bytes on a phone's
|
|
308
|
+
* connection.
|
|
309
|
+
*/
|
|
310
|
+
export interface PasskeyRegistrationCredential {
|
|
311
|
+
readonly id: PasskeyBase64Url;
|
|
312
|
+
/** The same id again. Required, non-null: see the type's own note. */
|
|
313
|
+
readonly rawId: PasskeyBase64Url;
|
|
314
|
+
readonly type: "public-key";
|
|
315
|
+
readonly response: {
|
|
316
|
+
readonly clientDataJSON: PasskeyBase64Url;
|
|
317
|
+
readonly attestationObject: PasskeyBase64Url;
|
|
318
|
+
readonly transports?: readonly PasskeyTransport[];
|
|
319
|
+
};
|
|
320
|
+
readonly authenticatorAttachment?: string;
|
|
321
|
+
/** Whatever the client extensions returned. `{}` when there were none. */
|
|
322
|
+
readonly clientExtensionResults?: Record<string, unknown>;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* What the authenticator produced during sign-in, ready to be posted back.
|
|
326
|
+
*
|
|
327
|
+
* Same `id` / `rawId` rule as {@link PasskeyRegistrationCredential}, same
|
|
328
|
+
* consequence for getting it wrong.
|
|
329
|
+
*
|
|
330
|
+
* `userHandle` is what a discoverable ceremony returns as the account the user
|
|
331
|
+
* picked, and this server does NOT read it: it looks the credential up by
|
|
332
|
+
* `external_id` instead. Send it anyway if the platform gave you one, but do
|
|
333
|
+
* not synthesise one, and never treat its absence as a failure.
|
|
334
|
+
*/
|
|
335
|
+
export interface PasskeyAssertionCredential {
|
|
336
|
+
readonly id: PasskeyBase64Url;
|
|
337
|
+
/** The same id again. Required, non-null: see {@link PasskeyRegistrationCredential}. */
|
|
338
|
+
readonly rawId: PasskeyBase64Url;
|
|
339
|
+
readonly type: "public-key";
|
|
340
|
+
readonly response: {
|
|
341
|
+
readonly clientDataJSON: PasskeyBase64Url;
|
|
342
|
+
readonly authenticatorData: PasskeyBase64Url;
|
|
343
|
+
readonly signature: PasskeyBase64Url;
|
|
344
|
+
readonly userHandle?: PasskeyBase64Url;
|
|
345
|
+
};
|
|
346
|
+
readonly authenticatorAttachment?: string;
|
|
347
|
+
readonly clientExtensionResults?: Record<string, unknown>;
|
|
348
|
+
}
|
|
349
|
+
/** Arguments for {@link PasskeysNamespace.register}. */
|
|
350
|
+
export interface RegisterPasskeyInput {
|
|
351
|
+
/** The attestation the platform just produced. Sent verbatim. */
|
|
352
|
+
readonly credential: PasskeyRegistrationCredential;
|
|
353
|
+
/**
|
|
354
|
+
* Optional label. Blank and whitespace-only strings are stored as `null` by
|
|
355
|
+
* the model's `normalizes`, so there is no point sending `" "` to clear
|
|
356
|
+
* anything - there is nothing to clear, the record cannot be updated.
|
|
357
|
+
*/
|
|
358
|
+
readonly nickname?: string;
|
|
359
|
+
}
|
|
360
|
+
/** Arguments for {@link PasskeysNamespace.authenticate}. */
|
|
361
|
+
export interface AuthenticatePasskeyInput {
|
|
362
|
+
/** The assertion the platform just produced. Sent verbatim. */
|
|
363
|
+
readonly credential: PasskeyAssertionCredential;
|
|
364
|
+
/** The `handle` from {@link PasskeysNamespace.authenticationOptions}. Single use. */
|
|
365
|
+
readonly handle: string;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Canonicalises one ceremony field into unpadded base64url.
|
|
369
|
+
*
|
|
370
|
+
* Accepts standard base64 and base64url, padded or not, and always emits
|
|
371
|
+
* unpadded base64url: `+` becomes `-`, `/` becomes `_`, trailing `=` goes away.
|
|
372
|
+
*
|
|
373
|
+
* ## Who needs this
|
|
374
|
+
*
|
|
375
|
+
* A browser does not. `@simplewebauthn/browser` already emits unpadded
|
|
376
|
+
* base64url on both sides, which is why the web frontend posts its credential
|
|
377
|
+
* straight through and has never needed a normaliser.
|
|
378
|
+
*
|
|
379
|
+
* React Native does. Each platform re-encodes in its own native layer on the
|
|
380
|
+
* way out of the OS, and they do not agree with each other about padding; iOS
|
|
381
|
+
* in particular re-pads on the way in and strips on the way out. That would be
|
|
382
|
+
* harmless if the server were lenient, and on most fields it is - the gem's
|
|
383
|
+
* decoder re-pads a short string and accepts either alphabet. It is NOT lenient
|
|
384
|
+
* about one thing: `valid_id?` decodes `id` and `rawId` SEPARATELY and demands
|
|
385
|
+
* identical bytes, so a platform that pads one and not the other loses the
|
|
386
|
+
* ceremony at the very last step, with a 500 and nothing in the message.
|
|
387
|
+
*
|
|
388
|
+
* Isolate-safe: two regexes and string methods, no platform API at all.
|
|
389
|
+
*
|
|
390
|
+
* @param field Name used in the error message. The VALUE is never included:
|
|
391
|
+
* these strings are signed material and a `clientDataJSON` in a log is a
|
|
392
|
+
* record of who signed in from where.
|
|
393
|
+
* @throws {TypeError} when the value is not a string, is empty, is outside both
|
|
394
|
+
* base64 alphabets, or has a length of `1 mod 4`. That last one is not a
|
|
395
|
+
* padding question: no base64 string is ever 1 mod 4 characters long, so such
|
|
396
|
+
* a value is truncated, and re-padding it would produce plausible bytes that
|
|
397
|
+
* fail verification much later.
|
|
398
|
+
*/
|
|
399
|
+
export declare function passkeyBase64Url(value: unknown, field: string): PasskeyBase64Url;
|
|
400
|
+
/**
|
|
401
|
+
* Non-throwing predicate for a value that is ALREADY canonical unpadded
|
|
402
|
+
* base64url. Use it to decide whether normalising is needed at all;
|
|
403
|
+
* {@link passkeyBase64Url} is what does the normalising.
|
|
404
|
+
*/
|
|
405
|
+
export declare function isPasskeyBase64Url(value: unknown): value is PasskeyBase64Url;
|
|
406
|
+
/**
|
|
407
|
+
* Rebuilds a registration credential in the canonical encoding, keeping only
|
|
408
|
+
* the fields the server reads.
|
|
409
|
+
*
|
|
410
|
+
* Call it at the call site, on what the platform handed you, and pass the
|
|
411
|
+
* result to {@link PasskeysNamespace.register}. It is NOT applied inside
|
|
412
|
+
* `register`, because the promise this namespace makes is that what you pass is
|
|
413
|
+
* what goes on the wire; a silent rewrite of signed material is exactly the
|
|
414
|
+
* thing that must not happen behind a caller's back.
|
|
415
|
+
*
|
|
416
|
+
* Drops `getPublicKey`, `publicKey`, `getPublicKeyAlgorithm` and
|
|
417
|
+
* `getAuthenticatorData` from `response`: `AuthenticatorAttestationResponse`
|
|
418
|
+
* reads `clientDataJSON`, `attestationObject` and `transports` and nothing
|
|
419
|
+
* else, and the dropped fields are large.
|
|
420
|
+
*
|
|
421
|
+
* @throws {TypeError} through {@link passkeyBase64Url} when a required field is
|
|
422
|
+
* missing or not base64.
|
|
423
|
+
*/
|
|
424
|
+
export declare function normalizePasskeyRegistrationCredential(raw: unknown): PasskeyRegistrationCredential;
|
|
425
|
+
/**
|
|
426
|
+
* Rebuilds an assertion credential in the canonical encoding. The companion of
|
|
427
|
+
* {@link normalizePasskeyRegistrationCredential}, with the same contract and
|
|
428
|
+
* the same reason for not being applied automatically.
|
|
429
|
+
*
|
|
430
|
+
* `userHandle` is dropped when the platform reported it as `null` or `""`,
|
|
431
|
+
* which several do for a non-discoverable credential. The server does not read
|
|
432
|
+
* it, so an absent one costs nothing, whereas an empty string would have to
|
|
433
|
+
* survive `passkeyBase64Url` and could not.
|
|
434
|
+
*
|
|
435
|
+
* @throws {TypeError} when a required field is missing or not base64.
|
|
436
|
+
*/
|
|
437
|
+
export declare function normalizePasskeyAssertionCredential(raw: unknown): PasskeyAssertionCredential;
|
|
438
|
+
/** The `passkeys` namespace, reachable as `oms.passkeys`. */
|
|
439
|
+
export declare class PasskeysNamespace extends Resource {
|
|
440
|
+
/**
|
|
441
|
+
* `GET /webauthn_credentials` - every passkey of the signed-in user, newest
|
|
442
|
+
* first.
|
|
443
|
+
*
|
|
444
|
+
* Returns a plain array, and that is the whole story: this action is
|
|
445
|
+
* hand-written rather than `CrudActions`, so unlike almost every other index
|
|
446
|
+
* in this API it has NO list DSL, NO `modifiers[page]`, NO `search` /
|
|
447
|
+
* `exact_search`, and NO `ETag` / `304`. Sending those parameters is not an
|
|
448
|
+
* error either - the controller never looks at `params`, so
|
|
449
|
+
* `reject_unknown_filter_keys!` is not in the chain and a filter is silently
|
|
450
|
+
* ignored rather than rejected with a `400`. The web frontend does exactly
|
|
451
|
+
* that today: `PasskeyService.list` accepts a `ListFilters` argument and
|
|
452
|
+
* forwards it, and it has never had any effect.
|
|
453
|
+
*
|
|
454
|
+
* Hence no `Paginated` and no `PageParams` here. An account's passkey count
|
|
455
|
+
* is bounded by how many devices a person owns, and the server would return
|
|
456
|
+
* all of them regardless of what was asked.
|
|
457
|
+
*
|
|
458
|
+
* The order is `created_at: :desc`, applied in SQL, so it does not change if
|
|
459
|
+
* a passkey is used.
|
|
460
|
+
*
|
|
461
|
+
* Requires a credential. General authenticated ceiling, 600/min.
|
|
462
|
+
*
|
|
463
|
+
* @throws {OmsAuthError} 401 with no credential.
|
|
464
|
+
*/
|
|
465
|
+
list(options?: RequestOptions): Promise<Passkey[]>;
|
|
466
|
+
/**
|
|
467
|
+
* `DELETE /webauthn_credentials/:id` - removes one passkey. `204`, no body.
|
|
468
|
+
*
|
|
469
|
+
* The lookup is scoped to the caller (`Current.user.webauthn_credentials`),
|
|
470
|
+
* so somebody else's id is a `404` and never a `403`: the endpoint does not
|
|
471
|
+
* confirm that the id exists. `destroyable_by?` additionally lets an admin
|
|
472
|
+
* through, but only for a passkey they can already find, which by that
|
|
473
|
+
* scoping means their own.
|
|
474
|
+
*
|
|
475
|
+
* Nothing stops the last passkey being removed. There is no "you would lock
|
|
476
|
+
* yourself out" guard, because passwords and OAuth identities are still
|
|
477
|
+
* there; a caller whose UI presents passkeys as the only sign-in method owns
|
|
478
|
+
* that warning itself.
|
|
479
|
+
*
|
|
480
|
+
* Deleting is one-way and the credential on the device is NOT revoked - the
|
|
481
|
+
* user keeps a dead passkey in their OS keychain that will offer itself at
|
|
482
|
+
* the next sign-in and then fail with "Unknown passkey."
|
|
483
|
+
*
|
|
484
|
+
* Requires a credential. General authenticated ceiling, 600/min.
|
|
485
|
+
*
|
|
486
|
+
* @throws {OmsApiError} 404 `"Passkey not found."` for an unknown id, or for
|
|
487
|
+
* one belonging to someone else.
|
|
488
|
+
*/
|
|
489
|
+
remove(id: Id, options?: RequestOptions): Promise<void>;
|
|
490
|
+
/**
|
|
491
|
+
* `POST /webauthn_credentials/registration_options` - starts enrolment and
|
|
492
|
+
* returns the arguments for `navigator.credentials.create()`.
|
|
493
|
+
*
|
|
494
|
+
* Two side effects, both of which matter:
|
|
495
|
+
*
|
|
496
|
+
* 1. **It assigns `users.webauthn_id` on first use.** An account that has
|
|
497
|
+
* never touched passkeys gets one minted here (`WebAuthn.generate_user_id`,
|
|
498
|
+
* written with `update_column`, so no callbacks and no `updated_at` bump).
|
|
499
|
+
* That handle is then permanent and is what every passkey on this account
|
|
500
|
+
* is bound to.
|
|
501
|
+
* 2. **It caches a challenge under a key that is per USER, not per call**
|
|
502
|
+
* (`webauthn:reg:<user id>`). Calling this twice for one account
|
|
503
|
+
* OVERWRITES the first challenge, so two registrations in flight at once
|
|
504
|
+
* means the older one fails verification with "Registration challenge
|
|
505
|
+
* expired" even though nothing expired. Do not call it speculatively, do
|
|
506
|
+
* not call it to warm a screen, and do not let a button fire it twice.
|
|
507
|
+
*
|
|
508
|
+
* The challenge lives **two minutes** (`CHALLENGE_TTL`), and the `timeout`
|
|
509
|
+
* inside the options is also 120000. Those are the same number, and the cache
|
|
510
|
+
* clock starts BEFORE the response is even sent, so a user who lets the OS
|
|
511
|
+
* sheet sit open for its full advertised timeout arrives after the challenge
|
|
512
|
+
* has gone. Treat 2 minutes as the budget for the whole round trip.
|
|
513
|
+
*
|
|
514
|
+
* Requires a credential. General authenticated ceiling, 600/min.
|
|
515
|
+
*
|
|
516
|
+
* An empty JSON object is sent as the body. The controller reads nothing from
|
|
517
|
+
* it; it is there so the request carries `Content-Type: application/json`
|
|
518
|
+
* like every other POST in this API rather than arriving bodyless.
|
|
519
|
+
*/
|
|
520
|
+
registrationOptions(options?: RequestOptions): Promise<PasskeyRegistrationOptions>;
|
|
521
|
+
/**
|
|
522
|
+
* `POST /webauthn_credentials/registration` - finishes enrolment. `201` with
|
|
523
|
+
* the new {@link Passkey}.
|
|
524
|
+
*
|
|
525
|
+
* `input.credential` is posted VERBATIM. Nothing here copies it, reshapes it,
|
|
526
|
+
* filters its keys or rewrites a `null` inside it, because the server reads
|
|
527
|
+
* it with `params.require(:credential).to_unsafe_h` and hands the raw hash to
|
|
528
|
+
* the gem, which then checks a signature over those exact bytes.
|
|
529
|
+
*
|
|
530
|
+
* ## The failure modes, and which of them are honest
|
|
531
|
+
*
|
|
532
|
+
* The controller rescues `WebAuthn::Error` and turns it into a `400`. Some of
|
|
533
|
+
* what can go wrong here is NOT a `WebAuthn::Error`, and those reach you as a
|
|
534
|
+
* `500`:
|
|
535
|
+
*
|
|
536
|
+
* - `type` that is not exactly `"public-key"`, or an `id` and `rawId` whose
|
|
537
|
+
* decoded bytes differ: `PublicKeyCredential#verify` calls bare
|
|
538
|
+
* `raise("invalid type")` / `raise("invalid id")`, which are `RuntimeError`
|
|
539
|
+
* and slip past the rescue;
|
|
540
|
+
* - `rawId` absent or `null`: the decoder calls `end_with?` on it and raises
|
|
541
|
+
* `NoMethodError`. See {@link normalizePasskeyRegistrationCredential},
|
|
542
|
+
* which fills `rawId` from `id` for the Android case where the platform
|
|
543
|
+
* really does send `null`;
|
|
544
|
+
* - `credential` absent altogether: `params.require` raises
|
|
545
|
+
* `ParameterMissing`, which Rails renders as `400`.
|
|
546
|
+
*
|
|
547
|
+
* So a `500` from this endpoint is a malformed payload, not an outage, and it
|
|
548
|
+
* is the single most likely thing to be wrong on a client that hand-builds
|
|
549
|
+
* the credential. A genuine verification failure - wrong challenge, wrong
|
|
550
|
+
* origin, bad attestation - is the `400` `"Passkey registration could not be
|
|
551
|
+
* verified."`.
|
|
552
|
+
*
|
|
553
|
+
* The default retry policy applies, which for a POST means only a `429` is
|
|
554
|
+
* replayed. That is safe here: the challenge is only deleted after a
|
|
555
|
+
* successful save, so a rate-limited attempt leaves the ceremony intact.
|
|
556
|
+
*
|
|
557
|
+
* Requires a credential. General authenticated ceiling, 600/min - this route
|
|
558
|
+
* is NOT in the 20/min webauthn bucket, which covers `authentication*` only.
|
|
559
|
+
*
|
|
560
|
+
* @throws {OmsAuthError} 401 `"Registration challenge expired. Please try
|
|
561
|
+
* again."` when more than two minutes passed since
|
|
562
|
+
* {@link registrationOptions}, or when a second call to it overwrote this
|
|
563
|
+
* ceremony's challenge.
|
|
564
|
+
* @throws {OmsApiError} 400 when verification fails, or when the row will not
|
|
565
|
+
* save - which in practice means `external_id` is already taken, i.e. this
|
|
566
|
+
* authenticator is already registered and `excludeCredentials` did not stop
|
|
567
|
+
* it.
|
|
568
|
+
*/
|
|
569
|
+
register(input: RegisterPasskeyInput, options?: RequestOptions): Promise<Passkey>;
|
|
570
|
+
/**
|
|
571
|
+
* `POST /webauthn_credentials/authentication_options` - starts a sign-in and
|
|
572
|
+
* returns the challenge handle plus the arguments for
|
|
573
|
+
* `navigator.credentials.get()`.
|
|
574
|
+
*
|
|
575
|
+
* **Send this with NO credential.** The route is
|
|
576
|
+
* `allow_unauthenticated_access`, and the whole point is that there is no
|
|
577
|
+
* session yet. The transport always attaches `Authorization` when the client
|
|
578
|
+
* holds a token, and the caller cannot strip it per request, so a client that
|
|
579
|
+
* might be signed in should build an anonymous one for the login flow.
|
|
580
|
+
*
|
|
581
|
+
* The ceremony is discoverable: `allowCredentials` comes back empty, the OS
|
|
582
|
+
* shows every passkey it holds for the domain, and the user picks the
|
|
583
|
+
* account. There is no email step, and asking for one would not help.
|
|
584
|
+
*
|
|
585
|
+
* Each call mints a fresh `handle` and caches a challenge under it for two
|
|
586
|
+
* minutes, so unlike {@link registrationOptions} concurrent calls do not
|
|
587
|
+
* fight: they are independent ceremonies. They do share the rate limit.
|
|
588
|
+
*
|
|
589
|
+
* **20 requests per minute per IP, shared with {@link authenticate}.**
|
|
590
|
+
*
|
|
591
|
+
* Retrying is off here, which deviates from the transport's default of
|
|
592
|
+
* replaying a `429`. The reason is the clock rather than safety: the replay
|
|
593
|
+
* sleeps out `Retry-After`, which Rack::Attack sets from a one-minute window,
|
|
594
|
+
* and then hands back a challenge that lives two minutes and still has an OS
|
|
595
|
+
* sheet and a second rate-limited request ahead of it. Waiting silently
|
|
596
|
+
* inside the SDK is likelier to produce a login that dies at the last step
|
|
597
|
+
* than one that succeeds. Surfacing the `429` lets the caller back off
|
|
598
|
+
* visibly and start a fresh ceremony. Pass `retry: {}` to opt back in.
|
|
599
|
+
*
|
|
600
|
+
* @throws {OmsQuotaError} 429 when the shared per-IP bucket is spent.
|
|
601
|
+
*/
|
|
602
|
+
authenticationOptions(options?: RequestOptions): Promise<PasskeyAuthenticationChallenge>;
|
|
603
|
+
/**
|
|
604
|
+
* `POST /webauthn_credentials/authentication` - finishes a sign-in. `201`
|
|
605
|
+
* with a {@link PasskeySession} carrying the new token.
|
|
606
|
+
*
|
|
607
|
+
* **Send this with NO credential** and read `token` off the answer; that
|
|
608
|
+
* token is the credential from here on. A browser additionally receives the
|
|
609
|
+
* same token as an httpOnly cookie and can ignore the body field entirely.
|
|
610
|
+
*
|
|
611
|
+
* `input.credential` is posted VERBATIM, for the same reason and with the
|
|
612
|
+
* same care as {@link register}.
|
|
613
|
+
*
|
|
614
|
+
* ## An assertion is spent exactly once, so nothing here is replayed
|
|
615
|
+
*
|
|
616
|
+
* The controller deletes the cached challenge BEFORE it verifies anything.
|
|
617
|
+
* Once a request has reached the controller the handle is gone whatever the
|
|
618
|
+
* outcome, so a second attempt carrying the same body answers `401 "Login
|
|
619
|
+
* challenge expired."` and reports the wrong cause for whatever actually
|
|
620
|
+
* went wrong.
|
|
621
|
+
*
|
|
622
|
+
* The transport's default policy already declines to replay a `POST` after a
|
|
623
|
+
* torn connection or a `5xx`, which is exactly right for that reason. The one
|
|
624
|
+
* outcome it WOULD replay is a `429`, and this method turns that off as well.
|
|
625
|
+
* A `429` comes from Rack::Attack, ahead of the router, so it genuinely did
|
|
626
|
+
* not spend the handle; but `Retry-After` is set from a one-minute window,
|
|
627
|
+
* the challenge lives two minutes, and the OS sheet has already eaten part of
|
|
628
|
+
* that. Sleeping through the rate limit inside the SDK converts it into an
|
|
629
|
+
* expired challenge, silently, and reports the failure at the wrong endpoint.
|
|
630
|
+
* Surface it instead and start a new ceremony from
|
|
631
|
+
* {@link authenticationOptions}.
|
|
632
|
+
*
|
|
633
|
+
* A `500` here means the same malformed-payload family described on
|
|
634
|
+
* {@link register}: a missing `rawId`, a wrong `type`, an `id` that does not
|
|
635
|
+
* decode to the same bytes as `rawId`.
|
|
636
|
+
*
|
|
637
|
+
* **20 requests per minute per IP, shared with
|
|
638
|
+
* {@link authenticationOptions}.** Every sign-in attempt costs two.
|
|
639
|
+
*
|
|
640
|
+
* @throws {OmsAuthError} 401 for all five of: an expired or already-spent
|
|
641
|
+
* handle (`"Login challenge expired. Please try again."`); a credential the
|
|
642
|
+
* server has never seen (`"Unknown passkey."`); a deactivated account
|
|
643
|
+
* (`"This account is deactivated."`); a failed signature, wrong origin or
|
|
644
|
+
* wrong challenge (`"Passkey could not be verified."`); and a sign counter
|
|
645
|
+
* that did not advance, which the gem treats as a cloned authenticator and
|
|
646
|
+
* which reports as that same message. Note that passkeys synced through a
|
|
647
|
+
* keychain report a counter of `0` forever, and `valid_sign_count?` lets
|
|
648
|
+
* `0` against `0` through, so that last case does not fire for them.
|
|
649
|
+
* @throws {OmsQuotaError} 429 when the shared per-IP bucket is spent.
|
|
650
|
+
*/
|
|
651
|
+
authenticate(input: AuthenticatePasskeyInput, options?: RequestOptions): Promise<PasskeySession>;
|
|
652
|
+
}
|