scorezilla 0.3.0-next.2 → 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/API.md +48 -0
- package/CHANGELOG.md +189 -0
- package/README.md +54 -0
- package/RECIPES.md +41 -0
- package/dist/{errors-CtXMAHtJ.d.cts → errors-CWTmormh.d.cts} +1 -1
- package/dist/{errors-CtXMAHtJ.d.ts → errors-CWTmormh.d.ts} +1 -1
- package/dist/identity.cjs +116 -4
- package/dist/identity.cjs.map +1 -1
- package/dist/identity.d.cts +53 -20
- package/dist/identity.d.ts +53 -20
- package/dist/identity.js +116 -4
- package/dist/identity.js.map +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +130 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +55 -3
- package/dist/server.d.ts +55 -3
- package/dist/server.js +130 -2
- package/dist/server.js.map +1 -1
- package/package.json +5 -4
package/API.md
CHANGED
|
@@ -325,6 +325,54 @@ For non-JWKS auth (Auth.js JWE sessions, opaque sessions, provider backend
|
|
|
325
325
|
SDKs), write your own `verify` — anything returning `{ playerId }` works. See
|
|
326
326
|
[RECIPES.md](./RECIPES.md) for worked recipes.
|
|
327
327
|
|
|
328
|
+
### `createGitHubOAuthHandler(config)`
|
|
329
|
+
|
|
330
|
+
The server half of `useAuthProvider({ provider: 'github' })` (see the
|
|
331
|
+
identity section below): a `(Request) => Promise<Response>` callback endpoint
|
|
332
|
+
that exchanges GitHub's OAuth `code` (the client secret stays server-side),
|
|
333
|
+
resolves the user id, and hands `{ id }` back to the sign-in popup via a
|
|
334
|
+
`postMessage` pinned to your game's origin. Deploy it anywhere that speaks
|
|
335
|
+
web `Request`/`Response`, and register its URL as the OAuth app's callback
|
|
336
|
+
URL on GitHub.
|
|
337
|
+
|
|
338
|
+
| Option | Type | Notes |
|
|
339
|
+
| --------------- | -------- | ------------------------------------------------------------------- |
|
|
340
|
+
| `clientId` | `string` | Your GitHub OAuth app client ID. Required. |
|
|
341
|
+
| `clientSecret` | `string` | Server-only. Required. |
|
|
342
|
+
| `allowedOrigin` | `string` | Exact origin of the game page (the `postMessage` target). Required. |
|
|
343
|
+
| `fetch?` | | Inject a fetch (testing / custom transport). |
|
|
344
|
+
|
|
345
|
+
## Identity presets (`scorezilla/identity`)
|
|
346
|
+
|
|
347
|
+
Browser-side helpers producing the `playerId` for `submitScore`. All of them
|
|
348
|
+
are **client-authoritative** — see the trust-boundary note in the
|
|
349
|
+
[README](./README.md#player-identity-scorezillaidentity); for spoof-proof
|
|
350
|
+
identity use the secure path above.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import {
|
|
354
|
+
useAnonymousPlayer, // { storageKey } → PlayerHandle (UUID, persisted)
|
|
355
|
+
usePromptedPlayer, // { storageKey, prompt } → Promise<PlayerHandle>
|
|
356
|
+
useServerAuthoritative, // () → marker; playerId comes from your server
|
|
357
|
+
useAuthProvider, // OAuth sign-in → Promise<AuthPlayerHandle | null>
|
|
358
|
+
} from 'scorezilla/identity';
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### `useAuthProvider(options)`
|
|
362
|
+
|
|
363
|
+
Discriminated on `provider`:
|
|
364
|
+
|
|
365
|
+
| Provider | Options | Flow |
|
|
366
|
+
| ---------- | --------------------------------------- | --------------------------------------------------------------------- |
|
|
367
|
+
| `'google'` | `clientId`, `storageKey`, `autoSelect?` | Google Identity Services One Tap, client-only |
|
|
368
|
+
| `'github'` | `clientId`, `exchangeUrl`, `storageKey` | Popup → your deployed `createGitHubOAuthHandler` endpoint (see above) |
|
|
369
|
+
|
|
370
|
+
Resolves an `AuthPlayerHandle` (`{ id, provider, source, signOut() }`) — or
|
|
371
|
+
`null` when the player **declines** (dismissed One Tap, cancelled on GitHub,
|
|
372
|
+
closed the popup). Throws only on genuine failures (invalid arguments, popup
|
|
373
|
+
blocked, exchange endpoint failure). `source: 'restored'` means the id was
|
|
374
|
+
rehydrated from `localStorage` without a fresh provider interaction.
|
|
375
|
+
|
|
328
376
|
## Advanced
|
|
329
377
|
|
|
330
378
|
### Custom fetch / polyfills
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,194 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#45](https://github.com/isco-tec/scorezilla-js/pull/45) [`1a1e625`](https://github.com/isco-tec/scorezilla-js/commit/1a1e625cc6aff058071f922c7c5a619efa80ddc8) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(identity): ship the GitHub provider for `useAuthProvider` (scorezilla#194)
|
|
8
|
+
|
|
9
|
+
The GitHub option is real (and final, per ADR 0009): a popup OAuth web flow
|
|
10
|
+
on the client plus a turnkey server-side token exchange.
|
|
11
|
+
- `useAuthProvider({ provider: 'github', clientId, exchangeUrl, storageKey })`
|
|
12
|
+
— opens the GitHub sign-in popup, validates the callback by origin + state,
|
|
13
|
+
resolves `github:<id>` (or `null` on decline). The provisional option shape
|
|
14
|
+
is finalized: `clientId`, `exchangeUrl`, and `storageKey` are all required.
|
|
15
|
+
- `createGitHubOAuthHandler({ clientId, clientSecret, allowedOrigin })` (new
|
|
16
|
+
in `scorezilla/server`) — the deployable callback endpoint: exchanges the
|
|
17
|
+
code (secret stays server-side), resolves the user id, posts it back to the
|
|
18
|
+
game's origin, closes the popup. The access token never reaches the browser.
|
|
19
|
+
- size-limit: server caps 7 → 8 KB (documented); new tree-shaking proof pins
|
|
20
|
+
that adapter-only consumers pay for neither factory.
|
|
21
|
+
|
|
22
|
+
- [#39](https://github.com/isco-tec/scorezilla-js/pull/39) [`608137f`](https://github.com/isco-tec/scorezilla-js/commit/608137f2a880fd3b9031cde8de765a5262d6c334) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(identity): ship the Google provider for `useAuthProvider`
|
|
23
|
+
|
|
24
|
+
`useAuthProvider({ provider: 'google', clientId, storageKey })` is now
|
|
25
|
+
implemented and **stable**. It wraps Google Identity Services ("One Tap"),
|
|
26
|
+
derives a stable, opaque player id from the account's `sub` claim
|
|
27
|
+
(`google:<sub>`), and persists it in `localStorage` so returning visitors are
|
|
28
|
+
recognized without signing in again.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { Scorezilla } from 'scorezilla';
|
|
32
|
+
import { useAuthProvider } from 'scorezilla/identity';
|
|
33
|
+
|
|
34
|
+
const player = await useAuthProvider({
|
|
35
|
+
provider: 'google',
|
|
36
|
+
clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com',
|
|
37
|
+
storageKey: 'mygame:player',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (player) {
|
|
41
|
+
const sz = new Scorezilla({ publicKey: 'pk_…' });
|
|
42
|
+
await sz.submitScore({ boardId, playerId: player.id, score: 42 });
|
|
43
|
+
// player.signOut() clears the persisted id and disables Google auto-select.
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Resolves `null` when the player declines** or One Tap can't be shown — a
|
|
48
|
+
dismissed sign-in is not an error. It **rejects** only on genuine failures
|
|
49
|
+
(invalid args, script load failure, malformed credential).
|
|
50
|
+
- **`handle.source`** is `'signed-in'` for a fresh sign-in or `'restored'` when
|
|
51
|
+
the id was rehydrated from `localStorage` (a restored id is not a re-verified
|
|
52
|
+
live session).
|
|
53
|
+
- **Bring your own client ID.** The SDK never bundles Scorezilla-owned OAuth
|
|
54
|
+
credentials, so revocation and consent stay under your control.
|
|
55
|
+
- **Privacy.** Only the derived `sub`-based id is stored and transmitted on
|
|
56
|
+
score submission — never the Google credential, email, or profile.
|
|
57
|
+
- **Bundle.** The Google provider tree-shakes out for consumers who don't call
|
|
58
|
+
`useAuthProvider`; the Google Identity Services library is loaded at runtime
|
|
59
|
+
from `accounts.google.com`, never bundled.
|
|
60
|
+
- `useAuthProvider` is now async (replacing the `0.3.0-next.0` preview stub that
|
|
61
|
+
threw synchronously). Despite the `use*` name it is **not** a React hook.
|
|
62
|
+
Identity errors are plain `Error`/`TypeError` (not `ScorezillaError`), keeping
|
|
63
|
+
the `scorezilla/identity` subpath dependency-free. The host page's CSP must
|
|
64
|
+
allow `https://accounts.google.com`.
|
|
65
|
+
- The **GitHub** provider is not available yet — it ships in a follow-up and
|
|
66
|
+
will require a server-side token exchange (your backend or a Scorezilla
|
|
67
|
+
Workers proxy). Calling `useAuthProvider({ provider: 'github' })` rejects
|
|
68
|
+
with guidance until then.
|
|
69
|
+
|
|
70
|
+
- [#36](https://github.com/isco-tec/scorezilla-js/pull/36) [`19c2dcc`](https://github.com/isco-tec/scorezilla-js/commit/19c2dcc14d2000551d80498813b075172c8f4d66) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(identity): preset helpers for `scorezilla/identity` (Phase 1)
|
|
71
|
+
|
|
72
|
+
New subpath export: `scorezilla/identity`. Three identity-strategy
|
|
73
|
+
presets ship as `stable`; one OAuth helper ships as a preview stub.
|
|
74
|
+
|
|
75
|
+
**Stable in this release:**
|
|
76
|
+
- `useAnonymousPlayer({ storageKey })` — generates a UUID, persists in
|
|
77
|
+
localStorage, same browser keeps the same id across reloads. Returns
|
|
78
|
+
`{ id, forget() }`. Privacy-safe by default (no PII).
|
|
79
|
+
- `usePromptedPlayer({ storageKey, prompt })` — `window.prompt()` on
|
|
80
|
+
first run, persists to localStorage. Returns `{ id, forget() } | null`
|
|
81
|
+
(null when SSR, no `prompt`, or user cancels).
|
|
82
|
+
- `useServerAuthoritative()` — no-op marker for snippets using the
|
|
83
|
+
HMAC-signed secure path (`scorezilla/server`). The browser SDK does
|
|
84
|
+
no identity work; the server picks the value.
|
|
85
|
+
|
|
86
|
+
**Preview stub in this release (throws on call):**
|
|
87
|
+
- `useAuthProvider({ provider: 'google' | 'github' })` — OAuth-backed
|
|
88
|
+
identity. Full implementation (Google + GitHub for v1) ships in a
|
|
89
|
+
follow-up `next` release before the 0.3.0 latest promote.
|
|
90
|
+
|
|
91
|
+
Per [ADR 0003](https://github.com/isco-tec/scorezilla/blob/main/docs/adr/0003-mcp-identity-axis.md). All helpers document where data is stored and
|
|
92
|
+
what `forget()` / `signOut()` does NOT do (server-side history is
|
|
93
|
+
retained — call admin delete-player for full erasure).
|
|
94
|
+
|
|
95
|
+
Closes upstream tracking issue isco-tec/scorezilla#125 (Phase 1).
|
|
96
|
+
|
|
97
|
+
- [#41](https://github.com/isco-tec/scorezilla-js/pull/41) [`e48a5a2`](https://github.com/isco-tec/scorezilla-js/commit/e48a5a2f09cd0f098e8466b51586bd4108bb5678) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(server): `createScoreSubmitHandler()` — turnkey secure score submissions
|
|
98
|
+
|
|
99
|
+
A framework-agnostic factory in `scorezilla/server` that collapses the secure
|
|
100
|
+
(HMAC-signed) submission path from ~150 lines of boilerplate into a few. It
|
|
101
|
+
returns a standard `(Request) => Promise<Response>` handler — drop it into a
|
|
102
|
+
Cloudflare Worker, a Next.js route handler, Hono, Deno, or Bun.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { createScoreSubmitHandler } from 'scorezilla/server';
|
|
106
|
+
|
|
107
|
+
export const POST = createScoreSubmitHandler({
|
|
108
|
+
secretKey: process.env.SCOREZILLA_SECRET_KEY!,
|
|
109
|
+
boardId: process.env.SCOREZILLA_BOARD_ID!,
|
|
110
|
+
verify: async (req) => {
|
|
111
|
+
// your auth — any provider; return the trusted playerId
|
|
112
|
+
const user = await myAuth(req);
|
|
113
|
+
return user ? { playerId: user.id } : null;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- The submitted `playerId` always comes from `verify` (the verified request),
|
|
119
|
+
never the request body — so ranking-sensitive boards aren't subject to the
|
|
120
|
+
client-authoritative submission of the public-key path.
|
|
121
|
+
- Owns body parsing/validation, HMAC signing, and `ScorezillaError` → HTTP
|
|
122
|
+
status mapping. Optional `cors` (OPTIONS preflight + reflected origin) and a
|
|
123
|
+
pre-verify `rateLimit` gate.
|
|
124
|
+
- Works with **any** auth via the `verify` callback (Supabase / Clerk / Auth0 /
|
|
125
|
+
Firebase JWTs, Lucia / opaque sessions, or a provider backend SDK). First-class
|
|
126
|
+
one-line verifiers (`verifySupabaseJwt`, `verifyJwt`) follow.
|
|
127
|
+
|
|
128
|
+
- [#42](https://github.com/isco-tec/scorezilla-js/pull/42) [`7ca5976`](https://github.com/isco-tec/scorezilla-js/commit/7ca5976857cfff44cc3a3c155181cd9f6276aea0) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(server): built-in `verifyJwt` + `verifySupabaseJwt` for `createScoreSubmitHandler`
|
|
129
|
+
|
|
130
|
+
Turn the common "verify a JWT, derive the player id" step into a one-liner.
|
|
131
|
+
Both return a `verify` function you drop straight into `createScoreSubmitHandler`.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { createScoreSubmitHandler, verifySupabaseJwt } from 'scorezilla/server';
|
|
135
|
+
|
|
136
|
+
export const POST = createScoreSubmitHandler({
|
|
137
|
+
secretKey: process.env.SCOREZILLA_SECRET_KEY!,
|
|
138
|
+
boardId: process.env.SCOREZILLA_BOARD_ID!,
|
|
139
|
+
verify: verifySupabaseJwt({ supabaseUrl: process.env.SUPABASE_URL! }),
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `verifyJwt({ jwksUrl, issuer, audience, claim? })` — generic JWKS verifier,
|
|
144
|
+
plus first-class presets for the popular providers: `verifySupabaseJwt({
|
|
145
|
+
supabaseUrl })`, `verifyClerkJwt({ issuer })`, `verifyAuth0Jwt({ domain,
|
|
146
|
+
audience })`, and `verifyFirebaseIdToken({ projectId })`.
|
|
147
|
+
- **`jose` is an optional peer dependency**, loaded lazily via dynamic
|
|
148
|
+
`import()` — consumers who use the public-key client, the factory with their
|
|
149
|
+
own `verify`, or a provider backend SDK never install or load it.
|
|
150
|
+
|
|
151
|
+
### Patch Changes
|
|
152
|
+
|
|
153
|
+
- [#44](https://github.com/isco-tec/scorezilla-js/pull/44) [`e7fcc42`](https://github.com/isco-tec/scorezilla-js/commit/e7fcc4262b5d0a706d29f05333335f746307cb47) Thanks [@isco-tec](https://github.com/isco-tec)! - docs: make the `useAuthProvider` trust boundary explicit (scorezilla#213)
|
|
154
|
+
|
|
155
|
+
Client OAuth identity is sign-in convenience, not anti-forgery — the derived
|
|
156
|
+
id is computed client-side and submitted with the public key. New
|
|
157
|
+
trust-boundary notes on the `useAuthProvider` JSDoc and `AuthPlayerHandle`, a
|
|
158
|
+
"Player identity" section in the README, and a RECIPES.md recipe ("OAuth
|
|
159
|
+
identity and the secure path") routing ranking-sensitive boards to
|
|
160
|
+
`createScoreSubmitHandler` with a server-verified identity.
|
|
161
|
+
|
|
162
|
+
## 0.3.0-next.3
|
|
163
|
+
|
|
164
|
+
### Minor Changes
|
|
165
|
+
|
|
166
|
+
- [#45](https://github.com/isco-tec/scorezilla-js/pull/45) [`1a1e625`](https://github.com/isco-tec/scorezilla-js/commit/1a1e625cc6aff058071f922c7c5a619efa80ddc8) Thanks [@isco-tec](https://github.com/isco-tec)! - feat(identity): ship the GitHub provider for `useAuthProvider` (scorezilla#194)
|
|
167
|
+
|
|
168
|
+
The GitHub option is real (and final, per ADR 0009): a popup OAuth web flow
|
|
169
|
+
on the client plus a turnkey server-side token exchange.
|
|
170
|
+
- `useAuthProvider({ provider: 'github', clientId, exchangeUrl, storageKey })`
|
|
171
|
+
— opens the GitHub sign-in popup, validates the callback by origin + state,
|
|
172
|
+
resolves `github:<id>` (or `null` on decline). The provisional option shape
|
|
173
|
+
is finalized: `clientId`, `exchangeUrl`, and `storageKey` are all required.
|
|
174
|
+
- `createGitHubOAuthHandler({ clientId, clientSecret, allowedOrigin })` (new
|
|
175
|
+
in `scorezilla/server`) — the deployable callback endpoint: exchanges the
|
|
176
|
+
code (secret stays server-side), resolves the user id, posts it back to the
|
|
177
|
+
game's origin, closes the popup. The access token never reaches the browser.
|
|
178
|
+
- size-limit: server caps 7 → 8 KB (documented); new tree-shaking proof pins
|
|
179
|
+
that adapter-only consumers pay for neither factory.
|
|
180
|
+
|
|
181
|
+
### Patch Changes
|
|
182
|
+
|
|
183
|
+
- [#44](https://github.com/isco-tec/scorezilla-js/pull/44) [`e7fcc42`](https://github.com/isco-tec/scorezilla-js/commit/e7fcc4262b5d0a706d29f05333335f746307cb47) Thanks [@isco-tec](https://github.com/isco-tec)! - docs: make the `useAuthProvider` trust boundary explicit (scorezilla#213)
|
|
184
|
+
|
|
185
|
+
Client OAuth identity is sign-in convenience, not anti-forgery — the derived
|
|
186
|
+
id is computed client-side and submitted with the public key. New
|
|
187
|
+
trust-boundary notes on the `useAuthProvider` JSDoc and `AuthPlayerHandle`, a
|
|
188
|
+
"Player identity" section in the README, and a RECIPES.md recipe ("OAuth
|
|
189
|
+
identity and the secure path") routing ranking-sensitive boards to
|
|
190
|
+
`createScoreSubmitHandler` with a server-verified identity.
|
|
191
|
+
|
|
3
192
|
## 0.3.0-next.2
|
|
4
193
|
|
|
5
194
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -92,6 +92,60 @@ await sz.getWindowAround({ boardId, playerId, before?, after? });
|
|
|
92
92
|
See [**API.md**](./API.md) for the full reference, including every response
|
|
93
93
|
field, every error code, and advanced patterns.
|
|
94
94
|
|
|
95
|
+
## Player identity (`scorezilla/identity`)
|
|
96
|
+
|
|
97
|
+
Browser-side helpers that produce the `playerId` you pass to `submitScore`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import {
|
|
101
|
+
useAnonymousPlayer, // mints a UUID, persists in localStorage — no prompt
|
|
102
|
+
usePromptedPlayer, // asks once for a nickname, saves it, silent thereafter
|
|
103
|
+
useAuthProvider, // OAuth sign-in (Google + GitHub)
|
|
104
|
+
} from 'scorezilla/identity';
|
|
105
|
+
|
|
106
|
+
const player = await useAuthProvider({
|
|
107
|
+
provider: 'google',
|
|
108
|
+
clientId: 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com',
|
|
109
|
+
storageKey: 'mygame:player',
|
|
110
|
+
});
|
|
111
|
+
if (player) await sz.submitScore({ boardId, playerId: player.id, score: 9001 });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**GitHub** needs one extra ingredient: GitHub's token exchange requires your
|
|
115
|
+
OAuth app's client secret, so you deploy a one-line exchange endpoint
|
|
116
|
+
(`createGitHubOAuthHandler` from `scorezilla/server`) and point the client at
|
|
117
|
+
it:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// Client — opens a GitHub sign-in popup; resolves github:<id> (or null on decline).
|
|
121
|
+
const player = await useAuthProvider({
|
|
122
|
+
provider: 'github',
|
|
123
|
+
clientId: 'YOUR_GITHUB_OAUTH_CLIENT_ID',
|
|
124
|
+
exchangeUrl: '/api/github-oauth',
|
|
125
|
+
storageKey: 'mygame:player',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Server — deploy at /api/github-oauth and register that URL as the OAuth
|
|
129
|
+
// app's callback URL on GitHub. The secret + access token never reach the browser.
|
|
130
|
+
import { createGitHubOAuthHandler } from 'scorezilla/server';
|
|
131
|
+
|
|
132
|
+
export const GET = createGitHubOAuthHandler({
|
|
133
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
134
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
135
|
+
allowedOrigin: 'https://mygame.example', // your game page's origin
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
> **Trust boundary — all of these are client-authoritative.** `useAuthProvider`
|
|
140
|
+
> proves the player's identity _to the browser_, not to the leaderboard: the
|
|
141
|
+
> derived id is computed client-side and submitted with the public key, so it
|
|
142
|
+
> is exactly as forgeable as any other public-key write. OAuth identity is
|
|
143
|
+
> sign-in convenience for casual/vanity boards — **not anti-forgery**. For
|
|
144
|
+
> competitive or ranking-sensitive boards, submit through the secure path
|
|
145
|
+
> below (`createScoreSubmitHandler` + a server-verified identity); see
|
|
146
|
+
> [RECIPES.md](./RECIPES.md) for the combined "OAuth identity + secure
|
|
147
|
+
> submission" recipe.
|
|
148
|
+
|
|
95
149
|
## Server-side HMAC (`scorezilla/server`)
|
|
96
150
|
|
|
97
151
|
Public-key submissions are client-authoritative — anyone with your `pk_` can
|
package/RECIPES.md
CHANGED
|
@@ -173,6 +173,47 @@ verify: async (req) => {
|
|
|
173
173
|
},
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
+
## OAuth identity and the secure path
|
|
177
|
+
|
|
178
|
+
`useAuthProvider` (from `scorezilla/identity`) is **client-authoritative**: it
|
|
179
|
+
proves the player's identity to the browser, not to your endpoint — the
|
|
180
|
+
`google:<sub>` / `github:<id>` ids arrive at your endpoint from the client,
|
|
181
|
+
with no credential attached for `verify` to check. (Yes, the GitHub flow's
|
|
182
|
+
exchange endpoint verifies identity — but only inside the sign-in popup; at
|
|
183
|
+
score-submit time the id is still client-asserted.) Combining `useAuthProvider`
|
|
184
|
+
with the secure path looks like one of these:
|
|
185
|
+
|
|
186
|
+
**Your app has real auth (recommended).** Trust comes from your auth platform;
|
|
187
|
+
the OAuth handle is just sign-in UX. Verify the platform session as usual —
|
|
188
|
+
the verified user id is the `playerId`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// Identity for TRUST: the verified Supabase/Clerk/Auth0/Firebase session.
|
|
192
|
+
verify: verifySupabaseJwt({ supabaseUrl: process.env.SUPABASE_URL! }),
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
If you want the OAuth display identity alongside, send it in the request
|
|
196
|
+
body's `metadata` — it's treated as untrusted client data, which is exactly
|
|
197
|
+
what it is.
|
|
198
|
+
|
|
199
|
+
**OAuth-only, no backend auth.** You can still route submissions through
|
|
200
|
+
`createScoreSubmitHandler` to keep `sk_*` off the client and gain payload
|
|
201
|
+
validation + rate limiting — but identity stays client-asserted, and the
|
|
202
|
+
endpoint should say so:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// ⚠️ Client-asserted: any client can claim any id. Acceptable for casual
|
|
206
|
+
// boards; NOT for ranking-sensitive ones — add real auth for that.
|
|
207
|
+
verify: async (req) => {
|
|
208
|
+
const playerId = req.headers.get('x-player-id'); // e.g. the google:<sub> id
|
|
209
|
+
return playerId ? { playerId } : null;
|
|
210
|
+
},
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
There is no middle option: an id that arrives from the browser without a
|
|
214
|
+
verifiable credential cannot be promoted to trusted server-side, no matter
|
|
215
|
+
which OAuth provider produced it.
|
|
216
|
+
|
|
176
217
|
## Hardening checklist
|
|
177
218
|
|
|
178
219
|
- **Never** derive `playerId` from the request body or an unverified header —
|
|
@@ -363,4 +363,4 @@ declare class ScorezillaError extends Error {
|
|
|
363
363
|
static timeout(timeoutMs: number): ScorezillaError;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
export { type ApiSuccess as A, type BaseConfig as B, type FetchImpl as F, type LeaderboardResponse as L, type OutOfBoundsReason as O, type PlayerRankResponse as P, type RankedEntry as R, type
|
|
366
|
+
export { type ApiSuccess as A, type BaseConfig as B, type FetchImpl as F, type LeaderboardResponse as L, type OutOfBoundsReason as O, type PlayerRankResponse as P, type RankedEntry as R, type ScorezillaConfig as S, type WindowAroundResponse as W, type SubmitScoreResponse as a, type ApiError as b, type ApiResponse as c, type PublicKeyConfig as d, ScorezillaError as e, type ScorezillaErrorCode as f, type SecretKeyConfig as g };
|
|
@@ -363,4 +363,4 @@ declare class ScorezillaError extends Error {
|
|
|
363
363
|
static timeout(timeoutMs: number): ScorezillaError;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
export { type ApiSuccess as A, type BaseConfig as B, type FetchImpl as F, type LeaderboardResponse as L, type OutOfBoundsReason as O, type PlayerRankResponse as P, type RankedEntry as R, type
|
|
366
|
+
export { type ApiSuccess as A, type BaseConfig as B, type FetchImpl as F, type LeaderboardResponse as L, type OutOfBoundsReason as O, type PlayerRankResponse as P, type RankedEntry as R, type ScorezillaConfig as S, type WindowAroundResponse as W, type SubmitScoreResponse as a, type ApiError as b, type ApiResponse as c, type PublicKeyConfig as d, ScorezillaError as e, type ScorezillaErrorCode as f, type SecretKeyConfig as g };
|
package/dist/identity.cjs
CHANGED
|
@@ -143,6 +143,94 @@ async function signInWithGoogle(params) {
|
|
|
143
143
|
return credential === null ? null : decodeSubFromIdToken(credential);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// src/identity/github.ts
|
|
147
|
+
var GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
|
|
148
|
+
var GITHUB_MESSAGE_SOURCE = "scorezilla:github-oauth";
|
|
149
|
+
var POPUP_CLOSED_POLL_MS = 500;
|
|
150
|
+
var SIGN_IN_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
151
|
+
var KNOWN_ERRORS = /* @__PURE__ */ new Set(["access_denied", "exchange_failed"]);
|
|
152
|
+
var ID_RE = /^\d{1,20}$/;
|
|
153
|
+
function randomState() {
|
|
154
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
155
|
+
let out = "";
|
|
156
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
|
157
|
+
for (const b of bytes) out += alphabet[b & 63];
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
async function signInWithGitHub(params) {
|
|
161
|
+
const exchangeUrl = new URL(params.exchangeUrl, window.location.href);
|
|
162
|
+
const state = randomState();
|
|
163
|
+
const authorize = new URL(GITHUB_AUTHORIZE_URL);
|
|
164
|
+
authorize.searchParams.set("client_id", params.clientId);
|
|
165
|
+
authorize.searchParams.set("redirect_uri", exchangeUrl.toString());
|
|
166
|
+
authorize.searchParams.set("state", state);
|
|
167
|
+
const popup = window.open(
|
|
168
|
+
authorize.toString(),
|
|
169
|
+
"scorezilla-github-oauth",
|
|
170
|
+
"popup,width=600,height=700"
|
|
171
|
+
);
|
|
172
|
+
if (popup === null) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"useAuthProvider: the GitHub sign-in popup was blocked. Call useAuthProvider from a user gesture (e.g. a click handler), or allow popups for this site."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const pollTimer = setInterval(() => {
|
|
179
|
+
if (popup.closed) settle(() => resolve(null));
|
|
180
|
+
}, POPUP_CLOSED_POLL_MS);
|
|
181
|
+
const timeoutTimer = setTimeout(() => {
|
|
182
|
+
settle(
|
|
183
|
+
() => reject(
|
|
184
|
+
new Error(
|
|
185
|
+
"useAuthProvider: GitHub sign-in timed out. If this recurs, check that exchangeUrl is deployed and reachable."
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
}, SIGN_IN_TIMEOUT_MS);
|
|
190
|
+
const settle = (action) => {
|
|
191
|
+
window.removeEventListener("message", onMessage);
|
|
192
|
+
clearInterval(pollTimer);
|
|
193
|
+
clearTimeout(timeoutTimer);
|
|
194
|
+
if (!popup.closed) popup.close();
|
|
195
|
+
action();
|
|
196
|
+
};
|
|
197
|
+
const onMessage = (event) => {
|
|
198
|
+
if (event.origin !== exchangeUrl.origin) return;
|
|
199
|
+
const data = event.data;
|
|
200
|
+
if (data === null || typeof data !== "object") return;
|
|
201
|
+
if (data.source !== GITHUB_MESSAGE_SOURCE) return;
|
|
202
|
+
if (data.state !== state) return;
|
|
203
|
+
if (typeof data.error === "string" && data.error.length > 0) {
|
|
204
|
+
if (data.error === "access_denied") {
|
|
205
|
+
settle(() => resolve(null));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const safeError = KNOWN_ERRORS.has(data.error) ? data.error : "exchange_failed";
|
|
209
|
+
settle(
|
|
210
|
+
() => reject(
|
|
211
|
+
new Error(
|
|
212
|
+
`useAuthProvider: GitHub token exchange failed (${safeError}). Check the exchange endpoint logs and its GitHub OAuth app credentials.`
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (typeof data.id === "string" && ID_RE.test(data.id)) {
|
|
219
|
+
settle(() => resolve(data.id));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
settle(
|
|
223
|
+
() => reject(
|
|
224
|
+
new Error(
|
|
225
|
+
"useAuthProvider: the GitHub exchange endpoint posted a malformed callback message (missing or non-numeric id). Is exchangeUrl pointing at createGitHubOAuthHandler (or an equivalent implementation)?"
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
window.addEventListener("message", onMessage);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
146
234
|
// src/identity.ts
|
|
147
235
|
var isBrowser = () => typeof window !== "undefined";
|
|
148
236
|
function readPersisted(key) {
|
|
@@ -227,9 +315,7 @@ async function useAuthProvider(options) {
|
|
|
227
315
|
case "google":
|
|
228
316
|
return signInWithGoogleProvider(options);
|
|
229
317
|
case "github":
|
|
230
|
-
|
|
231
|
-
'useAuthProvider: the GitHub provider is not available yet. It ships in scorezilla@0.3.0-next.2 and will require a server-side token exchange (your backend or a Scorezilla Workers proxy), because GitHub OAuth cannot be completed securely in the browser alone. Until then use provider: "google", or drive your own GitHub OAuth flow and pass the resulting id to submitScore.'
|
|
232
|
-
);
|
|
318
|
+
return signInWithGitHubProvider(options);
|
|
233
319
|
default:
|
|
234
320
|
throw new TypeError(
|
|
235
321
|
`useAuthProvider: unknown provider ${JSON.stringify(
|
|
@@ -268,6 +354,32 @@ async function signInWithGoogleProvider(options) {
|
|
|
268
354
|
googleSignInInFlight.set(storageKey, run);
|
|
269
355
|
return run;
|
|
270
356
|
}
|
|
357
|
+
var githubSignInInFlight = /* @__PURE__ */ new Map();
|
|
358
|
+
async function signInWithGitHubProvider(options) {
|
|
359
|
+
const clientId = requireNonEmptyString("useAuthProvider", "clientId", options.clientId);
|
|
360
|
+
const exchangeUrl = requireNonEmptyString("useAuthProvider", "exchangeUrl", options.exchangeUrl);
|
|
361
|
+
const storageKey = requireNonEmptyString("useAuthProvider", "storageKey", options.storageKey);
|
|
362
|
+
const persisted = readPersisted(storageKey);
|
|
363
|
+
if (persisted !== null && persisted.length > 0) {
|
|
364
|
+
return makeAuthHandle(persisted, "github", storageKey, "restored");
|
|
365
|
+
}
|
|
366
|
+
if (!isBrowser()) {
|
|
367
|
+
throw new Error("useAuthProvider: GitHub sign-in requires a browser environment.");
|
|
368
|
+
}
|
|
369
|
+
const existing = githubSignInInFlight.get(storageKey);
|
|
370
|
+
if (existing) return existing;
|
|
371
|
+
const run = (async () => {
|
|
372
|
+
const userId = await signInWithGitHub({ clientId, exchangeUrl });
|
|
373
|
+
if (userId === null) return null;
|
|
374
|
+
const id = `github:${userId}`;
|
|
375
|
+
writePersisted(storageKey, id);
|
|
376
|
+
return makeAuthHandle(id, "github", storageKey, "signed-in");
|
|
377
|
+
})().finally(() => {
|
|
378
|
+
githubSignInInFlight.delete(storageKey);
|
|
379
|
+
});
|
|
380
|
+
githubSignInInFlight.set(storageKey, run);
|
|
381
|
+
return run;
|
|
382
|
+
}
|
|
271
383
|
function makeAuthHandle(id, provider, storageKey, source) {
|
|
272
384
|
return {
|
|
273
385
|
id,
|
|
@@ -275,7 +387,7 @@ function makeAuthHandle(id, provider, storageKey, source) {
|
|
|
275
387
|
source,
|
|
276
388
|
signOut: () => {
|
|
277
389
|
removePersisted(storageKey);
|
|
278
|
-
disableGoogleAutoSelect();
|
|
390
|
+
if (provider === "google") disableGoogleAutoSelect();
|
|
279
391
|
}
|
|
280
392
|
};
|
|
281
393
|
}
|