@estiva-app/identity 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 +57 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/client.d.ts +124 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +398 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/shell.d.ts +195 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +166 -0
- package/dist/shell.js.map +1 -0
- package/package.json +53 -0
- package/src/client.ts +512 -0
- package/src/index.ts +81 -0
- package/src/shell.ts +280 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in with Estiva ID — authorization code + PKCE.
|
|
3
|
+
*
|
|
4
|
+
* The app does not hold a password or a key. It sends the person to the identity
|
|
5
|
+
* service and gets back a token whose `sub` is their Nostr pubkey.
|
|
6
|
+
*
|
|
7
|
+
* ## Why a redirect rather than a fetch
|
|
8
|
+
*
|
|
9
|
+
* `POST /token` is reachable cross-origin, but the *session* that authorises the
|
|
10
|
+
* redirect is a `SameSite=Lax` cookie on the identity origin. Lax cookies ride
|
|
11
|
+
* top-level navigations and nothing else, so the only way to prove who you are
|
|
12
|
+
* is to actually go there. That is deliberate on their side (PEEK-90): the origin
|
|
13
|
+
* holding every custodied key does not relax its cookie or send
|
|
14
|
+
* `Access-Control-Allow-Credentials`.
|
|
15
|
+
*
|
|
16
|
+
* **A hidden iframe does not work**, and the reason is the same property: `Lax`
|
|
17
|
+
* withholds the cookie from anything that is not a top-level navigation. The
|
|
18
|
+
* refresh-token grant exists because of this. Do not "simplify" it back.
|
|
19
|
+
*
|
|
20
|
+
* ## The bits that must match exactly
|
|
21
|
+
*
|
|
22
|
+
* `redirect_uri` is compared **exact-string** against the app's registered list —
|
|
23
|
+
* a trailing slash is a different URI and gets a 400 before anything else
|
|
24
|
+
* happens. An identity provider that prefix-matched would be an open redirector
|
|
25
|
+
* handing out authorization codes.
|
|
26
|
+
*
|
|
27
|
+
* The request body is **camelCase JSON** (`codeVerifier`, not `code_verifier`)
|
|
28
|
+
* while the response is snake_case (`access_token`). That is genuinely the
|
|
29
|
+
* contract, not a typo — see `docs/API.md` in estiva-id.
|
|
30
|
+
*
|
|
31
|
+
* ## Everything is injected, and each injection is a real difference
|
|
32
|
+
*
|
|
33
|
+
* This is a factory rather than a module of free functions because the two
|
|
34
|
+
* consumers genuinely differ, and hardcoding either would have made the package
|
|
35
|
+
* come out shaped like whichever app it was extracted from:
|
|
36
|
+
*
|
|
37
|
+
* - **`clientId`** — `estiva-peek` and `estiva-ship` are separate rows in
|
|
38
|
+
* `app_credentials` with separate `redirectUris` and separate kind ceilings.
|
|
39
|
+
* - **`redirectUri`** — Peek registers a fixed `/auth/callback`; Ship routes on
|
|
40
|
+
* `location.hash` and registers its current pathname. Neither is more correct.
|
|
41
|
+
* - **`storage`** — Peek keeps its token in `sessionStorage`, which dies with the
|
|
42
|
+
* tab and keeps a bearer token off a shared machine's disk. Ship uses
|
|
43
|
+
* `localStorage`. **Do not unify this**: NIP-RS slots (CRO-4) must survive a
|
|
44
|
+
* restart and belong in `localStorage`, while an access token does not.
|
|
45
|
+
* Different lifetimes, different homes.
|
|
46
|
+
* - **`keyPrefix`** — so two apps on one origin cannot read each other's keys.
|
|
47
|
+
* - **`navigate`** — the one genuinely untestable act. Injected so a test can
|
|
48
|
+
* observe where the flow *would* go.
|
|
49
|
+
*/
|
|
50
|
+
import { type KeyValueStore, type ShellReason, clearGuard, markGuard } from './shell.js'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `crypto`, `btoa`, `TextEncoder`, `URL`, `URLSearchParams` and `fetch` are not
|
|
54
|
+
* in `lib.es2022`, and this package compiles with `types: []` and no `lib: dom`
|
|
55
|
+
* so one published `.d.ts` works in every consumer (ADR 0002 §4a).
|
|
56
|
+
*
|
|
57
|
+
* Declared inside this module, so nothing lands in a consumer's global scope,
|
|
58
|
+
* and read inside function bodies, so importing this module touches no global.
|
|
59
|
+
* The same pattern `@estiva-app/protocol` uses, and it turned out to stretch
|
|
60
|
+
* further than expected — see the README on the prediction it falsified.
|
|
61
|
+
*/
|
|
62
|
+
declare const crypto: {
|
|
63
|
+
getRandomValues(array: Uint8Array): Uint8Array
|
|
64
|
+
subtle: { digest(algorithm: string, data: Uint8Array): Promise<ArrayBuffer> }
|
|
65
|
+
}
|
|
66
|
+
declare const btoa: (binary: string) => string
|
|
67
|
+
declare const TextEncoder: { new (): { encode(input: string): Uint8Array } }
|
|
68
|
+
declare const URL: {
|
|
69
|
+
new (url: string, base?: string): { searchParams: { set(k: string, v: string): void }; toString(): string }
|
|
70
|
+
}
|
|
71
|
+
declare const URLSearchParams: { new (init: string): { get(name: string): string | null } }
|
|
72
|
+
|
|
73
|
+
/** Just enough of `fetch` to redeem a code. A parameter as well as a global. */
|
|
74
|
+
export type FetchLike = (
|
|
75
|
+
url: string,
|
|
76
|
+
init: { method: string; headers: Record<string, string>; body: string },
|
|
77
|
+
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>
|
|
78
|
+
|
|
79
|
+
declare const fetch: FetchLike
|
|
80
|
+
|
|
81
|
+
export interface EstivaIdConfig {
|
|
82
|
+
/** Origin of the identity service, e.g. `https://id.estiva.app`. */
|
|
83
|
+
base: string
|
|
84
|
+
/** This app's `client_id` row in `app_credentials`. Never defaulted. */
|
|
85
|
+
clientId: string
|
|
86
|
+
/**
|
|
87
|
+
* Where Estiva ID sends people back to. Must match a registered entry
|
|
88
|
+
* **exactly** — a trailing slash is a refused sign-in.
|
|
89
|
+
*/
|
|
90
|
+
redirectUri: () => string
|
|
91
|
+
/** See the note on storage above. Returns null outside a browser. */
|
|
92
|
+
storage: () => KeyValueStore | null
|
|
93
|
+
/** Namespaces this app's keys, e.g. `peek.estivaId`. */
|
|
94
|
+
keyPrefix: string
|
|
95
|
+
/** Defaults to the runtime's `location.assign`-equivalent via `navigate`. */
|
|
96
|
+
navigate?: (url: string) => void
|
|
97
|
+
now?: () => number
|
|
98
|
+
fetch?: FetchLike
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StoredToken {
|
|
102
|
+
accessToken: string
|
|
103
|
+
/** Epoch ms. Derived from `expires_in` at receipt, not sent by the server. */
|
|
104
|
+
expiresAt: number
|
|
105
|
+
pubkey: string
|
|
106
|
+
/**
|
|
107
|
+
* The credential that renews the one above without a redirect (PEEK-108).
|
|
108
|
+
*
|
|
109
|
+
* Absent when Estiva ID issued none — a deployment predating the grant. The
|
|
110
|
+
* app then behaves exactly as it did before, which is what lets the two sides
|
|
111
|
+
* ship independently.
|
|
112
|
+
*
|
|
113
|
+
* **Single-use.** Estiva ID rotates it on every redemption and treats a replay
|
|
114
|
+
* as evidence of theft, revoking the whole chain. So it is replaced, never
|
|
115
|
+
* kept alongside its successor.
|
|
116
|
+
*/
|
|
117
|
+
refreshToken?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class SignInError extends Error {}
|
|
121
|
+
|
|
122
|
+
interface TokenResponse {
|
|
123
|
+
access_token?: string
|
|
124
|
+
expires_in?: number
|
|
125
|
+
pubkey?: string
|
|
126
|
+
refresh_token?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface EstivaIdClient {
|
|
130
|
+
redirectUri: () => string
|
|
131
|
+
codeChallenge: (verifier: string) => Promise<string>
|
|
132
|
+
beginSignIn: (returnTo: string, options?: { silent?: boolean }) => Promise<void>
|
|
133
|
+
completeSignIn: (search: string) => Promise<{ token: StoredToken; returnTo: string }>
|
|
134
|
+
refreshAccessToken: () => Promise<StoredToken | null>
|
|
135
|
+
storedToken: () => StoredToken | null
|
|
136
|
+
validToken: (now?: number) => StoredToken | null
|
|
137
|
+
hasSession: () => boolean
|
|
138
|
+
currentAccessToken: () => string | undefined
|
|
139
|
+
beginSignOut: () => void
|
|
140
|
+
clearSession: () => void
|
|
141
|
+
hasPendingSignIn: () => boolean
|
|
142
|
+
clearPendingSignIn: () => void
|
|
143
|
+
takeReturnTo: () => string | null
|
|
144
|
+
stashShellReason: (reason: ShellReason) => void
|
|
145
|
+
takeShellReason: () => ShellReason | null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createEstivaId(config: EstivaIdConfig): EstivaIdClient {
|
|
149
|
+
const now = config.now ?? (() => Date.now())
|
|
150
|
+
const send = () => config.fetch ?? fetch
|
|
151
|
+
const go = (url: string) => {
|
|
152
|
+
if (!config.navigate) throw new Error('createEstivaId: navigate is required to leave for Estiva ID')
|
|
153
|
+
config.navigate(url)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const VERIFIER_KEY = `${config.keyPrefix}.codeVerifier`
|
|
157
|
+
const STATE_KEY = `${config.keyPrefix}.state`
|
|
158
|
+
const RETURN_KEY = `${config.keyPrefix}.returnTo`
|
|
159
|
+
const TOKEN_KEY = `${config.keyPrefix}.token`
|
|
160
|
+
/** Why the last round trip came back empty, handed from the callback to the shell. */
|
|
161
|
+
const REASON_KEY = `${config.keyPrefix}.shellReason`
|
|
162
|
+
|
|
163
|
+
const base64Url = (bytes: Uint8Array): string => {
|
|
164
|
+
let s = ''
|
|
165
|
+
for (const b of bytes) s += String.fromCharCode(b)
|
|
166
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const randomUrlSafe = (bytes = 32): string => base64Url(crypto.getRandomValues(new Uint8Array(bytes)))
|
|
170
|
+
|
|
171
|
+
/** `base64url(sha256(verifier))` — the S256 challenge. `plain` is refused upstream. */
|
|
172
|
+
const codeChallenge = async (verifier: string): Promise<string> => {
|
|
173
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
174
|
+
return base64Url(new Uint8Array(digest))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Shape a token response into what is kept.
|
|
179
|
+
*
|
|
180
|
+
* The expiry is deliberately pessimistic by 30s, so a token is never presented
|
|
181
|
+
* in the window where it is technically alive and about to not be.
|
|
182
|
+
*/
|
|
183
|
+
const tokenFrom = (body: TokenResponse, pubkey: string): StoredToken => ({
|
|
184
|
+
accessToken: body.access_token as string,
|
|
185
|
+
expiresAt: now() + Math.max(0, (body.expires_in ?? 600) - 30) * 1000,
|
|
186
|
+
pubkey,
|
|
187
|
+
...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const storedToken = (): StoredToken | null => {
|
|
191
|
+
const raw = config.storage()?.getItem(TOKEN_KEY)
|
|
192
|
+
if (!raw) return null
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(raw) as StoredToken
|
|
195
|
+
return parsed.accessToken && parsed.expiresAt ? parsed : null
|
|
196
|
+
} catch {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const validToken = (at = now()): StoredToken | null => {
|
|
202
|
+
const t = storedToken()
|
|
203
|
+
return t && t.expiresAt > at ? t : null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Forget this app's session. **Not** an in-flight sign-in — and that
|
|
208
|
+
* separation is the whole point (PEEK-167).
|
|
209
|
+
*
|
|
210
|
+
* This used to clear the verifier, state and `returnTo` as well, which reads
|
|
211
|
+
* as thoroughness and is a bug. Those three are not session state: they are
|
|
212
|
+
* the credentials of an authorization request that is *currently in the air*,
|
|
213
|
+
* owned by the `beginSignIn` that wrote them and spent by the
|
|
214
|
+
* `completeSignIn` that comes back. Clearing them on somebody else's schedule
|
|
215
|
+
* does not tidy anything up — it destroys a sign-in that is still happening.
|
|
216
|
+
*
|
|
217
|
+
* The path that made it matter: a boot whose token has expired renews, fails,
|
|
218
|
+
* clears the session, drops to the shell, and the shell probes and writes a
|
|
219
|
+
* fresh verifier. A *second* renewal awaiting the same in-flight promise then
|
|
220
|
+
* resolves and clears again — now deleting the verifier the probe just wrote.
|
|
221
|
+
* The code comes back to a tab with nothing to redeem it, `completeSignIn`
|
|
222
|
+
* refuses it before `/token` is ever called, and the identity service has no
|
|
223
|
+
* record of a failure at all because nothing was asked of it.
|
|
224
|
+
*
|
|
225
|
+
* Anything that wants both says so, and only `beginSignOut` does.
|
|
226
|
+
*/
|
|
227
|
+
const clearSession = (): void => {
|
|
228
|
+
const s = config.storage()
|
|
229
|
+
if (!s) return
|
|
230
|
+
for (const k of [TOKEN_KEY, REASON_KEY]) s.removeItem(k)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Abandon a sign-in that has not come back.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately not called on any failure path. A verifier outliving its
|
|
237
|
+
* attempt is harmless — `completeSignIn` clears all three before it does
|
|
238
|
+
* anything else, and a stale one cannot redeem a code issued against a
|
|
239
|
+
* different challenge. Destroying a live one is the expensive direction.
|
|
240
|
+
*/
|
|
241
|
+
const clearPendingSignIn = (): void => {
|
|
242
|
+
const s = config.storage()
|
|
243
|
+
if (!s) return
|
|
244
|
+
for (const k of [VERIFIER_KEY, STATE_KEY, RETURN_KEY]) s.removeItem(k)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const stashShellReason = (reason: ShellReason): void => {
|
|
248
|
+
config.storage()?.setItem(REASON_KEY, reason)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Read once. Single-use: a stale reason outliving its cause is a lie. */
|
|
252
|
+
const takeShellReason = (): ShellReason | null => {
|
|
253
|
+
const s = config.storage()
|
|
254
|
+
const raw = s?.getItem(REASON_KEY)
|
|
255
|
+
if (raw) s?.removeItem(REASON_KEY)
|
|
256
|
+
return (raw as ShellReason | null) ?? null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Where to go back to, as stashed by `beginSignIn`. Single-use.
|
|
261
|
+
*
|
|
262
|
+
* The refusal path needs this as much as the success path: a silent probe that
|
|
263
|
+
* comes back `login_required` was still started from somewhere, and dropping it
|
|
264
|
+
* puts people on a default screen instead of the link they opened (PEEK-123).
|
|
265
|
+
*/
|
|
266
|
+
const takeReturnTo = (): string | null => {
|
|
267
|
+
const s = config.storage()
|
|
268
|
+
const raw = s?.getItem(RETURN_KEY)
|
|
269
|
+
if (raw) s?.removeItem(RETURN_KEY)
|
|
270
|
+
return raw ?? null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
redirectUri: config.redirectUri,
|
|
275
|
+
codeChallenge,
|
|
276
|
+
storedToken,
|
|
277
|
+
validToken,
|
|
278
|
+
clearSession,
|
|
279
|
+
clearPendingSignIn,
|
|
280
|
+
stashShellReason,
|
|
281
|
+
takeShellReason,
|
|
282
|
+
takeReturnTo,
|
|
283
|
+
|
|
284
|
+
hasSession: () => validToken() !== null,
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The access token to hand anything that talks to the relay.
|
|
288
|
+
*
|
|
289
|
+
* Every such call needs it, because Buzz answers a non-member with
|
|
290
|
+
* `403 relay_membership_required` and a backend is a member of nothing. One
|
|
291
|
+
* call site forgetting it is not a visible failure — the read comes back
|
|
292
|
+
* empty and the widget renders nothing — so it is worth one name for "the
|
|
293
|
+
* current token" rather than copies of `validToken()?.accessToken`.
|
|
294
|
+
*/
|
|
295
|
+
currentAccessToken: () => validToken()?.accessToken,
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Whether this tab has a sign-in of its own in the air.
|
|
299
|
+
*
|
|
300
|
+
* `beginSignIn` writes the verifier and state together and `completeSignIn`
|
|
301
|
+
* spends them together, so either key answers this. Both are read because
|
|
302
|
+
* the interesting question is not which key survived — it is whether the
|
|
303
|
+
* code that just arrived belongs to anything this tab did.
|
|
304
|
+
*/
|
|
305
|
+
hasPendingSignIn: () => {
|
|
306
|
+
const s = config.storage()
|
|
307
|
+
return Boolean(s?.getItem(VERIFIER_KEY) && s.getItem(STATE_KEY))
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Leave for the identity service. Never returns — the browser navigates away.
|
|
312
|
+
*
|
|
313
|
+
* `returnTo` is kept locally rather than smuggled through `state`: `state` is
|
|
314
|
+
* a CSRF token, and widening it into a general-purpose payload invites
|
|
315
|
+
* treating whatever comes back as trustworthy. What comes back is only ever
|
|
316
|
+
* compared.
|
|
317
|
+
*/
|
|
318
|
+
async beginSignIn(returnTo: string, { silent = false }: { silent?: boolean } = {}): Promise<void> {
|
|
319
|
+
const s = config.storage()
|
|
320
|
+
if (!s) throw new Error('sign-in requires a browser')
|
|
321
|
+
|
|
322
|
+
const verifier = randomUrlSafe()
|
|
323
|
+
const state = randomUrlSafe(16)
|
|
324
|
+
s.setItem(VERIFIER_KEY, verifier)
|
|
325
|
+
s.setItem(STATE_KEY, state)
|
|
326
|
+
s.setItem(RETURN_KEY, returnTo)
|
|
327
|
+
|
|
328
|
+
/*
|
|
329
|
+
The guard is marked HERE, synchronously with the writes above, and not
|
|
330
|
+
further down next to the navigation (PEEK-167).
|
|
331
|
+
|
|
332
|
+
It used to sit just before the navigation, which reads as the same thing
|
|
333
|
+
and is not: `codeChallenge` awaits a `crypto.subtle` digest in between.
|
|
334
|
+
For that whole window the verifier and state have already been overwritten
|
|
335
|
+
while `readGuard` still answers null, so a second boot decision landing in
|
|
336
|
+
it decides to probe as well.
|
|
337
|
+
|
|
338
|
+
Two probes then write two verifier/state pairs into one tab's storage, and
|
|
339
|
+
only one of the two navigations commits. When the one that commits is not
|
|
340
|
+
the one that wrote last, the code comes back matched to a state that is no
|
|
341
|
+
longer there and `completeSignIn` refuses it — an `exchange_failed` for a
|
|
342
|
+
code that was never even presented to `/token`.
|
|
343
|
+
|
|
344
|
+
Marking it with the other single-use writes is what makes the guard cover
|
|
345
|
+
the window it exists for: they are the things this attempt has already
|
|
346
|
+
spent, and they become true together or not at all.
|
|
347
|
+
*/
|
|
348
|
+
if (silent) markGuard(s, now())
|
|
349
|
+
|
|
350
|
+
const url = new URL('/authorize', config.base)
|
|
351
|
+
url.searchParams.set('client_id', config.clientId)
|
|
352
|
+
url.searchParams.set('redirect_uri', config.redirectUri())
|
|
353
|
+
url.searchParams.set('response_type', 'code')
|
|
354
|
+
url.searchParams.set('code_challenge', await codeChallenge(verifier))
|
|
355
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
356
|
+
url.searchParams.set('state', state)
|
|
357
|
+
|
|
358
|
+
/*
|
|
359
|
+
A silent attempt asks not to be prompted (PEEK-117), so a browser with no
|
|
360
|
+
session comes straight back with `error=login_required` instead of landing
|
|
361
|
+
on the identity origin's sign-in page.
|
|
362
|
+
*/
|
|
363
|
+
if (silent) url.searchParams.set('prompt', 'none')
|
|
364
|
+
|
|
365
|
+
go(url.toString())
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Finish the round trip: verify `state`, redeem the code, keep the token.
|
|
370
|
+
*
|
|
371
|
+
* Throws `SignInError` with something a human can read — every refusal from
|
|
372
|
+
* `/token` is a deliberately uniform `invalid_grant`, so the useful detail is
|
|
373
|
+
* on this side of the exchange.
|
|
374
|
+
*/
|
|
375
|
+
async completeSignIn(search: string): Promise<{ token: StoredToken; returnTo: string }> {
|
|
376
|
+
const s = config.storage()
|
|
377
|
+
if (!s) throw new SignInError('sign-in requires a browser')
|
|
378
|
+
|
|
379
|
+
const params = new URLSearchParams(search)
|
|
380
|
+
const error = params.get('error')
|
|
381
|
+
if (error) throw new SignInError(`Estiva ID refused the sign-in (${error}).`)
|
|
382
|
+
|
|
383
|
+
const code = params.get('code')
|
|
384
|
+
const state = params.get('state')
|
|
385
|
+
const expectedState = s.getItem(STATE_KEY)
|
|
386
|
+
const verifier = s.getItem(VERIFIER_KEY)
|
|
387
|
+
const returnTo = s.getItem(RETURN_KEY) || '/'
|
|
388
|
+
|
|
389
|
+
// Clear first: these are single-use, and a failed attempt must not leave a
|
|
390
|
+
// verifier lying around for a second code to be redeemed against.
|
|
391
|
+
s.removeItem(STATE_KEY)
|
|
392
|
+
s.removeItem(VERIFIER_KEY)
|
|
393
|
+
s.removeItem(RETURN_KEY)
|
|
394
|
+
|
|
395
|
+
if (!code) throw new SignInError('Estiva ID sent no authorization code back.')
|
|
396
|
+
if (!verifier || !expectedState) {
|
|
397
|
+
throw new SignInError('This sign-in did not start in this tab. Try signing in again.')
|
|
398
|
+
}
|
|
399
|
+
if (state !== expectedState) {
|
|
400
|
+
// The one check that is not about ergonomics: a code arriving with
|
|
401
|
+
// someone else's state is the shape of a login-CSRF, refused outright.
|
|
402
|
+
throw new SignInError('Sign-in state did not match. Try signing in again.')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const response = await send()(new URL('/token', config.base).toString(), {
|
|
406
|
+
method: 'POST',
|
|
407
|
+
headers: { 'content-type': 'application/json' },
|
|
408
|
+
// camelCase — see the file header.
|
|
409
|
+
body: JSON.stringify({
|
|
410
|
+
grantType: 'authorization_code',
|
|
411
|
+
code,
|
|
412
|
+
clientId: config.clientId,
|
|
413
|
+
redirectUri: config.redirectUri(),
|
|
414
|
+
codeVerifier: verifier,
|
|
415
|
+
}),
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
if (!response.ok) {
|
|
419
|
+
throw new SignInError(
|
|
420
|
+
response.status === 400
|
|
421
|
+
? 'Estiva ID rejected this sign-in code. It may have expired — codes last 60 seconds.'
|
|
422
|
+
: `Estiva ID returned ${response.status} exchanging the code.`,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const body = (await response.json()) as TokenResponse
|
|
427
|
+
if (!body.access_token || !body.pubkey) throw new SignInError('Estiva ID returned no token.')
|
|
428
|
+
|
|
429
|
+
const token = tokenFrom(body, body.pubkey)
|
|
430
|
+
s.setItem(TOKEN_KEY, JSON.stringify(token))
|
|
431
|
+
// The attempt worked, so the next boot is entitled to a fresh silent one.
|
|
432
|
+
clearGuard(s)
|
|
433
|
+
return { token, returnTo }
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Renew the access token without leaving the page (PEEK-108).
|
|
438
|
+
*
|
|
439
|
+
* This is the whole reason the grant exists. Before it, an expired token
|
|
440
|
+
* could only be replaced by a top-level navigation through `/authorize`,
|
|
441
|
+
* which costs whatever the person had on screen.
|
|
442
|
+
*
|
|
443
|
+
* Returns `null` for every failure, and the failures are not worth
|
|
444
|
+
* distinguishing: Estiva ID answers `invalid_grant` for all of them by
|
|
445
|
+
* design, and the caller's only move either way is to fall back to the
|
|
446
|
+
* shell. The stored token is cleared on refusal so a spent refresh token is
|
|
447
|
+
* never presented twice — a replay is what Estiva ID treats as theft.
|
|
448
|
+
*/
|
|
449
|
+
async refreshAccessToken(): Promise<StoredToken | null> {
|
|
450
|
+
const s = config.storage()
|
|
451
|
+
const current = storedToken()
|
|
452
|
+
if (!s || !current?.refreshToken) return null
|
|
453
|
+
|
|
454
|
+
let response: Awaited<ReturnType<FetchLike>>
|
|
455
|
+
try {
|
|
456
|
+
response = await send()(new URL('/token', config.base).toString(), {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'content-type': 'application/json' },
|
|
459
|
+
body: JSON.stringify({
|
|
460
|
+
grantType: 'refresh_token',
|
|
461
|
+
refreshToken: current.refreshToken,
|
|
462
|
+
clientId: config.clientId,
|
|
463
|
+
}),
|
|
464
|
+
})
|
|
465
|
+
} catch {
|
|
466
|
+
// Unreachable rather than refused. The token in hand may still be fine,
|
|
467
|
+
// so this deliberately does not clear the session — a flaky network must
|
|
468
|
+
// not sign somebody out.
|
|
469
|
+
return null
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
clearSession()
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const body = (await response.json()) as TokenResponse
|
|
478
|
+
if (!body.access_token) {
|
|
479
|
+
clearSession()
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const next = tokenFrom(body, body.pubkey ?? current.pubkey)
|
|
484
|
+
s.setItem(TOKEN_KEY, JSON.stringify(next))
|
|
485
|
+
return next
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Leave for Estiva ID to end the session *there* (PEEK-122).
|
|
490
|
+
*
|
|
491
|
+
* Clearing the local token is not signing out. The `estiva_id_session` cookie
|
|
492
|
+
* on the identity origin is untouched by it, so the shell's silent probe
|
|
493
|
+
* succeeds on the next boot and puts the person straight back in — which is
|
|
494
|
+
* exactly what "sign out doesn't sign me out" was.
|
|
495
|
+
*
|
|
496
|
+
* The cookie can only be reached by going there, for the same reason entry
|
|
497
|
+
* works the way it does. The reason is stashed first so the shell offers a
|
|
498
|
+
* passkey on return rather than silently probing for a session just ended.
|
|
499
|
+
*/
|
|
500
|
+
beginSignOut(): void {
|
|
501
|
+
clearSession()
|
|
502
|
+
// Going to /logout makes any half-started sign-in moot, and this is the
|
|
503
|
+
// one caller that genuinely means both.
|
|
504
|
+
clearPendingSignIn()
|
|
505
|
+
stashShellReason('no_session')
|
|
506
|
+
const url = new URL('/logout', config.base)
|
|
507
|
+
url.searchParams.set('client_id', config.clientId)
|
|
508
|
+
url.searchParams.set('post_logout_redirect_uri', config.redirectUri())
|
|
509
|
+
go(url.toString())
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@estiva-app/identity` — signing in with Estiva ID, once.
|
|
3
|
+
*
|
|
4
|
+
* ## What is in here, and what is deliberately not
|
|
5
|
+
*
|
|
6
|
+
* **In:** the PKCE flow, token storage and validity, silent renewal, sign-out,
|
|
7
|
+
* the returnTo and shell-reason vocabulary, and the auth-shell state machine.
|
|
8
|
+
* Everything that decides *whether somebody is signed in* and *what to say when
|
|
9
|
+
* they are not*.
|
|
10
|
+
*
|
|
11
|
+
* **Not in: any app's data layer.** `peek-app/src/auth/useEstivaIdAuth.ts` is the
|
|
12
|
+
* adapter Convex's `ConvexProviderWithAuth` reads, and it stays in Peek. If it
|
|
13
|
+
* were here, Ship and every future app would inherit a Convex dependency to work
|
|
14
|
+
* around — which is precisely the failure this package's sequencing exists to
|
|
15
|
+
* avoid. Ship needs no adapter at all: it hands the token straight to its relay
|
|
16
|
+
* client, and that is the test that the line held.
|
|
17
|
+
*
|
|
18
|
+
* **Not in: the shell's rendering.** It is a full-page surface and each app will
|
|
19
|
+
* want its own. What is shared is the state machine and the reason vocabulary,
|
|
20
|
+
* which is where the expensive mistakes live. This also keeps the package clear
|
|
21
|
+
* of `@estiva-app/ui` — ADR 0002 §2 names identity depending on ui as the trigger
|
|
22
|
+
* that folds ui back into the foundation repo, and that should be a decision
|
|
23
|
+
* rather than a discovery.
|
|
24
|
+
*
|
|
25
|
+
* ## Every difference between the two consumers is a parameter
|
|
26
|
+
*
|
|
27
|
+
* `clientId`, `redirectUri`, `storage`, `keyPrefix` and `navigate` are all
|
|
28
|
+
* injected, because Peek and Ship genuinely differ on every one of them and
|
|
29
|
+
* hardcoding any would have made the package come out Peek-shaped. See
|
|
30
|
+
* `client.ts` for what each difference actually is.
|
|
31
|
+
*
|
|
32
|
+
* ## The globals prediction this package falsified
|
|
33
|
+
*
|
|
34
|
+
* Before starting, the expectation on record was that identity could not follow
|
|
35
|
+
* ADR 0002 §4a as cleanly as `@estiva-app/protocol` did — that being
|
|
36
|
+
* browser-shaped throughout, it would need `lib: dom` and a looser rule.
|
|
37
|
+
*
|
|
38
|
+
* It did not. `crypto`, `btoa`, `TextEncoder`, `URL`, `URLSearchParams`, `fetch`
|
|
39
|
+
* and `console` are declared inside the modules that use them and read inside
|
|
40
|
+
* function bodies; the DOM's `Storage` became a local `KeyValueStore` interface
|
|
41
|
+
* that `sessionStorage` and `localStorage` both satisfy structurally; and
|
|
42
|
+
* navigation is a required `navigate` callback rather than a `location` reach.
|
|
43
|
+
* The published declarations name no ambient global, and the package builds with
|
|
44
|
+
* `types: []` and no `lib: dom` exactly as `protocol` does.
|
|
45
|
+
*
|
|
46
|
+
* The thing that made it possible was not cleverness — it was that a browser API
|
|
47
|
+
* an app must inject is also a browser API a test can observe.
|
|
48
|
+
*/
|
|
49
|
+
export const IDENTITY_VERSION = '0.1.0'
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
type KeyValueStore,
|
|
53
|
+
type ShellPhase,
|
|
54
|
+
type ShellReason,
|
|
55
|
+
type ShellState,
|
|
56
|
+
type BootObservation,
|
|
57
|
+
type BootAction,
|
|
58
|
+
type ShellEvent,
|
|
59
|
+
isRecoverable,
|
|
60
|
+
SHELL_COPY,
|
|
61
|
+
CONTINUE_LABEL,
|
|
62
|
+
RETRY_LABEL,
|
|
63
|
+
PROBE_TIMEOUT_MS,
|
|
64
|
+
GUARD_TTL_MS,
|
|
65
|
+
GUARD_KEY,
|
|
66
|
+
decideBoot,
|
|
67
|
+
reasonForCallbackError,
|
|
68
|
+
readGuard,
|
|
69
|
+
markGuard,
|
|
70
|
+
clearGuard,
|
|
71
|
+
logShellEvent,
|
|
72
|
+
} from './shell.js'
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
type EstivaIdConfig,
|
|
76
|
+
type EstivaIdClient,
|
|
77
|
+
type StoredToken,
|
|
78
|
+
type FetchLike,
|
|
79
|
+
SignInError,
|
|
80
|
+
createEstivaId,
|
|
81
|
+
} from './client.js'
|