@nano133/ticket 0.0.0-stage → 0.1.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/ai/PROMPT.md +10 -0
- package/ai/SPEC.md +42 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +173 -0
- package/package.json +31 -4
- package/src/index.ts +221 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release (Pre-alpha): `readTicket`, `verifyTicket`, `fetchKeys`, `ticketFromHash`,
|
|
6
|
+
`hashWithoutTicket`. One ticket is pinned byte for byte in the tests, and nano133.com's own
|
|
7
|
+
test pins the same one.
|
|
8
|
+
The key list is read with a 5 second limit (`KEYS_TIMEOUT_MS`), so a slow nano133.com never holds a game's first screen. A ticket's `look` is a clean copy of its five colours.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nano133.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nano133/ticket
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stage: Pre-alpha. What this means: https://nano133.com/stages
|
|
4
|
+
|
|
5
|
+
Read and check a Nanoterra sign-in ticket, in the player's browser. It is for a
|
|
6
|
+
game on a **static site**: no server, no secret. A player who is signed in at
|
|
7
|
+
nano133.com presses Play in Nanoterra, agrees once on a card, and arrives at your
|
|
8
|
+
game with a signed ticket after the `#` of its address. This package checks the
|
|
9
|
+
ticket and gives you the player's **username**, **Nano address** and
|
|
10
|
+
**character** (the look and the cosmetics the player owns).
|
|
11
|
+
|
|
12
|
+
If your game has a server, use the code exchange instead (the
|
|
13
|
+
[game starter](https://github.com/nano133-com/nano-game-starter) does): a server that holds
|
|
14
|
+
a secret can know who is at the other end. A ticket cannot.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { readTicket } from "@nano133/ticket";
|
|
18
|
+
|
|
19
|
+
const result = await readTicket();
|
|
20
|
+
if (result.ok) {
|
|
21
|
+
const { username, address, look, items } = result.ticket;
|
|
22
|
+
// Ask first: "Sign in as {username}? Continue / Not me" (see "Rules" below).
|
|
23
|
+
}
|
|
24
|
+
// result.ok === false: a guest. result.reason says why ("none" is a plain visit).
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`readTicket()` does four things, in this order:
|
|
28
|
+
|
|
29
|
+
1. Finds `#nanoterra=<ticket>` in the page's address. With none, it answers
|
|
30
|
+
`{ ok: false, reason: "none" }` and does nothing else (no network call).
|
|
31
|
+
2. **Takes the ticket out of the address bar** (`history.replaceState`), so a
|
|
32
|
+
reload, a copied link, a screenshot or a bookmark never carries it.
|
|
33
|
+
3. Reads the public keys from `https://nano133.com/api/nanoterra/keys` (once for
|
|
34
|
+
the session, and again if a ticket names a key it does not know; it waits 5 seconds at most, then the player is a guest).
|
|
35
|
+
4. Checks the signature, the kind, that the ticket is **for this origin** and that
|
|
36
|
+
it has not run out. It never throws.
|
|
37
|
+
|
|
38
|
+
Call it once, when the page loads.
|
|
39
|
+
|
|
40
|
+
## Rules
|
|
41
|
+
|
|
42
|
+
- **A ticket proves nothing to your server.** The ticket sits in an address bar:
|
|
43
|
+
anyone who sees it can use it for two minutes. Use it to show the player's name
|
|
44
|
+
and character. **Never give a sale, a prize or a score on a ticket alone.**
|
|
45
|
+
- **Ask before you treat the player as that name.** Anyone can send a link that holds
|
|
46
|
+
their *own* ticket (login CSRF). Show "Sign in as *name*? Continue / Not me" and
|
|
47
|
+
keep the player a guest until Continue.
|
|
48
|
+
- **Show the name** wherever the player is "signed in", and say whose account it is
|
|
49
|
+
before a payment.
|
|
50
|
+
- **The username comes from another site.** Put it in the page as text, never as HTML.
|
|
51
|
+
- A guest is a normal visit. Every refusal (`reason`) means "play as a guest".
|
|
52
|
+
|
|
53
|
+
## What you get
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
type Ticket = {
|
|
57
|
+
username: string;
|
|
58
|
+
address: string; // the whole Nano address; also the player's id
|
|
59
|
+
look: { skin: string; hair: string; shirt: string; pants: string; shoes: string }; // "#rrggbb"
|
|
60
|
+
items: string[]; // the Nanoterra cosmetics the player owns, in the character's slots
|
|
61
|
+
game: string; // your game's id at nano133.com
|
|
62
|
+
audience: string; // the origin the ticket is for
|
|
63
|
+
issuedAt: number; // ms since 1970
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
id: string;
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The character is the same one `@nano133/character` draws in Three.js.
|
|
70
|
+
|
|
71
|
+
## Refusals
|
|
72
|
+
|
|
73
|
+
| `reason` | Meaning |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `none` | no ticket in the address: a plain visit |
|
|
76
|
+
| `unsupported` | this browser has no Ed25519 in Web Crypto (Chrome 137, Safari 17, Firefox 129 and later have it) |
|
|
77
|
+
| `malformed` | not a ticket |
|
|
78
|
+
| `keys` | the public keys could not be read (offline, or nano133.com did not answer) |
|
|
79
|
+
| `unknown-key`, `signature` | signed with a key that is not nano133.com's, or the signature does not hold |
|
|
80
|
+
| `wrong-kind` | signed right, but not a version 1 ticket from nano133.com |
|
|
81
|
+
| `audience` | a ticket for another origin |
|
|
82
|
+
| `expired`, `early`, `life` | the time does not hold. A player's clock may be a minute off, either way |
|
|
83
|
+
|
|
84
|
+
## Options
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
readTicket({
|
|
88
|
+
keys, // pin the public keys: no fetch at all
|
|
89
|
+
keysUrl, // another place for the keys (tests)
|
|
90
|
+
keysTimeoutMs, // how long to wait for the keys; default 5,000 (a slow nano133.com never holds your first screen)
|
|
91
|
+
audience, // the origin to expect; default: this page's own
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`verifyTicket(token, { audience, keys })` checks a ticket you already hold, and
|
|
96
|
+
`fetchKeys()`, `ticketFromHash(hash)` and `hashWithoutTicket(hash)` are the
|
|
97
|
+
parts.
|
|
98
|
+
|
|
99
|
+
## The format
|
|
100
|
+
|
|
101
|
+
For any language. A ticket is `base64url(JSON claims) "." base64url(signature)`: an Ed25519 signature over the
|
|
102
|
+
ASCII of the first part, made with the key whose `kid` the claims name.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{ "v": 1, "typ": "ticket", "iss": "nano133.com", "kid": "fe812c12f3ab4ce6", "aud": "https://game.example",
|
|
106
|
+
"game": "gem-island", "sub": "nano_3i7m…", "name": "Ann",
|
|
107
|
+
"look": { "skin": "#f2c29b", "hair": "#3a2a1e", "shirt": "#2a9df4", "pants": "#1e2a4a", "shoes": "#ffffff" },
|
|
108
|
+
"items": ["hub.sun-hat"], "iat": 1800000000, "exp": 1800000120, "jti": "AAAAAAAAAAAAAAAA" }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`GET https://nano133.com/api/nanoterra/keys` answers `{ "keys": [{ "kid", "kty": "OKP", "crv": "Ed25519", "x", "alg": "EdDSA", "use": "sig" }] }`
|
|
112
|
+
and may be read from any page. A ticket lives two minutes (`exp - iat` is 120; a longer life is refused).
|
|
113
|
+
|
|
114
|
+
## Turn it on
|
|
115
|
+
|
|
116
|
+
Your `nano-game.json` says `"signin": "ticket"`. Nanoterra's Play key then sends signed-in players to
|
|
117
|
+
your play address with a ticket. A player who has not agreed, or who plays as a guest, arrives with no ticket.
|
|
118
|
+
|
|
119
|
+
MIT license.
|
package/ai/PROMPT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Nanoterra sign-in ticket: the prompt
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Take players who press Play in Nanoterra signed in on this static game (no server of its own).
|
|
5
|
+
Use the npm package @nano133/ticket and follow its SPEC step by step:
|
|
6
|
+
https://nano133.com/build/nanoterra
|
|
7
|
+
Set "signin": "ticket" in nano-game.json. Call readTicket() once when the page loads, ask
|
|
8
|
+
"Sign in as <name>? Continue / Not me" before treating the player as that name, and show the name.
|
|
9
|
+
Never give a sale, a prize or a score on a ticket alone. Put the name in the page as text.
|
|
10
|
+
```
|
package/ai/SPEC.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Nanoterra sign-in ticket: implementation spec
|
|
2
|
+
|
|
3
|
+
Package: `@nano133/ticket` (0.1.0 or later). For a game on a **static site** (no server of its
|
|
4
|
+
own). A game with a server uses the code exchange instead (the game starter has it).
|
|
5
|
+
|
|
6
|
+
## 0. Decide (ask)
|
|
7
|
+
|
|
8
|
+
1. Does the game have a server? If yes, stop: use the code exchange. A ticket cannot prove who
|
|
9
|
+
is at the other end, so a server must not trust it.
|
|
10
|
+
2. What the game does with the player's name and character: show them (fine), or give something
|
|
11
|
+
of value (a sale, a prize, a score). **Never give anything of value on a ticket alone.**
|
|
12
|
+
|
|
13
|
+
## 1. Turn it on
|
|
14
|
+
|
|
15
|
+
In `nano-game.json` set `"signin": "ticket"`. Nothing else is made at nano133.com (no secret).
|
|
16
|
+
|
|
17
|
+
## 2. Read the ticket when the page loads
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { readTicket } from "@nano133/ticket";
|
|
21
|
+
const result = await readTicket();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Call it once. It takes the ticket out of the address bar first. If `result.ok` is false the
|
|
25
|
+
player is a guest (`result.reason` is `"none"` for a plain visit).
|
|
26
|
+
|
|
27
|
+
## 3. Ask before you sign the player in
|
|
28
|
+
|
|
29
|
+
Show "Sign in as *name*? Continue / Not me". Anyone can send a link that holds their own ticket.
|
|
30
|
+
Until Continue, the player is a guest. Put the name in the page as text, never as HTML.
|
|
31
|
+
|
|
32
|
+
## 4. Use the details
|
|
33
|
+
|
|
34
|
+
`result.ticket`: `username`, `address`, `look` (five `#rrggbb` colours), `items` (catalogue ids).
|
|
35
|
+
Keep them in memory or `sessionStorage`. Show the name wherever the player is signed in.
|
|
36
|
+
|
|
37
|
+
## Checks
|
|
38
|
+
|
|
39
|
+
- A plain visit (no `#nanoterra=`) makes no network call and shows no card.
|
|
40
|
+
- A ticket for another origin, a changed ticket and an old ticket each give a guest.
|
|
41
|
+
- The address bar has no ticket after the page loaded.
|
|
42
|
+
- No sale, prize or score depends on the ticket.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export declare const TICKET_PARAM = "nanoterra";
|
|
2
|
+
export declare const DEFAULT_KEYS_URL = "https://nano133.com/api/nanoterra/keys";
|
|
3
|
+
export declare const ISSUER = "nano133.com";
|
|
4
|
+
/** A ticket longer than this is not one. */
|
|
5
|
+
export declare const MAX_LENGTH = 4096;
|
|
6
|
+
/** A ticket lives this long (seconds). A longer life is refused. */
|
|
7
|
+
export declare const MAX_LIFE_SECONDS = 120;
|
|
8
|
+
/** A player's clock may be this far off (seconds), either way. */
|
|
9
|
+
export declare const SLACK_SECONDS = 60;
|
|
10
|
+
export type Look = {
|
|
11
|
+
skin: string;
|
|
12
|
+
hair: string;
|
|
13
|
+
shirt: string;
|
|
14
|
+
pants: string;
|
|
15
|
+
shoes: string;
|
|
16
|
+
};
|
|
17
|
+
/** What a ticket says about the player. Times are in milliseconds since 1970. */
|
|
18
|
+
export type Ticket = {
|
|
19
|
+
username: string;
|
|
20
|
+
/** The player's whole Nano address. It is also the player's id. */
|
|
21
|
+
address: string;
|
|
22
|
+
look: Look;
|
|
23
|
+
/** The Nanoterra cosmetics the player owns, in the island character's slots (catalogue ids). */
|
|
24
|
+
items: string[];
|
|
25
|
+
/** The game's id at nano133.com, and the origin the ticket is for. */
|
|
26
|
+
game: string;
|
|
27
|
+
audience: string;
|
|
28
|
+
issuedAt: number;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
/** A random id of this ticket. */
|
|
31
|
+
id: string;
|
|
32
|
+
};
|
|
33
|
+
/** One public key of GET https://nano133.com/api/nanoterra/keys (a JWK, RFC 8037: Ed25519). */
|
|
34
|
+
export type PublicKey = {
|
|
35
|
+
kid: string;
|
|
36
|
+
x: string;
|
|
37
|
+
kty?: string;
|
|
38
|
+
crv?: string;
|
|
39
|
+
alg?: string;
|
|
40
|
+
use?: string;
|
|
41
|
+
};
|
|
42
|
+
export type Keys = {
|
|
43
|
+
keys: PublicKey[];
|
|
44
|
+
};
|
|
45
|
+
export type Refusal = "none" | "unsupported" | "malformed" | "keys" | "unknown-key" | "signature" | "wrong-kind" | "audience" | "expired" | "early" | "life";
|
|
46
|
+
export type Result = {
|
|
47
|
+
ok: true;
|
|
48
|
+
ticket: Ticket;
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: Refusal;
|
|
52
|
+
};
|
|
53
|
+
/** The ticket in an address's fragment (`location.hash`), or null. Other fragment parts are left alone. */
|
|
54
|
+
export declare function ticketFromHash(hash: string): string | null;
|
|
55
|
+
/** The fragment without the ticket ("" when nothing else is in it, else "#" and the rest). */
|
|
56
|
+
export declare function hashWithoutTicket(hash: string): string;
|
|
57
|
+
/** How long the page waits for the keys (milliseconds). A slow nano133.com must not hold a game's first screen. */
|
|
58
|
+
export declare const KEYS_TIMEOUT_MS = 5000;
|
|
59
|
+
/** The public keys, as nano133.com publishes them (any origin may read them). `cache: "reload"` skips the browser's copy. Rejects after KEYS_TIMEOUT_MS. */
|
|
60
|
+
export declare function fetchKeys(url?: string, fetcher?: typeof fetch, cache?: RequestCache, timeoutMs?: number): Promise<Keys>;
|
|
61
|
+
export type VerifyOptions = {
|
|
62
|
+
/** The origin this page is on, e.g. "https://game.example". A ticket for another origin is refused. */
|
|
63
|
+
audience: string;
|
|
64
|
+
keys: Keys;
|
|
65
|
+
/** Milliseconds since 1970 (for tests). */
|
|
66
|
+
now?: number;
|
|
67
|
+
/** Web Crypto's `subtle` (for tests). */
|
|
68
|
+
subtle?: SubtleCrypto;
|
|
69
|
+
};
|
|
70
|
+
/** Checks a ticket: the key, the signature, the kind, the audience and the time. Never throws. */
|
|
71
|
+
export declare function verifyTicket(token: string, o: VerifyOptions): Promise<Result>;
|
|
72
|
+
export type ReadOptions = {
|
|
73
|
+
/** Pin the keys (no fetch). Else they are read from `keysUrl` once, and again when a ticket names a key that is not in the list. */
|
|
74
|
+
keys?: Keys;
|
|
75
|
+
keysUrl?: string;
|
|
76
|
+
/** How long to wait for the keys (milliseconds): the default is KEYS_TIMEOUT_MS. */
|
|
77
|
+
keysTimeoutMs?: number;
|
|
78
|
+
/** The origin to expect. Default: this page's own origin. */
|
|
79
|
+
audience?: string;
|
|
80
|
+
location?: Pick<Location, "hash" | "pathname" | "search" | "origin">;
|
|
81
|
+
history?: Pick<History, "replaceState" | "state">;
|
|
82
|
+
fetch?: typeof fetch;
|
|
83
|
+
now?: number;
|
|
84
|
+
subtle?: SubtleCrypto;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Reads the ticket from this page's address, if there is one. FIRST it takes the ticket out of the address bar (the page's history
|
|
88
|
+
* entry is replaced), so a reload, a copied link, a screenshot or a bookmark never carries it; then it checks the ticket. Call it once, when
|
|
89
|
+
* the page loads. `{ ok: false, reason: "none" }` is a plain visit (no network call). Anything else that is not ok is a guest.
|
|
90
|
+
*
|
|
91
|
+
* Show "Sign in as <name>? Continue / Not me" before you treat the player as that name: anyone can send a link that holds their own ticket.
|
|
92
|
+
*/
|
|
93
|
+
export declare function readTicket(o?: ReadOptions): Promise<Result>;
|
|
94
|
+
/** Forgets the keys read so far (for tests). */
|
|
95
|
+
export declare function forgetKeys(): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Read and check a Nanoterra sign-in ticket, in the player's browser. For a game on a STATIC site: no server to trade a one-time code.
|
|
2
|
+
//
|
|
3
|
+
// Nanoterra (nano133.com) opens the game's own address with the ticket after a `#`: https://game.example/#nanoterra=<ticket>.
|
|
4
|
+
// A fragment is never sent to a server. `readTicket()` takes the ticket out of the address bar at once, checks its signature with
|
|
5
|
+
// nano133.com's public key and answers with the player's username, Nano address and character.
|
|
6
|
+
//
|
|
7
|
+
// WHAT A TICKET PROVES: that nano133.com said "this player, for this game, a moment ago". Anyone who sees the address can use the
|
|
8
|
+
// ticket until it runs out (two minutes), so it proves nothing to YOUR SERVER. Use it to show the player's name and character. Never
|
|
9
|
+
// give a sale, a prize or a score on a ticket alone: for that, use the code exchange (a server that holds a secret).
|
|
10
|
+
//
|
|
11
|
+
// Format (the same as nano133.com's receipts): base64url(JSON claims) "." base64url(Ed25519 signature over the first part).
|
|
12
|
+
// Web Crypto only (Ed25519: Chrome 137, Safari 17, Firefox 129, Node 20 and later); no other import.
|
|
13
|
+
export const TICKET_PARAM = "nanoterra";
|
|
14
|
+
export const DEFAULT_KEYS_URL = "https://nano133.com/api/nanoterra/keys";
|
|
15
|
+
export const ISSUER = "nano133.com";
|
|
16
|
+
/** A ticket longer than this is not one. */
|
|
17
|
+
export const MAX_LENGTH = 4096;
|
|
18
|
+
/** A ticket lives this long (seconds). A longer life is refused. */
|
|
19
|
+
export const MAX_LIFE_SECONDS = 120;
|
|
20
|
+
/** A player's clock may be this far off (seconds), either way. */
|
|
21
|
+
export const SLACK_SECONDS = 60;
|
|
22
|
+
const TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
23
|
+
const COLOUR = /^#[0-9a-fA-F]{6}$/;
|
|
24
|
+
const ADDRESS = /^nano_[13][13-9a-km-uw-z]{59}$/;
|
|
25
|
+
const dec = new TextDecoder();
|
|
26
|
+
const enc = new TextEncoder();
|
|
27
|
+
function unb64url(text) {
|
|
28
|
+
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
|
|
29
|
+
return Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
30
|
+
}
|
|
31
|
+
/** The ticket in an address's fragment (`location.hash`), or null. Other fragment parts are left alone. */
|
|
32
|
+
export function ticketFromHash(hash) {
|
|
33
|
+
for (const part of hash.replace(/^#/, "").split("&")) {
|
|
34
|
+
if (part.startsWith(`${TICKET_PARAM}=`)) {
|
|
35
|
+
const value = part.slice(TICKET_PARAM.length + 1);
|
|
36
|
+
return value.length <= MAX_LENGTH && TOKEN.test(value) ? value : null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/** The fragment without the ticket ("" when nothing else is in it, else "#" and the rest). */
|
|
42
|
+
export function hashWithoutTicket(hash) {
|
|
43
|
+
const rest = hash.replace(/^#/, "").split("&").filter((part) => !part.startsWith(`${TICKET_PARAM}=`) && part !== "");
|
|
44
|
+
return rest.length ? `#${rest.join("&")}` : "";
|
|
45
|
+
}
|
|
46
|
+
/** How long the page waits for the keys (milliseconds). A slow nano133.com must not hold a game's first screen. */
|
|
47
|
+
export const KEYS_TIMEOUT_MS = 5000;
|
|
48
|
+
/** The public keys, as nano133.com publishes them (any origin may read them). `cache: "reload"` skips the browser's copy. Rejects after KEYS_TIMEOUT_MS. */
|
|
49
|
+
export async function fetchKeys(url = DEFAULT_KEYS_URL, fetcher = fetch, cache = "default", timeoutMs = KEYS_TIMEOUT_MS) {
|
|
50
|
+
const stop = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => stop.abort(), timeoutMs);
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetcher(url, { credentials: "omit", cache, signal: stop.signal });
|
|
54
|
+
if (!r.ok)
|
|
55
|
+
throw new Error(`keys answered ${r.status}`);
|
|
56
|
+
return readKeys(await r.json());
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function readKeys(j) {
|
|
63
|
+
if (!j || !Array.isArray(j.keys))
|
|
64
|
+
throw new Error("keys: not a list");
|
|
65
|
+
return { keys: j.keys.filter((k) => !!k && typeof k.kid === "string" && typeof k.x === "string") };
|
|
66
|
+
}
|
|
67
|
+
/** Checks a ticket: the key, the signature, the kind, the audience and the time. Never throws. */
|
|
68
|
+
export async function verifyTicket(token, o) {
|
|
69
|
+
const no = (reason) => ({ ok: false, reason });
|
|
70
|
+
if (typeof token !== "string" || token.length > MAX_LENGTH || !TOKEN.test(token))
|
|
71
|
+
return no("malformed");
|
|
72
|
+
const subtle = o.subtle ?? globalThis.crypto?.subtle;
|
|
73
|
+
if (!subtle)
|
|
74
|
+
return no("unsupported");
|
|
75
|
+
const [body, sig] = token.split(".");
|
|
76
|
+
let claims;
|
|
77
|
+
try {
|
|
78
|
+
claims = JSON.parse(dec.decode(unb64url(body)));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return no("malformed");
|
|
82
|
+
}
|
|
83
|
+
if (!claims || typeof claims !== "object" || typeof claims.kid !== "string")
|
|
84
|
+
return no("malformed");
|
|
85
|
+
const key = o.keys.keys.find((k) => k.kid === claims.kid);
|
|
86
|
+
if (!key)
|
|
87
|
+
return no("unknown-key");
|
|
88
|
+
try {
|
|
89
|
+
const publicKey = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: key.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
90
|
+
if (!(await subtle.verify("Ed25519", publicKey, unb64url(sig), enc.encode(body))))
|
|
91
|
+
return no("signature");
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return no(e?.name === "NotSupportedError" ? "unsupported" : "signature");
|
|
95
|
+
}
|
|
96
|
+
// The signature holds: the claims are nano133.com's. They still must be what a ticket is, for this page, now.
|
|
97
|
+
if (claims.v !== 1 || claims.typ !== "ticket" || claims.iss !== ISSUER)
|
|
98
|
+
return no("wrong-kind");
|
|
99
|
+
if (claims.aud !== o.audience)
|
|
100
|
+
return no("audience");
|
|
101
|
+
const { iat, exp } = claims;
|
|
102
|
+
if (typeof iat !== "number" || typeof exp !== "number")
|
|
103
|
+
return no("malformed");
|
|
104
|
+
const now = (o.now ?? Date.now()) / 1000;
|
|
105
|
+
if (!(exp > now - SLACK_SECONDS))
|
|
106
|
+
return no("expired");
|
|
107
|
+
if (!(iat < now + SLACK_SECONDS))
|
|
108
|
+
return no("early");
|
|
109
|
+
if (!(exp - iat <= MAX_LIFE_SECONDS))
|
|
110
|
+
return no("life");
|
|
111
|
+
const { sub, name, look, items, game, jti } = claims;
|
|
112
|
+
const l = look;
|
|
113
|
+
if (typeof sub !== "string" || !ADDRESS.test(sub) || typeof name !== "string" || !name || name.length > 60 || typeof game !== "string" || typeof jti !== "string")
|
|
114
|
+
return no("malformed");
|
|
115
|
+
if (!l || typeof l !== "object" || !["skin", "hair", "shirt", "pants", "shoes"].every((p) => typeof l[p] === "string" && COLOUR.test(l[p])))
|
|
116
|
+
return no("malformed");
|
|
117
|
+
if (!Array.isArray(items) || items.length > 64 || !items.every((i) => typeof i === "string" && i.length <= 48))
|
|
118
|
+
return no("malformed");
|
|
119
|
+
const clean = { skin: l.skin, hair: l.hair, shirt: l.shirt, pants: l.pants, shoes: l.shoes };
|
|
120
|
+
return { ok: true, ticket: { username: name, address: sub, look: clean, items: [...items], game, audience: claims.aud, issuedAt: iat * 1000, expiresAt: exp * 1000, id: jti } };
|
|
121
|
+
}
|
|
122
|
+
let cached = null;
|
|
123
|
+
/**
|
|
124
|
+
* Reads the ticket from this page's address, if there is one. FIRST it takes the ticket out of the address bar (the page's history
|
|
125
|
+
* entry is replaced), so a reload, a copied link, a screenshot or a bookmark never carries it; then it checks the ticket. Call it once, when
|
|
126
|
+
* the page loads. `{ ok: false, reason: "none" }` is a plain visit (no network call). Anything else that is not ok is a guest.
|
|
127
|
+
*
|
|
128
|
+
* Show "Sign in as <name>? Continue / Not me" before you treat the player as that name: anyone can send a link that holds their own ticket.
|
|
129
|
+
*/
|
|
130
|
+
export async function readTicket(o = {}) {
|
|
131
|
+
const loc = o.location ?? globalThis.location;
|
|
132
|
+
const hist = o.history ?? globalThis.history;
|
|
133
|
+
if (!loc)
|
|
134
|
+
return { ok: false, reason: "none" };
|
|
135
|
+
// A ticket is taken out of the address bar FIRST, also one that is not a good ticket.
|
|
136
|
+
if (!loc.hash.replace(/^#/, "").split("&").some((part) => part.startsWith(`${TICKET_PARAM}=`)))
|
|
137
|
+
return { ok: false, reason: "none" };
|
|
138
|
+
const token = ticketFromHash(loc.hash);
|
|
139
|
+
if (hist)
|
|
140
|
+
hist.replaceState(hist.state, "", loc.pathname + loc.search + hashWithoutTicket(loc.hash));
|
|
141
|
+
if (!token)
|
|
142
|
+
return { ok: false, reason: "malformed" };
|
|
143
|
+
const audience = o.audience ?? loc.origin;
|
|
144
|
+
const load = async (fresh) => {
|
|
145
|
+
if (o.keys)
|
|
146
|
+
return o.keys;
|
|
147
|
+
if (cached && !fresh)
|
|
148
|
+
return cached;
|
|
149
|
+
try {
|
|
150
|
+
cached = await fetchKeys(o.keysUrl, o.fetch, fresh ? "reload" : "default", o.keysTimeoutMs);
|
|
151
|
+
return cached;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
let keys = await load(false);
|
|
158
|
+
if (!keys)
|
|
159
|
+
return { ok: false, reason: "keys" };
|
|
160
|
+
let result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
|
|
161
|
+
if (!result.ok && result.reason === "unknown-key" && !o.keys) {
|
|
162
|
+
// nano133.com may have a new key since this page last read the list.
|
|
163
|
+
keys = await load(true);
|
|
164
|
+
if (!keys)
|
|
165
|
+
return { ok: false, reason: "keys" };
|
|
166
|
+
result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
/** Forgets the keys read so far (for tests). */
|
|
171
|
+
export function forgetKeys() {
|
|
172
|
+
cached = null;
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/ticket",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read and check a Nanoterra sign-in ticket in the player's browser: for a game on a static site. The player's name, address and character; no server, no secret.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
|
|
10
|
+
},
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^26.6.3",
|
|
21
|
+
"tsx": "^4.23.15",
|
|
22
|
+
"typescript": "5.9"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": { "access": "public" },
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/nano133-com/kit.git",
|
|
28
|
+
"directory": "packages/ticket"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://nano133.com/build/nanoterra",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"keywords": ["nano", "xno", "nanoterra", "sign in", "ticket", "ed25519", "static site"]
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Read and check a Nanoterra sign-in ticket, in the player's browser. For a game on a STATIC site: no server to trade a one-time code.
|
|
2
|
+
//
|
|
3
|
+
// Nanoterra (nano133.com) opens the game's own address with the ticket after a `#`: https://game.example/#nanoterra=<ticket>.
|
|
4
|
+
// A fragment is never sent to a server. `readTicket()` takes the ticket out of the address bar at once, checks its signature with
|
|
5
|
+
// nano133.com's public key and answers with the player's username, Nano address and character.
|
|
6
|
+
//
|
|
7
|
+
// WHAT A TICKET PROVES: that nano133.com said "this player, for this game, a moment ago". Anyone who sees the address can use the
|
|
8
|
+
// ticket until it runs out (two minutes), so it proves nothing to YOUR SERVER. Use it to show the player's name and character. Never
|
|
9
|
+
// give a sale, a prize or a score on a ticket alone: for that, use the code exchange (a server that holds a secret).
|
|
10
|
+
//
|
|
11
|
+
// Format (the same as nano133.com's receipts): base64url(JSON claims) "." base64url(Ed25519 signature over the first part).
|
|
12
|
+
// Web Crypto only (Ed25519: Chrome 137, Safari 17, Firefox 129, Node 20 and later); no other import.
|
|
13
|
+
|
|
14
|
+
export const TICKET_PARAM = "nanoterra";
|
|
15
|
+
export const DEFAULT_KEYS_URL = "https://nano133.com/api/nanoterra/keys";
|
|
16
|
+
export const ISSUER = "nano133.com";
|
|
17
|
+
/** A ticket longer than this is not one. */
|
|
18
|
+
export const MAX_LENGTH = 4096;
|
|
19
|
+
/** A ticket lives this long (seconds). A longer life is refused. */
|
|
20
|
+
export const MAX_LIFE_SECONDS = 120;
|
|
21
|
+
/** A player's clock may be this far off (seconds), either way. */
|
|
22
|
+
export const SLACK_SECONDS = 60;
|
|
23
|
+
|
|
24
|
+
export type Look = { skin: string; hair: string; shirt: string; pants: string; shoes: string };
|
|
25
|
+
|
|
26
|
+
/** What a ticket says about the player. Times are in milliseconds since 1970. */
|
|
27
|
+
export type Ticket = {
|
|
28
|
+
username: string;
|
|
29
|
+
/** The player's whole Nano address. It is also the player's id. */
|
|
30
|
+
address: string;
|
|
31
|
+
look: Look;
|
|
32
|
+
/** The Nanoterra cosmetics the player owns, in the island character's slots (catalogue ids). */
|
|
33
|
+
items: string[];
|
|
34
|
+
/** The game's id at nano133.com, and the origin the ticket is for. */
|
|
35
|
+
game: string;
|
|
36
|
+
audience: string;
|
|
37
|
+
issuedAt: number;
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
/** A random id of this ticket. */
|
|
40
|
+
id: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** One public key of GET https://nano133.com/api/nanoterra/keys (a JWK, RFC 8037: Ed25519). */
|
|
44
|
+
export type PublicKey = { kid: string; x: string; kty?: string; crv?: string; alg?: string; use?: string };
|
|
45
|
+
export type Keys = { keys: PublicKey[] };
|
|
46
|
+
|
|
47
|
+
export type Refusal =
|
|
48
|
+
| "none" // no ticket in the address: a plain visit
|
|
49
|
+
| "unsupported" // this browser has no Ed25519 in Web Crypto
|
|
50
|
+
| "malformed" // not a ticket
|
|
51
|
+
| "keys" // the public keys could not be read
|
|
52
|
+
| "unknown-key" // signed with a key that is not in the list
|
|
53
|
+
| "signature" // the signature does not hold
|
|
54
|
+
| "wrong-kind" // signed right, but not a version 1 ticket from nano133.com
|
|
55
|
+
| "audience" // a ticket for another origin
|
|
56
|
+
| "expired"
|
|
57
|
+
| "early" // issued in the future (a clock that is far off)
|
|
58
|
+
| "life"; // asks for a life longer than a ticket may have
|
|
59
|
+
export type Result = { ok: true; ticket: Ticket } | { ok: false; reason: Refusal };
|
|
60
|
+
|
|
61
|
+
const TOKEN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
62
|
+
const COLOUR = /^#[0-9a-fA-F]{6}$/;
|
|
63
|
+
const ADDRESS = /^nano_[13][13-9a-km-uw-z]{59}$/;
|
|
64
|
+
const dec = new TextDecoder();
|
|
65
|
+
const enc = new TextEncoder();
|
|
66
|
+
|
|
67
|
+
function unb64url(text: string): Uint8Array<ArrayBuffer> {
|
|
68
|
+
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
|
|
69
|
+
return Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The ticket in an address's fragment (`location.hash`), or null. Other fragment parts are left alone. */
|
|
73
|
+
export function ticketFromHash(hash: string): string | null {
|
|
74
|
+
for (const part of hash.replace(/^#/, "").split("&")) {
|
|
75
|
+
if (part.startsWith(`${TICKET_PARAM}=`)) {
|
|
76
|
+
const value = part.slice(TICKET_PARAM.length + 1);
|
|
77
|
+
return value.length <= MAX_LENGTH && TOKEN.test(value) ? value : null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The fragment without the ticket ("" when nothing else is in it, else "#" and the rest). */
|
|
84
|
+
export function hashWithoutTicket(hash: string): string {
|
|
85
|
+
const rest = hash.replace(/^#/, "").split("&").filter((part) => !part.startsWith(`${TICKET_PARAM}=`) && part !== "");
|
|
86
|
+
return rest.length ? `#${rest.join("&")}` : "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How long the page waits for the keys (milliseconds). A slow nano133.com must not hold a game's first screen. */
|
|
90
|
+
export const KEYS_TIMEOUT_MS = 5000;
|
|
91
|
+
|
|
92
|
+
/** The public keys, as nano133.com publishes them (any origin may read them). `cache: "reload"` skips the browser's copy. Rejects after KEYS_TIMEOUT_MS. */
|
|
93
|
+
export async function fetchKeys(url: string = DEFAULT_KEYS_URL, fetcher: typeof fetch = fetch, cache: RequestCache = "default", timeoutMs: number = KEYS_TIMEOUT_MS): Promise<Keys> {
|
|
94
|
+
const stop = new AbortController();
|
|
95
|
+
const timer = setTimeout(() => stop.abort(), timeoutMs);
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetcher(url, { credentials: "omit", cache, signal: stop.signal });
|
|
98
|
+
if (!r.ok) throw new Error(`keys answered ${r.status}`);
|
|
99
|
+
return readKeys(await r.json());
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readKeys(j: Partial<Keys>): Keys {
|
|
106
|
+
if (!j || !Array.isArray(j.keys)) throw new Error("keys: not a list");
|
|
107
|
+
return { keys: j.keys.filter((k): k is PublicKey => !!k && typeof k.kid === "string" && typeof k.x === "string") };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type VerifyOptions = {
|
|
111
|
+
/** The origin this page is on, e.g. "https://game.example". A ticket for another origin is refused. */
|
|
112
|
+
audience: string;
|
|
113
|
+
keys: Keys;
|
|
114
|
+
/** Milliseconds since 1970 (for tests). */
|
|
115
|
+
now?: number;
|
|
116
|
+
/** Web Crypto's `subtle` (for tests). */
|
|
117
|
+
subtle?: SubtleCrypto;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type Claims = { v?: unknown; typ?: unknown; iss?: unknown; kid?: unknown; aud?: unknown; game?: unknown; sub?: unknown; name?: unknown; look?: unknown; items?: unknown; iat?: unknown; exp?: unknown; jti?: unknown };
|
|
121
|
+
|
|
122
|
+
/** Checks a ticket: the key, the signature, the kind, the audience and the time. Never throws. */
|
|
123
|
+
export async function verifyTicket(token: string, o: VerifyOptions): Promise<Result> {
|
|
124
|
+
const no = (reason: Refusal): Result => ({ ok: false, reason });
|
|
125
|
+
if (typeof token !== "string" || token.length > MAX_LENGTH || !TOKEN.test(token)) return no("malformed");
|
|
126
|
+
const subtle = o.subtle ?? globalThis.crypto?.subtle;
|
|
127
|
+
if (!subtle) return no("unsupported");
|
|
128
|
+
const [body, sig] = token.split(".") as [string, string];
|
|
129
|
+
let claims: Claims;
|
|
130
|
+
try {
|
|
131
|
+
claims = JSON.parse(dec.decode(unb64url(body))) as Claims;
|
|
132
|
+
} catch {
|
|
133
|
+
return no("malformed");
|
|
134
|
+
}
|
|
135
|
+
if (!claims || typeof claims !== "object" || typeof claims.kid !== "string") return no("malformed");
|
|
136
|
+
const key = o.keys.keys.find((k) => k.kid === claims.kid);
|
|
137
|
+
if (!key) return no("unknown-key");
|
|
138
|
+
try {
|
|
139
|
+
const publicKey = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: key.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
140
|
+
if (!(await subtle.verify("Ed25519", publicKey, unb64url(sig), enc.encode(body)))) return no("signature");
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return no((e as { name?: string })?.name === "NotSupportedError" ? "unsupported" : "signature");
|
|
143
|
+
}
|
|
144
|
+
// The signature holds: the claims are nano133.com's. They still must be what a ticket is, for this page, now.
|
|
145
|
+
if (claims.v !== 1 || claims.typ !== "ticket" || claims.iss !== ISSUER) return no("wrong-kind");
|
|
146
|
+
if (claims.aud !== o.audience) return no("audience");
|
|
147
|
+
const { iat, exp } = claims;
|
|
148
|
+
if (typeof iat !== "number" || typeof exp !== "number") return no("malformed");
|
|
149
|
+
const now = (o.now ?? Date.now()) / 1000;
|
|
150
|
+
if (!(exp > now - SLACK_SECONDS)) return no("expired");
|
|
151
|
+
if (!(iat < now + SLACK_SECONDS)) return no("early");
|
|
152
|
+
if (!(exp - iat <= MAX_LIFE_SECONDS)) return no("life");
|
|
153
|
+
const { sub, name, look, items, game, jti } = claims;
|
|
154
|
+
const l = look as Record<string, unknown> | null;
|
|
155
|
+
if (typeof sub !== "string" || !ADDRESS.test(sub) || typeof name !== "string" || !name || name.length > 60 || typeof game !== "string" || typeof jti !== "string") return no("malformed");
|
|
156
|
+
if (!l || typeof l !== "object" || !(["skin", "hair", "shirt", "pants", "shoes"] as const).every((p) => typeof l[p] === "string" && COLOUR.test(l[p] as string))) return no("malformed");
|
|
157
|
+
if (!Array.isArray(items) || items.length > 64 || !items.every((i) => typeof i === "string" && i.length <= 48)) return no("malformed");
|
|
158
|
+
const clean: Look = { skin: l.skin as string, hair: l.hair as string, shirt: l.shirt as string, pants: l.pants as string, shoes: l.shoes as string };
|
|
159
|
+
return { ok: true, ticket: { username: name, address: sub, look: clean, items: [...(items as string[])], game, audience: claims.aud as string, issuedAt: iat * 1000, expiresAt: exp * 1000, id: jti } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type ReadOptions = {
|
|
163
|
+
/** Pin the keys (no fetch). Else they are read from `keysUrl` once, and again when a ticket names a key that is not in the list. */
|
|
164
|
+
keys?: Keys;
|
|
165
|
+
keysUrl?: string;
|
|
166
|
+
/** How long to wait for the keys (milliseconds): the default is KEYS_TIMEOUT_MS. */
|
|
167
|
+
keysTimeoutMs?: number;
|
|
168
|
+
/** The origin to expect. Default: this page's own origin. */
|
|
169
|
+
audience?: string;
|
|
170
|
+
location?: Pick<Location, "hash" | "pathname" | "search" | "origin">;
|
|
171
|
+
history?: Pick<History, "replaceState" | "state">;
|
|
172
|
+
fetch?: typeof fetch;
|
|
173
|
+
now?: number;
|
|
174
|
+
subtle?: SubtleCrypto;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let cached: Keys | null = null;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reads the ticket from this page's address, if there is one. FIRST it takes the ticket out of the address bar (the page's history
|
|
181
|
+
* entry is replaced), so a reload, a copied link, a screenshot or a bookmark never carries it; then it checks the ticket. Call it once, when
|
|
182
|
+
* the page loads. `{ ok: false, reason: "none" }` is a plain visit (no network call). Anything else that is not ok is a guest.
|
|
183
|
+
*
|
|
184
|
+
* Show "Sign in as <name>? Continue / Not me" before you treat the player as that name: anyone can send a link that holds their own ticket.
|
|
185
|
+
*/
|
|
186
|
+
export async function readTicket(o: ReadOptions = {}): Promise<Result> {
|
|
187
|
+
const loc = o.location ?? globalThis.location;
|
|
188
|
+
const hist = o.history ?? globalThis.history;
|
|
189
|
+
if (!loc) return { ok: false, reason: "none" };
|
|
190
|
+
// A ticket is taken out of the address bar FIRST, also one that is not a good ticket.
|
|
191
|
+
if (!loc.hash.replace(/^#/, "").split("&").some((part) => part.startsWith(`${TICKET_PARAM}=`))) return { ok: false, reason: "none" };
|
|
192
|
+
const token = ticketFromHash(loc.hash);
|
|
193
|
+
if (hist) hist.replaceState(hist.state, "", loc.pathname + loc.search + hashWithoutTicket(loc.hash));
|
|
194
|
+
if (!token) return { ok: false, reason: "malformed" };
|
|
195
|
+
const audience = o.audience ?? loc.origin;
|
|
196
|
+
const load = async (fresh: boolean): Promise<Keys | null> => {
|
|
197
|
+
if (o.keys) return o.keys;
|
|
198
|
+
if (cached && !fresh) return cached;
|
|
199
|
+
try {
|
|
200
|
+
cached = await fetchKeys(o.keysUrl, o.fetch, fresh ? "reload" : "default", o.keysTimeoutMs);
|
|
201
|
+
return cached;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
let keys = await load(false);
|
|
207
|
+
if (!keys) return { ok: false, reason: "keys" };
|
|
208
|
+
let result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
|
|
209
|
+
if (!result.ok && result.reason === "unknown-key" && !o.keys) {
|
|
210
|
+
// nano133.com may have a new key since this page last read the list.
|
|
211
|
+
keys = await load(true);
|
|
212
|
+
if (!keys) return { ok: false, reason: "keys" };
|
|
213
|
+
result = await verifyTicket(token, { audience, keys, now: o.now, subtle: o.subtle });
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Forgets the keys read so far (for tests). */
|
|
219
|
+
export function forgetKeys(): void {
|
|
220
|
+
cached = null;
|
|
221
|
+
}
|