@estiva-app/identity 0.2.0 → 0.2.2
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 +60 -0
- package/README.md +12 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/signer.d.ts +32 -5
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +37 -4
- package/dist/signer.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/signer.ts +55 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# @estiva-app/identity
|
|
2
2
|
|
|
3
|
+
## 0.2.2 — 2026-09-08
|
|
4
|
+
|
|
5
|
+
**Additive.** `token` gains a function form; a string behaves exactly as before,
|
|
6
|
+
so `^0.2.0` consumers need no change.
|
|
7
|
+
|
|
8
|
+
- **`estivaIdSigner`'s `token` may now be `() => string | undefined`**, read per
|
|
9
|
+
signature instead of captured once.
|
|
10
|
+
|
|
11
|
+
Found while adopting `scheduleRenewal` in Ship, which builds its signer at
|
|
12
|
+
module scope (`web/src/api/store.ts`) and passed the token as a string. A
|
|
13
|
+
schedule replaces the *stored* token before it expires; a signer holding a
|
|
14
|
+
string keeps presenting the old one, pays a `401` on the next signature, and
|
|
15
|
+
its retry spends a **second** grant to reach a token the app already had. Two
|
|
16
|
+
rotations per expiry — and the renewal the schedule exists to make invisible is
|
|
17
|
+
not invisible at all. Adopting the schedule without this would have been
|
|
18
|
+
decorative on the write path.
|
|
19
|
+
|
|
20
|
+
A string stays supported deliberately: a script that obtains one token and
|
|
21
|
+
exits has nothing to read from, and making those write `() => t` would be
|
|
22
|
+
noise. With a reader, a renewal from anywhere is picked up without the signer
|
|
23
|
+
being told — including one written by another tab into shared storage.
|
|
24
|
+
|
|
25
|
+
A reader returning `undefined` is treated as {@link SignerTokenExpired} and
|
|
26
|
+
`/sign` is never called: signed out mid-session is not a signing failure, and
|
|
27
|
+
asking the service to judge an absent token is a round trip whose answer is
|
|
28
|
+
already known.
|
|
29
|
+
|
|
30
|
+
- **Corrected a claim this package inherited and spread.** The `renew` doc said
|
|
31
|
+
access tokens "live ten minutes rather than an hour". Measured against
|
|
32
|
+
production on 2026-09-08, a token has ~3600s: `expires_in_sec` read 3544 on a
|
|
33
|
+
live session and 3554 immediately after a renewal. The sentence came from
|
|
34
|
+
Ship's own comment and I carried it here when `POST /sign` moved in, so it was
|
|
35
|
+
about to be the shared layer's version of a wrong fact. No behaviour depended
|
|
36
|
+
on it — `expires_in` is read from the response and never assumed.
|
|
37
|
+
|
|
38
|
+
3 new tests, 80 to 83.
|
|
39
|
+
|
|
40
|
+
## 0.2.1 — 2026-09-08
|
|
41
|
+
|
|
42
|
+
**Additive.** `SignRefused` is new; nothing that catches `Error` or reads a
|
|
43
|
+
message changes.
|
|
44
|
+
|
|
45
|
+
- **A `/sign` refusal that is not an expiry now throws `SignRefused`, carrying
|
|
46
|
+
the HTTP `status`.** A `401` still throws `SignerTokenExpired`, which is the
|
|
47
|
+
one refusal a new token fixes.
|
|
48
|
+
|
|
49
|
+
The status belongs on the error because **consumers classify on it and a
|
|
50
|
+
message is not an API.** Found while wiring Peek as the third consumer:
|
|
51
|
+
`reasonForSignFailure` maps `/sign` failures into the auth shell's reason
|
|
52
|
+
vocabulary by regexing `POST /sign failed: (\d{3})` out of the message Peek's
|
|
53
|
+
own copy threw. `403` is `identity_inactive` — an administrator has to restore
|
|
54
|
+
the identity — while a `422` policy refusal is not, and neither is a `502`.
|
|
55
|
+
|
|
56
|
+
Swapping in this package's differently worded message would have collapsed
|
|
57
|
+
every one of those into a single reason. **Nothing would have failed**: Peek's
|
|
58
|
+
tests pass literal strings rather than real errors, so the regex kept matching
|
|
59
|
+
in the test suite while matching nothing in production. That is the shape worth
|
|
60
|
+
recording — the test and the code agreed with each other and both stopped
|
|
61
|
+
describing the app.
|
|
62
|
+
|
|
3
63
|
## 0.2.0 — 2026-09-07
|
|
4
64
|
|
|
5
65
|
**MINOR because it adds a peer dependency**, which is a break for a consumer
|
package/README.md
CHANGED
|
@@ -75,8 +75,14 @@ export const { validToken, beginSignIn, completeSignIn, beginSignOut } = client
|
|
|
75
75
|
// Peek: one round trip, a token read fresh each time
|
|
76
76
|
const event = await signViaEstivaId(unsigned, { base, token, expectedPubkey: myPubkey })
|
|
77
77
|
|
|
78
|
-
// Ship: a protocol Signer, built once,
|
|
79
|
-
|
|
78
|
+
// Ship: a protocol Signer, built once. Pass a READER, not a string, if anything
|
|
79
|
+
// else can replace the token — `scheduleRenewal` does.
|
|
80
|
+
const signer = estivaIdSigner({
|
|
81
|
+
base,
|
|
82
|
+
pubkey,
|
|
83
|
+
token: () => client.validToken()?.accessToken,
|
|
84
|
+
renew: client.refreshAccessToken,
|
|
85
|
+
})
|
|
80
86
|
```
|
|
81
87
|
|
|
82
88
|
An app publishes as a person without ever seeing their secret. `@estiva-app/protocol`
|
|
@@ -90,7 +96,10 @@ built with. There were three copies of this round trip before SHA-4 and only one
|
|
|
90
96
|
of them checked; the one that did not was Ship's, through which every write in
|
|
91
97
|
Ship passes.
|
|
92
98
|
|
|
93
|
-
A `401` throws `SignerTokenExpired
|
|
99
|
+
A `401` throws `SignerTokenExpired`; any other refusal throws `SignRefused`
|
|
100
|
+
carrying the HTTP `status`, because consumers classify on it and a message is not
|
|
101
|
+
an API — `403` means an administrator has to restore the identity, while a `422`
|
|
102
|
+
policy refusal means something else entirely. And
|
|
94
103
|
`estivaIdSigner` renews **once** and retries. Never twice: a second `401` after
|
|
95
104
|
a successful renewal is a token the service will not accept, and retrying that
|
|
96
105
|
is how one refused signature becomes a hot loop. Anything that is not an expiry
|
package/dist/index.d.ts
CHANGED
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
* The thing that made it possible was not cleverness — it was that a browser API
|
|
47
47
|
* an app must inject is also a browser API a test can observe.
|
|
48
48
|
*/
|
|
49
|
-
export declare const IDENTITY_VERSION = "0.2.
|
|
49
|
+
export declare const IDENTITY_VERSION = "0.2.2";
|
|
50
50
|
export { type KeyValueStore, type ShellPhase, type ShellReason, type ShellState, type BootObservation, type BootAction, type ShellEvent, isRecoverable, SHELL_COPY, CONTINUE_LABEL, RETRY_LABEL, PROBE_TIMEOUT_MS, GUARD_TTL_MS, GUARD_KEY, decideBoot, reasonForCallbackError, readGuard, markGuard, clearGuard, logShellEvent, } from './shell.js';
|
|
51
51
|
export { type EstivaIdConfig, type EstivaIdClient, type StoredToken, type FetchLike, type RenewalHandlers, type RenewalSchedule, SignInError, createEstivaId, } from './client.js';
|
|
52
|
-
export { type SignViaEstivaIdOptions, type EstivaIdSignerOptions, type SignFetchLike, SignerTokenExpired, signViaEstivaId, estivaIdSigner, } from './signer.js';
|
|
52
|
+
export { type SignViaEstivaIdOptions, type EstivaIdSignerOptions, type SignFetchLike, SignerTokenExpired, SignRefused, signViaEstivaId, estivaIdSigner, } from './signer.js';
|
|
53
53
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,aAAa,EAClB,kBAAkB,EAClB,eAAe,EACf,cAAc,GACf,MAAM,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,eAAO,MAAM,gBAAgB,UAAU,CAAA;AAEvC,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,aAAa,EAClB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,cAAc,GACf,MAAM,aAAa,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
* The thing that made it possible was not cleverness — it was that a browser API
|
|
47
47
|
* an app must inject is also a browser API a test can observe.
|
|
48
48
|
*/
|
|
49
|
-
export const IDENTITY_VERSION = '0.2.
|
|
49
|
+
export const IDENTITY_VERSION = '0.2.2';
|
|
50
50
|
export { isRecoverable, SHELL_COPY, CONTINUE_LABEL, RETRY_LABEL, PROBE_TIMEOUT_MS, GUARD_TTL_MS, GUARD_KEY, decideBoot, reasonForCallbackError, readGuard, markGuard, clearGuard, logShellEvent, } from './shell.js';
|
|
51
51
|
export { SignInError, createEstivaId, } from './client.js';
|
|
52
|
-
export { SignerTokenExpired, signViaEstivaId, estivaIdSigner, } from './signer.js';
|
|
52
|
+
export { SignerTokenExpired, SignRefused, signViaEstivaId, estivaIdSigner, } from './signer.js';
|
|
53
53
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC,OAAO,EAQL,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EAOL,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAEpB,OAAO,EAIL,kBAAkB,EAClB,eAAe,EACf,cAAc,GACf,MAAM,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAA;AAEvC,OAAO,EAQL,aAAa,EACb,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,UAAU,EACV,sBAAsB,EACtB,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAA;AAEnB,OAAO,EAOL,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAEpB,OAAO,EAIL,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,cAAc,GACf,MAAM,aAAa,CAAA"}
|
package/dist/signer.d.ts
CHANGED
|
@@ -67,6 +67,21 @@ export type SignFetchLike = typeof fetch;
|
|
|
67
67
|
export declare class SignerTokenExpired extends Error {
|
|
68
68
|
constructor();
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A refusal that is not an expiry, **carrying the status that says which**.
|
|
72
|
+
*
|
|
73
|
+
* The status is on the error rather than only in its text because consumers
|
|
74
|
+
* classify on it and a message is not an API. Peek maps `/sign` failures into
|
|
75
|
+
* the auth shell's reason vocabulary — `403` is `identity_inactive`, an
|
|
76
|
+
* administrator's problem, while a `422` policy refusal is not — and it did that
|
|
77
|
+
* by regexing its own thrown string. A package that threw a differently worded
|
|
78
|
+
* message would have quietly collapsed every one of those into the same reason,
|
|
79
|
+
* and Peek's tests pass literal strings, so nothing would have failed.
|
|
80
|
+
*/
|
|
81
|
+
export declare class SignRefused extends Error {
|
|
82
|
+
readonly status: number;
|
|
83
|
+
constructor(status: number, message: string);
|
|
84
|
+
}
|
|
70
85
|
export interface SignViaEstivaIdOptions {
|
|
71
86
|
/** Origin of the identity service, e.g. `https://id.estiva.app`. */
|
|
72
87
|
base: string;
|
|
@@ -106,15 +121,27 @@ export interface EstivaIdSignerOptions {
|
|
|
106
121
|
base: string;
|
|
107
122
|
/** The pubkey the token was issued for — `sub` in the JWT. */
|
|
108
123
|
pubkey: string;
|
|
109
|
-
/**
|
|
110
|
-
|
|
124
|
+
/**
|
|
125
|
+
* A bearer token from `POST /token`, audience-bound to this app — or a
|
|
126
|
+
* function returning the current one.
|
|
127
|
+
*
|
|
128
|
+
* **Pass the function if anything else can replace the token**, which
|
|
129
|
+
* {@link EstivaIdClient.scheduleRenewal} does by design. A string is captured
|
|
130
|
+
* once and held, so a scheduled renewal would refresh the *stored* token while
|
|
131
|
+
* this signer kept presenting the old one: the next signature still pays a
|
|
132
|
+
* `401`, and the retry spends a **second** grant for a token the app already
|
|
133
|
+
* holds. Two rotations per expiry, and the invisible renewal the schedule
|
|
134
|
+
* exists to provide never happens.
|
|
135
|
+
*
|
|
136
|
+
* A string stays supported because a script that obtains one token and exits
|
|
137
|
+
* has nothing to read from, and making those pass `() => t` would be noise.
|
|
138
|
+
*/
|
|
139
|
+
token: string | (() => string | undefined);
|
|
111
140
|
/**
|
|
112
141
|
* Renew the token, once, when `/sign` says it has expired (PEEK-108).
|
|
113
142
|
*
|
|
114
143
|
* Optional: without it a `401` surfaces as {@link SignerTokenExpired} for the
|
|
115
|
-
* caller to deal with, exactly as before. With it, an expiry is invisible
|
|
116
|
-
* which matters more than it used to, because access tokens live ten minutes
|
|
117
|
-
* rather than an hour, so a session of any length crosses one.
|
|
144
|
+
* caller to deal with, exactly as before. With it, an expiry is invisible.
|
|
118
145
|
*
|
|
119
146
|
* Returning `null` means renewal failed and the expiry is real.
|
|
120
147
|
*
|
package/dist/signer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signer.d.ts","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAE9E;;;;;;;;GAQG;AACH,OAAO,CAAC,MAAM,KAAK,EAAE,CACnB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KACpE,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CAAA;AAEtE,kFAAkF;AAClF,MAAM,MAAM,aAAa,GAAG,OAAO,KAAK,CAAA;AAExC,mEAAmE;AACnE,qBAAa,kBAAmB,SAAQ,KAAK;;CAK5C;AAED,MAAM,WAAW,sBAAsB;IACrC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,aAAa,EACvB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,WAAW,CAAC,CAkDtB;AAED,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAA;IACd
|
|
1
|
+
{"version":3,"file":"signer.d.ts","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAE9E;;;;;;;;GAQG;AACH,OAAO,CAAC,MAAM,KAAK,EAAE,CACnB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KACpE,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CAAA;AAEtE,kFAAkF;AAClF,MAAM,MAAM,aAAa,GAAG,OAAO,KAAK,CAAA;AAExC,mEAAmE;AACnE,qBAAa,kBAAmB,SAAQ,KAAK;;CAK5C;AAED;;;;;;;;;;GAUG;AACH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,MAAM,EAAE,MAAM;gBAAd,MAAM,EAAE,MAAM,EACvB,OAAO,EAAE,MAAM;CAKlB;AAED,MAAM,WAAW,sBAAsB;IACrC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,aAAa,EACvB,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,WAAW,CAAC,CAkDtB;AAED,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAA;IACd;;;;;;;;;;;;;;OAcG;IACH,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAA;IAC1C;;;;;;;;;;OAUG;IACH,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACpC,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,qBAAqB,GAAG,MAAM,CAqDzG"}
|
package/dist/signer.js
CHANGED
|
@@ -5,6 +5,25 @@ export class SignerTokenExpired extends Error {
|
|
|
5
5
|
this.name = 'SignerTokenExpired';
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A refusal that is not an expiry, **carrying the status that says which**.
|
|
10
|
+
*
|
|
11
|
+
* The status is on the error rather than only in its text because consumers
|
|
12
|
+
* classify on it and a message is not an API. Peek maps `/sign` failures into
|
|
13
|
+
* the auth shell's reason vocabulary — `403` is `identity_inactive`, an
|
|
14
|
+
* administrator's problem, while a `422` policy refusal is not — and it did that
|
|
15
|
+
* by regexing its own thrown string. A package that threw a differently worded
|
|
16
|
+
* message would have quietly collapsed every one of those into the same reason,
|
|
17
|
+
* and Peek's tests pass literal strings, so nothing would have failed.
|
|
18
|
+
*/
|
|
19
|
+
export class SignRefused extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(status, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.name = 'SignRefused';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
8
27
|
/**
|
|
9
28
|
* One `POST /sign`.
|
|
10
29
|
*
|
|
@@ -53,7 +72,7 @@ export async function signViaEstivaId(unsigned, options) {
|
|
|
53
72
|
// Keep the raw text. `/sign`'s error bodies are not reliably JSON, and the
|
|
54
73
|
// raw text is more use to a person than "unparseable response".
|
|
55
74
|
}
|
|
56
|
-
throw new
|
|
75
|
+
throw new SignRefused(response.status, `Estiva ID refused to sign kind:${unsigned.kind} — ${reason}`);
|
|
57
76
|
}
|
|
58
77
|
const event = JSON.parse(text).event;
|
|
59
78
|
if (!event)
|
|
@@ -71,7 +90,15 @@ export async function signViaEstivaId(unsigned, options) {
|
|
|
71
90
|
* rather than being rediscovered on each one.
|
|
72
91
|
*/
|
|
73
92
|
export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }) {
|
|
74
|
-
|
|
93
|
+
/*
|
|
94
|
+
A held bearer only when there is nothing to read from. When `token` is a
|
|
95
|
+
function the current value is asked for per signature, so a renewal from
|
|
96
|
+
anywhere — a schedule, another tab's write into shared storage, the retry
|
|
97
|
+
below — is picked up without this signer being told.
|
|
98
|
+
*/
|
|
99
|
+
const read = typeof token === 'function' ? token : undefined;
|
|
100
|
+
let held = typeof token === 'string' ? token : undefined;
|
|
101
|
+
const bearerNow = () => (read ? read() : held);
|
|
75
102
|
return {
|
|
76
103
|
pubkey,
|
|
77
104
|
kind: 'estiva-id',
|
|
@@ -87,6 +114,9 @@ export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }) {
|
|
|
87
114
|
expectedPubkey: pubkey,
|
|
88
115
|
...(send ? { fetch: send } : {}),
|
|
89
116
|
});
|
|
117
|
+
const bearer = bearerNow();
|
|
118
|
+
if (bearer === undefined)
|
|
119
|
+
throw new SignerTokenExpired();
|
|
90
120
|
try {
|
|
91
121
|
return await once(bearer);
|
|
92
122
|
}
|
|
@@ -105,8 +135,11 @@ export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }) {
|
|
|
105
135
|
const renewed = await renew();
|
|
106
136
|
if (!renewed)
|
|
107
137
|
throw error;
|
|
108
|
-
bearer
|
|
109
|
-
|
|
138
|
+
// Only a held bearer needs replacing. A reader already sees the new one,
|
|
139
|
+
// because `renew` wrote it where the reader reads from.
|
|
140
|
+
if (read === undefined)
|
|
141
|
+
held = renewed;
|
|
142
|
+
return await once(renewed);
|
|
110
143
|
}
|
|
111
144
|
},
|
|
112
145
|
};
|
package/dist/signer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signer.js","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"AA+DA,mEAAmE;AACnE,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC3C;QACE,KAAK,CAAC,+BAA+B,CAAC,CAAA;QACtC,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAA;IAClC,CAAC;CACF;AAwBD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,QAAuB,EACvB,OAA+B;IAE/B,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,IAAI,KAAK,CAAA;IACnC,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IAE/C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,GAAG,MAAM,OAAO,EAAE;QAC5C,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,aAAa,EAAE,UAAU,OAAO,CAAC,KAAK,EAAE;SACzC;QACD;;;;UAIE;QACF,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,QAAQ,CAAC,IAAI;gBACnB,UAAU,EAAE,QAAQ,CAAC,UAAU;gBAC/B,IAAI,EAAE,QAAQ,CAAC,IAAI;gBACnB,OAAO,EAAE,QAAQ,CAAC,OAAO;aAC1B;SACF,CAAC;KACH,CAAC,CAAA;IAEF,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAClC,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;QAAE,MAAM,IAAI,kBAAkB,EAAE,CAAA;IAC3D,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,IAAI,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;QAC/B,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqC,CAAA;YACjE,MAAM,GAAG,IAAI,CAAC,KAAK,EAAE,OAAO,IAAI,MAAM,CAAA;QACxC,CAAC;QAAC,MAAM,CAAC;YACP,2EAA2E;YAC3E,gEAAgE;QAClE,CAAC;QACD,MAAM,IAAI,
|
|
1
|
+
{"version":3,"file":"signer.js","sourceRoot":"","sources":["../src/signer.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/identity",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Estiva ID sign-in: authorization code + PKCE, token storage, silent renewal, sign-out, signing via POST /sign, and the auth-shell state machine. Knows nothing about any app's data layer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* The thing that made it possible was not cleverness — it was that a browser API
|
|
47
47
|
* an app must inject is also a browser API a test can observe.
|
|
48
48
|
*/
|
|
49
|
-
export const IDENTITY_VERSION = '0.2.
|
|
49
|
+
export const IDENTITY_VERSION = '0.2.2'
|
|
50
50
|
|
|
51
51
|
export {
|
|
52
52
|
type KeyValueStore,
|
|
@@ -87,6 +87,7 @@ export {
|
|
|
87
87
|
type EstivaIdSignerOptions,
|
|
88
88
|
type SignFetchLike,
|
|
89
89
|
SignerTokenExpired,
|
|
90
|
+
SignRefused,
|
|
90
91
|
signViaEstivaId,
|
|
91
92
|
estivaIdSigner,
|
|
92
93
|
} from './signer.js'
|
package/src/signer.ts
CHANGED
|
@@ -69,6 +69,27 @@ export class SignerTokenExpired extends Error {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A refusal that is not an expiry, **carrying the status that says which**.
|
|
74
|
+
*
|
|
75
|
+
* The status is on the error rather than only in its text because consumers
|
|
76
|
+
* classify on it and a message is not an API. Peek maps `/sign` failures into
|
|
77
|
+
* the auth shell's reason vocabulary — `403` is `identity_inactive`, an
|
|
78
|
+
* administrator's problem, while a `422` policy refusal is not — and it did that
|
|
79
|
+
* by regexing its own thrown string. A package that threw a differently worded
|
|
80
|
+
* message would have quietly collapsed every one of those into the same reason,
|
|
81
|
+
* and Peek's tests pass literal strings, so nothing would have failed.
|
|
82
|
+
*/
|
|
83
|
+
export class SignRefused extends Error {
|
|
84
|
+
constructor(
|
|
85
|
+
readonly status: number,
|
|
86
|
+
message: string,
|
|
87
|
+
) {
|
|
88
|
+
super(message)
|
|
89
|
+
this.name = 'SignRefused'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
export interface SignViaEstivaIdOptions {
|
|
73
94
|
/** Origin of the identity service, e.g. `https://id.estiva.app`. */
|
|
74
95
|
base: string
|
|
@@ -142,7 +163,7 @@ export async function signViaEstivaId(
|
|
|
142
163
|
// Keep the raw text. `/sign`'s error bodies are not reliably JSON, and the
|
|
143
164
|
// raw text is more use to a person than "unparseable response".
|
|
144
165
|
}
|
|
145
|
-
throw new
|
|
166
|
+
throw new SignRefused(response.status, `Estiva ID refused to sign kind:${unsigned.kind} — ${reason}`)
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
const event = (JSON.parse(text) as { event?: SignedEvent }).event
|
|
@@ -163,15 +184,27 @@ export interface EstivaIdSignerOptions {
|
|
|
163
184
|
base: string
|
|
164
185
|
/** The pubkey the token was issued for — `sub` in the JWT. */
|
|
165
186
|
pubkey: string
|
|
166
|
-
/**
|
|
167
|
-
|
|
187
|
+
/**
|
|
188
|
+
* A bearer token from `POST /token`, audience-bound to this app — or a
|
|
189
|
+
* function returning the current one.
|
|
190
|
+
*
|
|
191
|
+
* **Pass the function if anything else can replace the token**, which
|
|
192
|
+
* {@link EstivaIdClient.scheduleRenewal} does by design. A string is captured
|
|
193
|
+
* once and held, so a scheduled renewal would refresh the *stored* token while
|
|
194
|
+
* this signer kept presenting the old one: the next signature still pays a
|
|
195
|
+
* `401`, and the retry spends a **second** grant for a token the app already
|
|
196
|
+
* holds. Two rotations per expiry, and the invisible renewal the schedule
|
|
197
|
+
* exists to provide never happens.
|
|
198
|
+
*
|
|
199
|
+
* A string stays supported because a script that obtains one token and exits
|
|
200
|
+
* has nothing to read from, and making those pass `() => t` would be noise.
|
|
201
|
+
*/
|
|
202
|
+
token: string | (() => string | undefined)
|
|
168
203
|
/**
|
|
169
204
|
* Renew the token, once, when `/sign` says it has expired (PEEK-108).
|
|
170
205
|
*
|
|
171
206
|
* Optional: without it a `401` surfaces as {@link SignerTokenExpired} for the
|
|
172
|
-
* caller to deal with, exactly as before. With it, an expiry is invisible
|
|
173
|
-
* which matters more than it used to, because access tokens live ten minutes
|
|
174
|
-
* rather than an hour, so a session of any length crosses one.
|
|
207
|
+
* caller to deal with, exactly as before. With it, an expiry is invisible.
|
|
175
208
|
*
|
|
176
209
|
* Returning `null` means renewal failed and the expiry is real.
|
|
177
210
|
*
|
|
@@ -189,7 +222,15 @@ export interface EstivaIdSignerOptions {
|
|
|
189
222
|
* rather than being rediscovered on each one.
|
|
190
223
|
*/
|
|
191
224
|
export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }: EstivaIdSignerOptions): Signer {
|
|
192
|
-
|
|
225
|
+
/*
|
|
226
|
+
A held bearer only when there is nothing to read from. When `token` is a
|
|
227
|
+
function the current value is asked for per signature, so a renewal from
|
|
228
|
+
anywhere — a schedule, another tab's write into shared storage, the retry
|
|
229
|
+
below — is picked up without this signer being told.
|
|
230
|
+
*/
|
|
231
|
+
const read = typeof token === 'function' ? token : undefined
|
|
232
|
+
let held = typeof token === 'string' ? token : undefined
|
|
233
|
+
const bearerNow = (): string | undefined => (read ? read() : held)
|
|
193
234
|
|
|
194
235
|
return {
|
|
195
236
|
pubkey,
|
|
@@ -208,6 +249,9 @@ export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }: Esti
|
|
|
208
249
|
...(send ? { fetch: send } : {}),
|
|
209
250
|
})
|
|
210
251
|
|
|
252
|
+
const bearer = bearerNow()
|
|
253
|
+
if (bearer === undefined) throw new SignerTokenExpired()
|
|
254
|
+
|
|
211
255
|
try {
|
|
212
256
|
return await once(bearer)
|
|
213
257
|
} catch (error) {
|
|
@@ -223,8 +267,10 @@ export function estivaIdSigner({ base, pubkey, token, renew, fetch: send }: Esti
|
|
|
223
267
|
if (!(error instanceof SignerTokenExpired) || !renew) throw error
|
|
224
268
|
const renewed = await renew()
|
|
225
269
|
if (!renewed) throw error
|
|
226
|
-
bearer
|
|
227
|
-
|
|
270
|
+
// Only a held bearer needs replacing. A reader already sees the new one,
|
|
271
|
+
// because `renew` wrote it where the reader reads from.
|
|
272
|
+
if (read === undefined) held = renewed
|
|
273
|
+
return await once(renewed)
|
|
228
274
|
}
|
|
229
275
|
},
|
|
230
276
|
}
|