@spfn/auth 0.3.0-beta.7 → 0.3.0-beta.8
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 +378 -0
- package/dist/client-proof.d.ts +23 -12
- package/dist/client-proof.js +143 -4
- package/dist/client-proof.js.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +76 -3
- package/dist/errors.js +45 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +46 -6
- package/dist/index.js +75 -0
- package/dist/index.js.map +1 -1
- package/dist/{authenticate-Mg9D7Nys.d.ts → machine-principals-Bd5hp76H.d.ts} +343 -6
- package/dist/nextjs/api.js +190 -9
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.js +59 -8
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +719 -89
- package/dist/server.js +1383 -440
- package/dist/server.js.map +1 -1
- package/migrations/20260901091716_fine_arclight/migration.sql +21 -0
- package/migrations/20260901091716_fine_arclight/snapshot.json +3849 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -136,6 +136,7 @@ real secret values out of band, never commit them.
|
|
|
136
136
|
| `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
|
|
137
137
|
| `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `12` (native bcrypt, off the event loop) |
|
|
138
138
|
| `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
|
|
139
|
+
| `SPFN_AUTH_CSRF` | `.env.local` | — | `off` \| `warn` \| `enforce`; unset behaves as `warn` — see [CSRF protection](#csrf-protection) |
|
|
139
140
|
| `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
|
|
140
141
|
| `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
|
|
141
142
|
| `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | — | comma-separated; default `email,profile` |
|
|
@@ -184,6 +185,11 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
184
185
|
| `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
|
|
185
186
|
| `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
|
|
186
187
|
| `login` | POST `/_auth/login` | public | password login + new session key |
|
|
188
|
+
| `startDeviceAuth` | POST `/_auth/device/start` | public | begin a device-code login — see [Device-code login](#device-code-login) |
|
|
189
|
+
| `pollDeviceAuth` | POST `/_auth/device/poll` | public | ask whether the request was answered; the approved answer *is* the login |
|
|
190
|
+
| `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
|
|
191
|
+
| `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
|
|
192
|
+
| `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
|
|
187
193
|
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
188
194
|
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
189
195
|
| `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
|
|
@@ -287,6 +293,100 @@ override it there to change the copy.
|
|
|
287
293
|
`spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
|
|
288
294
|
both are one-time: a link opens one setup session, and a setup session sets one password.
|
|
289
295
|
|
|
296
|
+
### Device-code login
|
|
297
|
+
|
|
298
|
+
A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI
|
|
299
|
+
on a headless box. The new device shows a short code; the account owner types that code on a
|
|
300
|
+
device that is already signed in.
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// On the new device — it has no key on file, so this call is public.
|
|
304
|
+
const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
|
|
305
|
+
await authApi.startDeviceAuth.call({ body: {
|
|
306
|
+
publicKey, keyId, fingerprint, algorithm: 'ES256',
|
|
307
|
+
deviceName: 'Living room TV', platform: 'desktop',
|
|
308
|
+
} });
|
|
309
|
+
|
|
310
|
+
// Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
|
|
311
|
+
const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
|
|
312
|
+
// → { status: 'pending', intervalMillis }
|
|
313
|
+
// → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// On the signed-in device — the user typed the code they read off the other screen.
|
|
318
|
+
const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
|
|
319
|
+
// → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
|
|
320
|
+
|
|
321
|
+
await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuth
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**There is no token handed over, because there is no token.** Every request in this system is
|
|
325
|
+
signed by the calling device's own key, so "logging a device in" means getting its public key
|
|
326
|
+
into `user_public_keys` under the right account — which is exactly what the winning poll does.
|
|
327
|
+
That is why the approved answer is the same shape `login` returns: from the client's side the
|
|
328
|
+
two ways in are indistinguishable.
|
|
329
|
+
|
|
330
|
+
- **Only ever show the code on the new device's screen.** The whole attack on this flow is
|
|
331
|
+
someone sending a victim a code and asking them to approve it — a support call, a chat
|
|
332
|
+
message, a "verify your account" email. A code that arrived any way other than off the
|
|
333
|
+
device in front of you is an attack. This is why `info` and `approve` answer with the
|
|
334
|
+
requesting device's name, platform and fingerprint prefix, and why an approval screen that
|
|
335
|
+
shows only the code is doing it wrong: it is asking the user to confirm a number they were
|
|
336
|
+
just told.
|
|
337
|
+
- **The device code is stored only as a SHA-256 hash**, like the ops-token and signup-link
|
|
338
|
+
secrets. It is returned once. A dump of `spfn_auth.device_authorizations` does not let its
|
|
339
|
+
reader finish anyone's login.
|
|
340
|
+
- **The user code is stored in the clear, and that is fine** — it authorizes nothing without
|
|
341
|
+
an approver who is already signed in. It is drawn from an alphabet with no `0`/`O` or
|
|
342
|
+
`1`/`I`/`L`, since it is read off one screen and typed on another.
|
|
343
|
+
- **A decision is made once.** Approve and deny move the record from `pending` and nowhere
|
|
344
|
+
else, so a second approval, a deny after an approve, or two approvals racing each other all
|
|
345
|
+
get `DeviceAuthAlreadyHandledError` (409) — a refusal is never undone.
|
|
346
|
+
- **The approval is one-shot.** The poll that registers the key spends the record in the same
|
|
347
|
+
statement that reads it, so of two polls arriving together exactly one registers the key and
|
|
348
|
+
the other is answered as if the code were unknown.
|
|
349
|
+
- **A spent code and a code that never existed answer identically** (`DeviceAuthNotFoundError`,
|
|
350
|
+
404). Saying "that one was real, but it is used up" is the difference between guessing at
|
|
351
|
+
random and knowing a guess landed. Every route that accepts a code is rate limited for the
|
|
352
|
+
same reason: `start` and `poll` per IP, `info` / `approve` / `deny` per IP *and* per calling
|
|
353
|
+
account.
|
|
354
|
+
- **Expiry outranks state.** A code that sat past its TTL is expired whatever it says, so an
|
|
355
|
+
approval nobody collected in time registers nothing. The TTL travels in the statement that
|
|
356
|
+
moves the record, not only in the read before it, so a code cannot be spent by a poll that
|
|
357
|
+
read it a moment before it died.
|
|
358
|
+
- **A global revocation reaches the codes too.** `revoke-all`, a password change and a
|
|
359
|
+
deletion request each refuse the account's live device authorizations, so an approval nobody
|
|
360
|
+
collected cannot register a fresh key seconds after the user signed everything out — which
|
|
361
|
+
would hand one back to exactly the device they were cutting off. Revoking a single key,
|
|
362
|
+
logging out and rotating a key do not: those name one device, and the waiting one is not it.
|
|
363
|
+
- **The poll re-checks the account.** It is a login, so it refuses a suspended or
|
|
364
|
+
pending-deletion account with the same errors `/_auth/login` does. Approval and collection
|
|
365
|
+
are separate moments, and what the account is when the key is registered is what counts.
|
|
366
|
+
- **`start` bounds what it stores.** It is the one route that takes key material from a caller
|
|
367
|
+
who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
|
|
368
|
+
generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
|
|
369
|
+
and small next to the megabyte that would otherwise sit in a table no job clears.
|
|
370
|
+
- **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
|
|
371
|
+
`expiresAtMillis` in the start response is for the waiting device's countdown display, and
|
|
372
|
+
nothing the client believes about the time reaches the server's judgement.
|
|
373
|
+
|
|
374
|
+
Two knobs, both announced to the waiting device in the start response and therefore resolved
|
|
375
|
+
at lifecycle time rather than read per call:
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
createAuthLifecycle({
|
|
379
|
+
deviceAuth: {
|
|
380
|
+
ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
|
|
381
|
+
intervalMs: 5000, // poll interval the server asks for. default 5s
|
|
382
|
+
},
|
|
383
|
+
})
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
|
|
387
|
+
stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
|
|
388
|
+
not run out.
|
|
389
|
+
|
|
290
390
|
### Registered devices (key management)
|
|
291
391
|
|
|
292
392
|
Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
|
|
@@ -337,6 +437,10 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
|
|
|
337
437
|
- **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
|
|
338
438
|
"sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
|
|
339
439
|
only as a side effect of changing a password, which nobody does for that reason.
|
|
440
|
+
- **It also refuses device-code approvals still in flight**, in both modes, because an approved
|
|
441
|
+
code is a key that has not been handed out yet: the next poll would register a fresh active one
|
|
442
|
+
and undo the sign-out. `revokedCount` still counts keys only — a code nobody collected was
|
|
443
|
+
never a session. See [Device-code login](#device-code-login).
|
|
340
444
|
- **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
|
|
341
445
|
the answer is only ever "not yours" and reveals nothing about other accounts.
|
|
342
446
|
- **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
|
|
@@ -779,6 +883,134 @@ export default async function AdminPage()
|
|
|
779
883
|
Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
|
|
780
884
|
`hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
|
|
781
885
|
|
|
886
|
+
## CSRF protection
|
|
887
|
+
|
|
888
|
+
Cookie-authenticated mutations carry a CSRF token by default. Nothing to write: the
|
|
889
|
+
Next.js proxy issues the token with the session and the api client sends it back.
|
|
890
|
+
|
|
891
|
+
**What it protects.** The session cookie is `SameSite=Lax`, which already blocks the
|
|
892
|
+
classic cross-site form POST. What remains is what Lax does not cover: a sibling
|
|
893
|
+
subdomain that can write cookies on your parent domain (an XSS on `blog.example.com`
|
|
894
|
+
against `app.example.com`), browsers that predate or mis-implement Lax, and a domain
|
|
895
|
+
layout that drifts into `SameSite=None` later. This closes those.
|
|
896
|
+
|
|
897
|
+
**What it does not protect.** Nothing here helps against XSS on your own origin.
|
|
898
|
+
Script running on your origin can read the token cookie and call your API as the
|
|
899
|
+
user — that is true of every CSRF scheme, and no token design changes it. Same-origin
|
|
900
|
+
XSS is out of scope; Content-Security-Policy and output escaping are the answer to it.
|
|
901
|
+
|
|
902
|
+
### How it works
|
|
903
|
+
|
|
904
|
+
- On login, OAuth finalize, key rotation and every session renewal, the proxy sets
|
|
905
|
+
`spfn_csrf` — a readable (non-HttpOnly) cookie holding only an HMAC of the session's
|
|
906
|
+
key id, keyed by a subkey derived from `SPFN_AUTH_SESSION_SECRET`. No new variable,
|
|
907
|
+
and the raw session secret is never used as the token key. Sessions that predate
|
|
908
|
+
the feature get one on their first authenticated response, so upgrading does not
|
|
909
|
+
require anyone to sign in again.
|
|
910
|
+
- The api client mirrors the cookie into the `x-spfn-csrf` header on **every** RPC call,
|
|
911
|
+
GET-shaped ones included — see "Which requests are checked" for why it cannot narrow
|
|
912
|
+
that itself. Where the header is *checked* is the proxy's decision, not the client's.
|
|
913
|
+
- The proxy **recomputes** the expected value from the session it just unsealed and
|
|
914
|
+
compares it to the header, in constant time. It never compares the cookie to the
|
|
915
|
+
header — that is the classic double-submit weakness, and it is exactly what a
|
|
916
|
+
sibling subdomain defeats by tossing a cookie it chose. A tossed cookie fails here.
|
|
917
|
+
- The token derives from the session key id, so rotating the key invalidates it. The
|
|
918
|
+
proxy reissues the cookie in the same response that rotates or renews the session.
|
|
919
|
+
|
|
920
|
+
The check runs in the proxy, not the backend, because only the proxy knows the
|
|
921
|
+
request's credential was ambient: it turns the session cookie into a short-lived
|
|
922
|
+
bearer JWT, so the backend sees `scheme:'bearer'` for cookie callers and for genuine
|
|
923
|
+
bearer clients alike.
|
|
924
|
+
|
|
925
|
+
### Which requests are checked
|
|
926
|
+
|
|
927
|
+
Only requests the proxy authenticates from the session cookie, and only when the
|
|
928
|
+
resolved **route** method is not GET/HEAD/OPTIONS.
|
|
929
|
+
|
|
930
|
+
Route method, not the method the browser used to reach the proxy. The api client picks
|
|
931
|
+
its wire method from whether the input has a body, and holds no route map — that is the
|
|
932
|
+
point of "no metadata codegen required" — so a mutation with nothing to send travels as
|
|
933
|
+
GET. `logout` is `POST /_auth/logout`; `revokeOpsToken` is
|
|
934
|
+
`DELETE /_auth/ops-tokens/:id`, called with only a path param. Both are `GET` on the
|
|
935
|
+
wire and both are forwarded as the route's real method. A client that withheld the
|
|
936
|
+
header on GET-shaped calls would therefore 403 them under `enforce`, which is why the
|
|
937
|
+
contract is "every call carries it" and the proxy alone decides where it is checked.
|
|
938
|
+
Gating in the proxy on the wire method would be worse still: a cross-site top-level GET
|
|
939
|
+
navigation *does* carry a `SameSite=Lax` cookie, so every mutation would stay reachable
|
|
940
|
+
that way.
|
|
941
|
+
|
|
942
|
+
Untouched, by construction: requests with no session, direct-to-backend bearer
|
|
943
|
+
clients, `clientProofV1` mobile callers, machine and ops tokens. None of them pass
|
|
944
|
+
through this code. A request without a session is answered exactly as before (the
|
|
945
|
+
backend returns 401) — a CSRF refusal only ever answers an authenticated request, so
|
|
946
|
+
the refusal itself cannot tell an anonymous caller whether anyone is signed in.
|
|
947
|
+
|
|
948
|
+
### Modes
|
|
949
|
+
|
|
950
|
+
| Mode | Behaviour |
|
|
951
|
+
|---|---|
|
|
952
|
+
| `off` | No check. |
|
|
953
|
+
| `warn` | **Default.** Allows the request, logs one line per request that would be refused. |
|
|
954
|
+
| `enforce` | Refuses with `403 {"error":"Forbidden","message":"CSRF token missing or invalid"}`. |
|
|
955
|
+
|
|
956
|
+
Existing apps get signal before breakage: unset means `warn`. Watch for
|
|
957
|
+
`@spfn/auth:interceptor:csrf` lines, then switch on. Apps scaffolded by `spfn init`
|
|
958
|
+
start at `enforce`.
|
|
959
|
+
|
|
960
|
+
```bash
|
|
961
|
+
# .env.local — read by the Next.js process, where the proxy runs
|
|
962
|
+
SPFN_AUTH_CSRF=enforce
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
```typescript
|
|
966
|
+
import { configureAuth } from '@spfn/auth/server';
|
|
967
|
+
|
|
968
|
+
configureAuth({
|
|
969
|
+
csrf: {
|
|
970
|
+
mode: 'enforce',
|
|
971
|
+
// Exact backend route paths, params already substituted — not /api/rpc/… URLs.
|
|
972
|
+
// For endpoints a browser session never calls, e.g. webhook receivers that
|
|
973
|
+
// authenticate themselves by signature. An exempt path is unprotected for
|
|
974
|
+
// cookie callers too, so list only endpoints that carry their own auth.
|
|
975
|
+
exemptPaths: ['/webhooks/stripe'],
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
`configureAuth` wins over the environment variable. `enforce` and `warn` both need
|
|
981
|
+
`SPFN_AUTH_SESSION_SECRET` — sessions need it anyway — and refuse rather than quietly
|
|
982
|
+
passing everything if it is missing.
|
|
983
|
+
|
|
984
|
+
### If a request is refused
|
|
985
|
+
|
|
986
|
+
A refusal in a running app almost always means the token cookie is gone or stale while
|
|
987
|
+
the session is not — cleared by hand or by an extension, or a session that predates this
|
|
988
|
+
feature. Rotation is not a cause: the response that rotates the key reissues the cookie
|
|
989
|
+
in the same breath, and one browser has one jar, so other tabs pick the new value up
|
|
990
|
+
with it.
|
|
991
|
+
|
|
992
|
+
Two things repair it, and both are mechanical:
|
|
993
|
+
|
|
994
|
+
- **The 403 carries the fix.** The proxy is the one emitting the refusal, so it sets a
|
|
995
|
+
fresh `spfn_csrf` on that very response. A browser that repeats the mutation succeeds.
|
|
996
|
+
The refusal is otherwise unchanged — same status, same body.
|
|
997
|
+
- **Any authenticated response reissues a wrong one.** A response whose request arrived
|
|
998
|
+
with no CSRF cookie, or with one that no longer matches the session, queues the
|
|
999
|
+
correct value. A cookie that is merely *present* is not taken as proof it is right.
|
|
1000
|
+
|
|
1001
|
+
**The client does not retry a refused call**, so a user sees one failure before the
|
|
1002
|
+
repaired state takes effect — the framework fixes the browser, not the click.
|
|
1003
|
+
|
|
1004
|
+
**Limitation — calls made from the server.** A Server Component cannot set cookies at
|
|
1005
|
+
all, and Next.js does not forward `Set-Cookie` from a fetch the api client made on the
|
|
1006
|
+
server to the browser. So neither repair reaches the jar when the refused call came from
|
|
1007
|
+
a Server Component, a Server Action or a Route Handler; the next browser-originated
|
|
1008
|
+
request through the proxy is what heals it. Server-side callers otherwise need no
|
|
1009
|
+
change: the api client reads the whole jar through `next/headers`, and an explicit
|
|
1010
|
+
`cookies` option merges over that rather than replacing it. Only a caller that
|
|
1011
|
+
hand-builds a jar somewhere `cookies()` cannot be reached — build time, static
|
|
1012
|
+
generation — has to include the CSRF cookie itself.
|
|
1013
|
+
|
|
782
1014
|
## How do I define roles and permissions?
|
|
783
1015
|
|
|
784
1016
|
Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
|
|
@@ -871,6 +1103,12 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
|
|
|
871
1103
|
});
|
|
872
1104
|
```
|
|
873
1105
|
|
|
1106
|
+
`authLoginEvent`'s `provider` is `'email'`, `'phone'`, a social provider, or `'device'` — the
|
|
1107
|
+
last one being a [device-code login](#device-code-login), where the account was proven on
|
|
1108
|
+
another device that was already signed in and no credential was presented here.
|
|
1109
|
+
`authRegisterEvent` does not accept `'device'`: a device-code request can only ever be
|
|
1110
|
+
approved by an account that already exists, so it is never a signup.
|
|
1111
|
+
|
|
874
1112
|
Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
|
|
875
1113
|
`InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
|
|
876
1114
|
`AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
|
|
@@ -1107,6 +1345,7 @@ Every operation in the exported bundle carries `since` — the contract version
|
|
|
1107
1345
|
| `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
|
|
1108
1346
|
| `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
|
|
1109
1347
|
| `core.time` | 0.9.0 |
|
|
1348
|
+
| `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
|
|
1110
1349
|
|
|
1111
1350
|
- **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
|
|
1112
1351
|
contract version passes or refuses the whole surface, so these three fields change no verdict here.
|
|
@@ -1242,6 +1481,145 @@ x-acme-service-token: <the app's own credential>
|
|
|
1242
1481
|
The field stays informational: downstream permission and tenant code takes one principal shape and
|
|
1243
1482
|
never branches on how it was produced.
|
|
1244
1483
|
|
|
1484
|
+
## Machine principals (`registerMachineVerifier`)
|
|
1485
|
+
|
|
1486
|
+
A machine credential is issued by a service to a non-interactive process, and its subject is
|
|
1487
|
+
an account or a tenant, not a person. `AuthContext` cannot hold one — it requires a `users`
|
|
1488
|
+
row — and resolving a machine token to its owning user is worse than the type error: it makes
|
|
1489
|
+
the machine's request indistinguishable from that user's own session.
|
|
1490
|
+
|
|
1491
|
+
So a machine principal never enters `AuthContext`. It lives in its own context key, is read by
|
|
1492
|
+
its own helper, and is admitted by its own middleware:
|
|
1493
|
+
|
|
1494
|
+
```typescript
|
|
1495
|
+
import { machineAuth, requireMachineScope, getMachinePrincipal } from '@spfn/auth/server';
|
|
1496
|
+
|
|
1497
|
+
export const ingest = route.post('/v1/ingest')
|
|
1498
|
+
.use([machineAuth, requireMachineScope('events:write')])
|
|
1499
|
+
.handler(async (c) =>
|
|
1500
|
+
{
|
|
1501
|
+
const { subjectType, subjectId } = getMachinePrincipal(c.raw)!;
|
|
1502
|
+
// subjectType: 'account' | 'service' | whatever the verifier named
|
|
1503
|
+
});
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
`getAuth(c)` on that route returns nothing, because nothing put a user there. That is the
|
|
1507
|
+
whole design: a machine request cannot impersonate a user session, not because a check
|
|
1508
|
+
forbids it but because no code path leads there.
|
|
1509
|
+
|
|
1510
|
+
**Ownership is not authentication.** Who issued a machine token, who owns it, and who may
|
|
1511
|
+
revoke or audit it are the registrant's data-level concerns — put the token id in `claims` and
|
|
1512
|
+
answer them from your own tables. What the request *acts as* is the token's own subject and
|
|
1513
|
+
scopes, and nothing here resolves a machine subject to a user.
|
|
1514
|
+
|
|
1515
|
+
### Registering a verifier
|
|
1516
|
+
|
|
1517
|
+
A verifier claims one namespace, by a raw `tokenPrefix` (for an opaque secret, the
|
|
1518
|
+
`spfn_ops_` shape) or by a `kidPrefix` on the unverified JOSE header of a JWS. Register at
|
|
1519
|
+
boot, before the first request:
|
|
1520
|
+
|
|
1521
|
+
```typescript
|
|
1522
|
+
import { registerMachineVerifier } from '@spfn/auth/server';
|
|
1523
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
1524
|
+
|
|
1525
|
+
const RUNTIME_JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'));
|
|
1526
|
+
|
|
1527
|
+
registerMachineVerifier({
|
|
1528
|
+
id: 'runtimeJwsV1',
|
|
1529
|
+
match: { kidPrefix: 'machine:runtime:' },
|
|
1530
|
+
verify: async (token) =>
|
|
1531
|
+
{
|
|
1532
|
+
const { payload } = await jwtVerify(token, RUNTIME_JWKS, { issuer: 'https://issuer.example.com' });
|
|
1533
|
+
|
|
1534
|
+
return {
|
|
1535
|
+
subjectType: 'account',
|
|
1536
|
+
subjectId: String(payload.sub),
|
|
1537
|
+
scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
|
|
1538
|
+
claims: { tokenId: payload.jti },
|
|
1539
|
+
scheme: 'runtimeJwsV1',
|
|
1540
|
+
};
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
The request carries it as an ordinary bearer token — no new wire format, and the
|
|
1546
|
+
profile-header channel is not involved:
|
|
1547
|
+
|
|
1548
|
+
```http
|
|
1549
|
+
POST /v1/ingest
|
|
1550
|
+
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hY2hpbmU6cnVudGltZTo...
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
- **Namespace your kids.** `machine:` is the convention this package documents, and a user
|
|
1554
|
+
session JWT never carries that shape. The prefix is what tells the two apart before either
|
|
1555
|
+
is verified.
|
|
1556
|
+
- **Conflicting discriminators are refused at registration** — a duplicate `id`, a duplicate
|
|
1557
|
+
prefix, or a prefix that would shadow an already-registered one (`machine:` swallowing
|
|
1558
|
+
`machine:runtime:`). Two verifiers one token could match would make admission depend on
|
|
1559
|
+
registration order, so that is a boot-time error rather than something the dispatch
|
|
1560
|
+
resolves per request.
|
|
1561
|
+
- **A `tokenPrefix` claims every token that starts with it**, and `authenticate` consults the
|
|
1562
|
+
registry before it decodes anything. A prefix a user's JWT could begin with (`ey…`) would
|
|
1563
|
+
therefore refuse every user session — pick a prefix no other credential on your surface
|
|
1564
|
+
shares, as `spfn_ops_` does.
|
|
1565
|
+
- **Register at boot, before the first request.** The registry is module state read on every
|
|
1566
|
+
dispatch, so a verifier registered later is simply a verifier the requests before it did
|
|
1567
|
+
not have. There is no unregistration and no reset — the same contract, and the same reason,
|
|
1568
|
+
as [`registerAuthProfile`](#custom-auth-profiles-registerauthprofile).
|
|
1569
|
+
- **Registering nothing costs nothing.** With no verifier registered, `authenticate` is two
|
|
1570
|
+
array-length checks away from what it was. The unverified JOSE header peek happens only
|
|
1571
|
+
once a `kidPrefix` verifier exists.
|
|
1572
|
+
- **`scheme` is the registry's answer**, not the verifier's: whatever a verifier returns
|
|
1573
|
+
there, the principal carries the `id` that admitted it, so an audit trail cannot be made to
|
|
1574
|
+
name the wrong verifier.
|
|
1575
|
+
|
|
1576
|
+
### The case table
|
|
1577
|
+
|
|
1578
|
+
| credential ↓ route → | `authenticate` (user) | `machineAuth` | `optionalAuth` |
|
|
1579
|
+
|---|---|---|---|
|
|
1580
|
+
| user bearer JWT | ✓ user (unchanged) | 401 | ✓ user (unchanged) |
|
|
1581
|
+
| machine token, registered namespace, valid | 401 — refused before the token is decoded | ✓ sets `machinePrincipal` | 401 |
|
|
1582
|
+
| machine token, registered namespace, verifier rejects | 401 | 401 | 401 |
|
|
1583
|
+
| machine-shaped token, unregistered namespace | 401 (the existing invalid-token path) | 401 | continues, no auth |
|
|
1584
|
+
| profile header + any Bearer | `PROFILE_REJECTED` (unchanged) | `PROFILE_REJECTED` | `PROFILE_REJECTED` |
|
|
1585
|
+
| nothing | 401 (unchanged) | 401 | continues, no auth |
|
|
1586
|
+
| valid principal, missing scope | — | 403 | — |
|
|
1587
|
+
| valid principal, sufficient scope | — | 200 | — |
|
|
1588
|
+
|
|
1589
|
+
Every 401 above is one message. Whether a namespace is registered, whether a presented token
|
|
1590
|
+
was ever valid, and whether a verifier rejected it are not inferable from the answer — the
|
|
1591
|
+
same non-disclosure rule the [ops-token](#ops-tokens-spfn-ops) table keeps. 403 is reserved
|
|
1592
|
+
for scope, where the caller is already authenticated; `requireMachineScope` matches scopes
|
|
1593
|
+
exactly and has no wildcard, and it fails closed with a 401 if it runs without `machineAuth`
|
|
1594
|
+
before it.
|
|
1595
|
+
|
|
1596
|
+
A verifier that throws something other than a refusal — a bug in registrant code — is the
|
|
1597
|
+
same generic 401 on the wire, with the real error logged. Never a 500 carrying registrant
|
|
1598
|
+
internals, and never a silent pass.
|
|
1599
|
+
|
|
1600
|
+
The last row of the unregistered-namespace case is the one asymmetry: a token in a namespace
|
|
1601
|
+
nobody registered is not a machine credential as far as this package can tell, so under
|
|
1602
|
+
`optionalAuth` it gets what any unusable bearer token has always got. A token in a
|
|
1603
|
+
*registered* namespace is refused there, because refusing it is the difference between
|
|
1604
|
+
"presented the wrong credential" and "presented none".
|
|
1605
|
+
|
|
1606
|
+
The non-disclosure above is therefore an `authenticate` and `machineAuth` property, not an
|
|
1607
|
+
`optionalAuth` one: on an `optionalAuth` route a caller can tell a registered namespace from
|
|
1608
|
+
an unregistered one, because one is refused and the other is served anonymously. Closing that
|
|
1609
|
+
gap would mean refusing every unusable bearer token on those routes — a change to behaviour
|
|
1610
|
+
that predates machine principals, and a worse trade than the inference it prevents. Mount
|
|
1611
|
+
`machineAuth` where the distinction matters.
|
|
1612
|
+
|
|
1613
|
+
### Issuance is yours
|
|
1614
|
+
|
|
1615
|
+
This package verifies machine tokens; it does not mint them. Issuance, rotation, and
|
|
1616
|
+
revocation belong to whoever owns the subject — keep the tokens short-lived, and prefer a
|
|
1617
|
+
signature you can verify offline (`kidPrefix` + JWKS) over a secret you must look up.
|
|
1618
|
+
|
|
1619
|
+
`opsTokenAuth` is the built-in instance of exactly this pattern, hand-written for one
|
|
1620
|
+
credential before the registry existed: its own context key (`opsToken`), its own scope guard,
|
|
1621
|
+
`AuthContext` never set. It keeps its own implementation and is not registered here.
|
|
1622
|
+
|
|
1245
1623
|
## Account Deletion & Recovery
|
|
1246
1624
|
|
|
1247
1625
|
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
package/dist/client-proof.d.ts
CHANGED
|
@@ -398,7 +398,7 @@ declare function getClientProofReplayStore(): ClientProofReplayStore;
|
|
|
398
398
|
*/
|
|
399
399
|
|
|
400
400
|
interface ContractOperation {
|
|
401
|
-
id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll' | typeof CORE_TIME_OPERATION_ID;
|
|
401
|
+
id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll' | 'auth.device.start' | 'auth.device.poll' | 'auth.device.info' | 'auth.device.approve' | 'auth.device.deny' | typeof CORE_TIME_OPERATION_ID;
|
|
402
402
|
method: 'GET' | 'POST';
|
|
403
403
|
path: string;
|
|
404
404
|
/**
|
|
@@ -411,7 +411,11 @@ interface ContractOperation {
|
|
|
411
411
|
requiresSession: boolean;
|
|
412
412
|
/** Absent only when the operation has no request body. */
|
|
413
413
|
requestType?: string;
|
|
414
|
-
|
|
414
|
+
/**
|
|
415
|
+
* Absent only when the operation answers with no body — a 204, which has
|
|
416
|
+
* nothing for a consumer to decode and therefore names no type.
|
|
417
|
+
*/
|
|
418
|
+
responseType?: string;
|
|
415
419
|
summary: string;
|
|
416
420
|
/**
|
|
417
421
|
* The contract version this operation first appeared in. Required, so an
|
|
@@ -460,16 +464,23 @@ declare const IMPORTED_CORE_TIME_CONTRACT: {
|
|
|
460
464
|
declare const CORE_PREREQUISITE_OPERATIONS: readonly ContractOperation[];
|
|
461
465
|
declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
|
|
462
466
|
/**
|
|
463
|
-
* The `/_auth` surface exported into the mobile contract: enrollment, login
|
|
464
|
-
* and
|
|
465
|
-
* operations — the dev handler never serves them, and their wire
|
|
466
|
-
* the `restOperations` section of the bundle, not `canonicalJson`.
|
|
467
|
-
*
|
|
468
|
-
* The
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
467
|
+
* The `/_auth` surface exported into the mobile contract: enrollment, login,
|
|
468
|
+
* key rotation and device-code login. These are ordinary SPFN REST routes, not
|
|
469
|
+
* canonical-JSON operations — the dev handler never serves them, and their wire
|
|
470
|
+
* rules are the `restOperations` section of the bundle, not `canonicalJson`.
|
|
471
|
+
*
|
|
472
|
+
* The `authProfile: 'none'` operations are the unproven class: they are accepted
|
|
473
|
+
* with neither proof headers nor a session header, because they are how a client
|
|
474
|
+
* obtains a key in the first place. `auth.keys.rotate` requires an authenticated
|
|
475
|
+
* caller (a clientProofV1 proof on this surface); an unproven call to it is
|
|
476
|
+
* refused like any failed admission.
|
|
477
|
+
*
|
|
478
|
+
* Device-code login is in both classes at once, because two devices run it. The
|
|
479
|
+
* device being let in calls `start` and `poll` unproven — it has no registered
|
|
480
|
+
* key to sign with, and getting one is the point of the flow. The approver calls
|
|
481
|
+
* `info`, `approve` and `deny` proven, from a device that is already signed in,
|
|
482
|
+
* which is what lets the server read the approving account from the caller
|
|
483
|
+
* rather than from the request body.
|
|
473
484
|
*/
|
|
474
485
|
declare const AUTH_SURFACE_OPERATIONS: readonly ContractOperation[];
|
|
475
486
|
/** The body is canonical JSON but not the declared request type. */
|
package/dist/client-proof.js
CHANGED
|
@@ -1078,6 +1078,60 @@ var AUTH_SURFACE_OPERATIONS = [
|
|
|
1078
1078
|
responseType: "RevokeAllKeysResponse",
|
|
1079
1079
|
summary: "Revokes every key the caller has, sparing the calling device unless asked otherwise.",
|
|
1080
1080
|
since: "0.4.1"
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
id: "auth.device.start",
|
|
1084
|
+
method: "POST",
|
|
1085
|
+
path: "/_auth/device/start",
|
|
1086
|
+
authProfile: "none",
|
|
1087
|
+
requiresSession: false,
|
|
1088
|
+
requestType: "StartDeviceAuthRequest",
|
|
1089
|
+
responseType: "StartDeviceAuthResponse",
|
|
1090
|
+
summary: "Parks a new device's public key and returns the codes it shows and polls with.",
|
|
1091
|
+
since: "0.10.0"
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
id: "auth.device.poll",
|
|
1095
|
+
method: "POST",
|
|
1096
|
+
path: "/_auth/device/poll",
|
|
1097
|
+
authProfile: "none",
|
|
1098
|
+
requiresSession: false,
|
|
1099
|
+
requestType: "PollDeviceAuthRequest",
|
|
1100
|
+
responseType: "PollDeviceAuthResponse",
|
|
1101
|
+
summary: "Asks whether the request has been answered; the approved answer is the login it produced.",
|
|
1102
|
+
since: "0.10.0"
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
id: "auth.device.info",
|
|
1106
|
+
method: "POST",
|
|
1107
|
+
path: "/_auth/device/info",
|
|
1108
|
+
authProfile: "clientProofV1",
|
|
1109
|
+
requiresSession: false,
|
|
1110
|
+
requestType: "DeviceAuthInfoRequest",
|
|
1111
|
+
responseType: "DeviceAuthInfoResponse",
|
|
1112
|
+
summary: "Describes the device waiting on a user code, so the approver can recognise it before deciding.",
|
|
1113
|
+
since: "0.10.0"
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
id: "auth.device.approve",
|
|
1117
|
+
method: "POST",
|
|
1118
|
+
path: "/_auth/device/approve",
|
|
1119
|
+
authProfile: "clientProofV1",
|
|
1120
|
+
requiresSession: false,
|
|
1121
|
+
requestType: "ApproveDeviceAuthRequest",
|
|
1122
|
+
responseType: "DeviceAuthInfoResponse",
|
|
1123
|
+
summary: "Lets the waiting device in, answering with the device it just let in.",
|
|
1124
|
+
since: "0.10.0"
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
id: "auth.device.deny",
|
|
1128
|
+
method: "POST",
|
|
1129
|
+
path: "/_auth/device/deny",
|
|
1130
|
+
authProfile: "clientProofV1",
|
|
1131
|
+
requiresSession: false,
|
|
1132
|
+
requestType: "DenyDeviceAuthRequest",
|
|
1133
|
+
summary: "Refuses the waiting device. Answers 204 with no body, so it names no response type.",
|
|
1134
|
+
since: "0.10.0"
|
|
1081
1135
|
}
|
|
1082
1136
|
];
|
|
1083
1137
|
var ContractTypeError = class extends Error {
|
|
@@ -1325,6 +1379,7 @@ import {
|
|
|
1325
1379
|
|
|
1326
1380
|
// src/server/types.ts
|
|
1327
1381
|
var KEY_ALGORITHM = ["ES256", "RS256"];
|
|
1382
|
+
var KEY_PLATFORM = ["ios", "android", "web", "desktop"];
|
|
1328
1383
|
|
|
1329
1384
|
// src/server/client-proof/wire-headers.ts
|
|
1330
1385
|
var CLIENT_IDENTITY_HEADERS = {
|
|
@@ -1342,9 +1397,9 @@ function isAppKind(kind) {
|
|
|
1342
1397
|
}
|
|
1343
1398
|
|
|
1344
1399
|
// src/server/client-proof/contract-bundle.ts
|
|
1345
|
-
var CONTRACT_VERSION = "0.
|
|
1400
|
+
var CONTRACT_VERSION = "0.10.0";
|
|
1346
1401
|
var CONTRACT_MAJOR = 0;
|
|
1347
|
-
var CONTRACT_SUPPORTED_RANGE = ">=0.
|
|
1402
|
+
var CONTRACT_SUPPORTED_RANGE = ">=0.10.0 <0.11.0";
|
|
1348
1403
|
function required(name, type) {
|
|
1349
1404
|
return { name, type, optional: false };
|
|
1350
1405
|
}
|
|
@@ -1515,7 +1570,7 @@ var CONTRACT_TYPES = [
|
|
|
1515
1570
|
fields: [
|
|
1516
1571
|
required("keyId", "string"),
|
|
1517
1572
|
optional("deviceName", "string"),
|
|
1518
|
-
optional("platform", "
|
|
1573
|
+
optional("platform", "KeyPlatform"),
|
|
1519
1574
|
required("algorithm", "KeyAlgorithm"),
|
|
1520
1575
|
required("fingerprintPrefix", "string"),
|
|
1521
1576
|
required("createdAtMillis", "integer"),
|
|
@@ -1557,10 +1612,94 @@ var CONTRACT_TYPES = [
|
|
|
1557
1612
|
required("revokedCount", "integer"),
|
|
1558
1613
|
required("currentKeyRevoked", "boolean")
|
|
1559
1614
|
]
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
name: "StartDeviceAuthRequest",
|
|
1618
|
+
fields: [
|
|
1619
|
+
required("publicKey", "string"),
|
|
1620
|
+
required("keyId", "string"),
|
|
1621
|
+
required("fingerprint", "string"),
|
|
1622
|
+
optional("algorithm", "KeyAlgorithm"),
|
|
1623
|
+
optional("deviceName", "string"),
|
|
1624
|
+
optional("platform", "KeyPlatform")
|
|
1625
|
+
]
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
name: "StartDeviceAuthResponse",
|
|
1629
|
+
fields: [
|
|
1630
|
+
required("deviceCode", "string"),
|
|
1631
|
+
required("userCode", "string"),
|
|
1632
|
+
required("expiresAtMillis", "integer"),
|
|
1633
|
+
required("intervalMillis", "integer")
|
|
1634
|
+
]
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
name: "PollDeviceAuthRequest",
|
|
1638
|
+
fields: [
|
|
1639
|
+
required("deviceCode", "string")
|
|
1640
|
+
]
|
|
1641
|
+
},
|
|
1642
|
+
/**
|
|
1643
|
+
* The poll union, flattened into the one shape this grammar can carry.
|
|
1644
|
+
*
|
|
1645
|
+
* `status` is the discriminant and the only required field; everything else
|
|
1646
|
+
* belongs to one branch and is therefore optional. `intervalMillis` is the
|
|
1647
|
+
* pending branch, and the five after it are the approved branch — the same
|
|
1648
|
+
* fields `LoginResponse` carries, because an approved poll is the login the
|
|
1649
|
+
* approval produced. `deviceAuthorization.pollStatusRule` states the pairing
|
|
1650
|
+
* the grammar cannot.
|
|
1651
|
+
*/
|
|
1652
|
+
{
|
|
1653
|
+
name: "PollDeviceAuthResponse",
|
|
1654
|
+
fields: [
|
|
1655
|
+
required("status", "DeviceAuthPollStatus"),
|
|
1656
|
+
optional("intervalMillis", "integer"),
|
|
1657
|
+
optional("userId", "string"),
|
|
1658
|
+
optional("publicId", "string"),
|
|
1659
|
+
optional("email", "string"),
|
|
1660
|
+
optional("phone", "string"),
|
|
1661
|
+
optional("passwordChangeRequired", "boolean")
|
|
1662
|
+
]
|
|
1663
|
+
},
|
|
1664
|
+
/**
|
|
1665
|
+
* Info, approve and deny each declare their own request type although all
|
|
1666
|
+
* three carry nothing but `userCode`. An operation's request shape is its
|
|
1667
|
+
* own: a field added to one of them later must not appear on the other two
|
|
1668
|
+
* by accident, which is what a shared type would do.
|
|
1669
|
+
*/
|
|
1670
|
+
{
|
|
1671
|
+
name: "DeviceAuthInfoRequest",
|
|
1672
|
+
fields: [
|
|
1673
|
+
required("userCode", "string")
|
|
1674
|
+
]
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
name: "DeviceAuthInfoResponse",
|
|
1678
|
+
fields: [
|
|
1679
|
+
optional("deviceName", "string"),
|
|
1680
|
+
optional("platform", "KeyPlatform"),
|
|
1681
|
+
required("fingerprintPrefix", "string"),
|
|
1682
|
+
required("requestedAtMillis", "integer"),
|
|
1683
|
+
required("expiresAtMillis", "integer")
|
|
1684
|
+
]
|
|
1685
|
+
},
|
|
1686
|
+
{
|
|
1687
|
+
name: "ApproveDeviceAuthRequest",
|
|
1688
|
+
fields: [
|
|
1689
|
+
required("userCode", "string")
|
|
1690
|
+
]
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
name: "DenyDeviceAuthRequest",
|
|
1694
|
+
fields: [
|
|
1695
|
+
required("userCode", "string")
|
|
1696
|
+
]
|
|
1560
1697
|
}
|
|
1561
1698
|
];
|
|
1562
1699
|
var CONTRACT_ENUMS = [
|
|
1563
|
-
{ name: "KeyAlgorithm", values: [...KEY_ALGORITHM] }
|
|
1700
|
+
{ name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
|
|
1701
|
+
{ name: "KeyPlatform", values: [...KEY_PLATFORM] },
|
|
1702
|
+
{ name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
|
|
1564
1703
|
];
|
|
1565
1704
|
var BUNDLE_FILENAME = "spfn-mobile-contract.json";
|
|
1566
1705
|
var BUNDLE_REPO_PATH = `contracts/mobile/${BUNDLE_FILENAME}`;
|