@guuey/widget-auth 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/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/errors.d.ts +157 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +175 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +324 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Loqu, Inc.
|
|
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
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# @guuey/widget-auth
|
|
2
|
+
|
|
3
|
+
Mint end-user identity tokens for a [guuey](https://guuey.com) embeddable widget, from your own backend.
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies. Node 18+.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @guuey/widget-auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
The widget on your page needs to know **who the visitor is** — otherwise every visit
|
|
14
|
+
starts from scratch, with no history, memory or files. It learns that from a short-lived
|
|
15
|
+
signed token, and only your backend can issue one, because only your backend knows who
|
|
16
|
+
is logged in.
|
|
17
|
+
|
|
18
|
+
You never hold a signing key. Your app's private key is sealed in the platform's KMS and
|
|
19
|
+
never leaves it; this package is a typed, validated call to the mint route.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
**1. Enrol your app once** and keep the secret it prints:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
guuey widget keys create <appId> --audience <your-app-audience>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The secret is shown **once**. Store it the way you store a database password.
|
|
30
|
+
|
|
31
|
+
**2. Add a token endpoint to your backend.** This example is a Next.js route handler;
|
|
32
|
+
any framework works the same way.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// app/api/guuey-token/route.ts
|
|
36
|
+
import { signUserToken } from "@guuey/widget-auth";
|
|
37
|
+
import { getSession } from "@/lib/auth";
|
|
38
|
+
|
|
39
|
+
export async function GET() {
|
|
40
|
+
const session = await getSession();
|
|
41
|
+
if (!session) return new Response("Unauthorized", { status: 401 });
|
|
42
|
+
|
|
43
|
+
const { token } = await signUserToken(
|
|
44
|
+
{ userId: session.userId, name: session.name, email: session.email },
|
|
45
|
+
{
|
|
46
|
+
appId: process.env.GUUEY_APP_ID!,
|
|
47
|
+
appSecret: process.env.GUUEY_APP_SECRET!,
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// The widget's `getToken` expects the raw token string.
|
|
52
|
+
return new Response(token, { headers: { "content-type": "text/plain" } });
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**3. Point the widget at it** in the embed snippet:
|
|
57
|
+
|
|
58
|
+
<!-- GENERATED — do not hand-edit. This block is a paste target, generated by
|
|
59
|
+
`embedSnippet()` in apps/widget/loader/snippet.ts and byte-asserted against
|
|
60
|
+
it by that package's snippet-docs-parity.test.ts. Prettier reformatting it
|
|
61
|
+
(it exploded the queue shim once already) makes it valid JS and a docs lie:
|
|
62
|
+
the shim must stay byte-identical to the one the loader suite replays. -->
|
|
63
|
+
<!-- prettier-ignore -->
|
|
64
|
+
```html
|
|
65
|
+
<!-- guuey widget. Add this site to the app's allowed domains or the embed is refused. -->
|
|
66
|
+
<script>
|
|
67
|
+
window.guuey = window.guuey || function(){(guuey.q=guuey.q||[]).push(arguments)};
|
|
68
|
+
guuey("init", {
|
|
69
|
+
app: "app_abc123",
|
|
70
|
+
identity: {
|
|
71
|
+
getToken: (reason) =>
|
|
72
|
+
fetch("/api/guuey-token" + "?reason=" + reason).then((r) => {
|
|
73
|
+
if (!r.ok) throw new Error("token endpoint failed: " + r.status);
|
|
74
|
+
return r.text();
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
</script>
|
|
79
|
+
<script src="https://widget.guuey.com/v1.js" async></script>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Your endpoint is called by the browser; the app secret stays on your server.
|
|
83
|
+
|
|
84
|
+
## The app secret is server-side only
|
|
85
|
+
|
|
86
|
+
`appSecret` authorizes minting an identity for **any user of your app**. If it reaches a
|
|
87
|
+
browser — bundled into frontend code, or because you called this package from the client —
|
|
88
|
+
anyone who reads it can impersonate any of your users.
|
|
89
|
+
|
|
90
|
+
That is the whole reason the widget asks _your_ server for a token instead of minting one
|
|
91
|
+
itself. Keep the secret in an environment variable on the server, and never return it from
|
|
92
|
+
an endpoint.
|
|
93
|
+
|
|
94
|
+
**`signUserToken` refuses to run in a browser** — it throws a `WidgetAuthConfigError` if it
|
|
95
|
+
detects a DOM, before doing anything else — so this fails loudly at your first call rather
|
|
96
|
+
than silently shipping the secret to every visitor. Edge runtimes (Cloudflare Workers,
|
|
97
|
+
Vercel Edge, Deno Deploy) are not browsers and are fine.
|
|
98
|
+
|
|
99
|
+
If it does leak, rotate it:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
guuey widget keys rotate <appId> --new-secret
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Caching tokens
|
|
106
|
+
|
|
107
|
+
Tokens are short-lived (15 minutes by default). Minting one per page load is fine, but if
|
|
108
|
+
your token endpoint is hot you can cache.
|
|
109
|
+
|
|
110
|
+
**Cache per user, and expire ~2 minutes before the token does.** The margin covers the
|
|
111
|
+
round trip plus any clock difference between your server and the visitor's browser.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const cache = new Map<string, { token: string; refreshAt: number }>();
|
|
115
|
+
const MARGIN_SECONDS = 120;
|
|
116
|
+
|
|
117
|
+
async function tokenFor(user: { userId: string; name?: string }, force: boolean) {
|
|
118
|
+
const hit = cache.get(user.userId);
|
|
119
|
+
if (!force && hit && Date.now() / 1000 < hit.refreshAt) return hit.token;
|
|
120
|
+
|
|
121
|
+
const { token, expiresAtEpoch } = await signUserToken(user, {
|
|
122
|
+
appId: process.env.GUUEY_APP_ID!,
|
|
123
|
+
appSecret: process.env.GUUEY_APP_SECRET!,
|
|
124
|
+
});
|
|
125
|
+
cache.set(user.userId, { token, refreshAt: expiresAtEpoch - MARGIN_SECONDS });
|
|
126
|
+
return token;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**If you cache, you must honour `reason`.** The widget calls `getToken(reason)` with:
|
|
131
|
+
|
|
132
|
+
| `reason` | Meaning |
|
|
133
|
+
| ----------- | ----------------------------------------------------------------------------- |
|
|
134
|
+
| `'initial'` | First token needed for this session — a cached token is fine. |
|
|
135
|
+
| `'expired'` | The token was rejected as expired. **Mint a fresh one, ignoring your cache.** |
|
|
136
|
+
|
|
137
|
+
A cache that ignores `'expired'` keeps returning the same dead token, and every turn after
|
|
138
|
+
the first expiry fails. Forward the reason to your endpoint and pass it through:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
export async function GET(request: Request) {
|
|
142
|
+
const session = await getSession();
|
|
143
|
+
if (!session) return new Response("Unauthorized", { status: 401 });
|
|
144
|
+
|
|
145
|
+
const reason = new URL(request.url).searchParams.get("reason");
|
|
146
|
+
const token = await tokenFor(session, reason === "expired");
|
|
147
|
+
return new Response(token, { headers: { "content-type": "text/plain" } });
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Errors
|
|
152
|
+
|
|
153
|
+
Every failure throws a subclass of `WidgetAuthError` — never a partial result, and never a
|
|
154
|
+
token-shaped value. A service that returns a 500, an HTML error page, or a 200 with an
|
|
155
|
+
unusable body all raise an error rather than producing a token nobody issued.
|
|
156
|
+
|
|
157
|
+
| Class | Cause | Retry? |
|
|
158
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
159
|
+
| `WidgetAuthConfigError` | Running in a browser (see above), or bad arguments — missing `appId`/`appSecret`/base URL, over-long field, `ttlSeconds` outside `1..3600`. Thrown before any network call. | No |
|
|
160
|
+
| `WidgetAuthCredentialError` | HTTP 401. The secret is wrong, revoked, or for another app. | No |
|
|
161
|
+
| `WidgetAuthAppNotConfiguredError` | HTTP 409. The app isn't wired to its own widget issuer; the message names the fixing command. | No |
|
|
162
|
+
| `WidgetAuthRequestError` | HTTP 400. Usually means this package is out of date with the deployed API. | No |
|
|
163
|
+
| `WidgetAuthServiceError` | 5xx, an unexpected status, or an unusable response body. | `retryable` is `true` for 5xx |
|
|
164
|
+
| `WidgetAuthNetworkError` | The request never arrived — DNS, TLS, timeout, abort. Original error on `cause`. | Yes |
|
|
165
|
+
|
|
166
|
+
Each carries `status` (when there was one) and `retryable`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
try {
|
|
170
|
+
await signUserToken(user, config);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err instanceof WidgetAuthError && err.retryable) {
|
|
173
|
+
// transient — back off and try again
|
|
174
|
+
}
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
A 401 does not tell you _which_ of "wrong secret", "revoked key" or "app not enrolled" it
|
|
180
|
+
was. That is deliberate: the app id is caller-supplied, so a more specific answer would let
|
|
181
|
+
anyone probe which apps exist.
|
|
182
|
+
|
|
183
|
+
**The app secret is kept out of every error this package constructs** — its message, its
|
|
184
|
+
stack, and `JSON.stringify(err)`. Any text sourced from the service or from a transport
|
|
185
|
+
error is redacted first, and `cause` is non-enumerable so a structured logger cannot drag
|
|
186
|
+
a transport error's request dump into a log line.
|
|
187
|
+
|
|
188
|
+
One gap worth knowing, because it is outside what this package controls:
|
|
189
|
+
`console.error(err)` in Node prints the **cause chain** via `util.inspect`, which reaches
|
|
190
|
+
the non-enumerable `cause`. If your `fetch` implementation puts the secret in its own
|
|
191
|
+
error's message, that text is not redacted. Log `err.message` rather than `err` if your
|
|
192
|
+
aggregator ingests raw error objects.
|
|
193
|
+
|
|
194
|
+
## What the platform sets, and you must not
|
|
195
|
+
|
|
196
|
+
The token's claims are assembled **server-side**. Do not re-implement any of this:
|
|
197
|
+
|
|
198
|
+
- **`iat` and `nbf` are backdated 60 seconds.** The agent verifies with zero clock
|
|
199
|
+
tolerance, so this absorbs ordinary drift. `exp` is measured from the real current time,
|
|
200
|
+
not from the backdated `iat`, so the headroom costs no usable life. Backdating again on
|
|
201
|
+
top of it would not shorten anything — it would widen the acceptance window, so the
|
|
202
|
+
60-second skew margin quietly stops meaning 60 seconds, and `iat` would misstate the
|
|
203
|
+
token's age to anything that reads it.
|
|
204
|
+
- **`exp`** defaults to 15 minutes; `ttlSeconds` may set `1..3600`. Shorter is safer — the
|
|
205
|
+
token is a bearer credential held in a browser, and the widget re-requests one when it
|
|
206
|
+
expires. It is not a session length.
|
|
207
|
+
- **`iss`** is your app's canonical issuer, and **`aud`** is your app's configured audience.
|
|
208
|
+
|
|
209
|
+
Attempting to send any of them is rejected by the signer outright.
|
|
210
|
+
|
|
211
|
+
## API
|
|
212
|
+
|
|
213
|
+
### `signUserToken(user, config): Promise<WidgetToken>`
|
|
214
|
+
|
|
215
|
+
**`user`**
|
|
216
|
+
|
|
217
|
+
| Field | Type | |
|
|
218
|
+
| -------- | -------- | ---------------------------------------------------------------------- |
|
|
219
|
+
| `userId` | `string` | **Required.** Your stable id for this user; becomes the token's `sub`. |
|
|
220
|
+
| `name` | `string` | Optional. Display name. |
|
|
221
|
+
| `email` | `string` | Optional. |
|
|
222
|
+
|
|
223
|
+
`userId` must be **stable for the life of the account** — it is what ties a returning
|
|
224
|
+
visitor to their existing conversations, memory and files. A value that changes (a session
|
|
225
|
+
id, an editable email) silently orphans all of it and the user reappears as a stranger.
|
|
226
|
+
|
|
227
|
+
**`config`**
|
|
228
|
+
|
|
229
|
+
| Field | Type | |
|
|
230
|
+
| ------------ | ------------- | ------------------------------------------------------------------------------------------- |
|
|
231
|
+
| `appId` | `string` | **Required.** |
|
|
232
|
+
| `appSecret` | `string` | **Required.** Server-side only. |
|
|
233
|
+
| `apiBaseUrl` | `string` | Defaults to `GUUEY_API_URL`. No compiled-in default — the API base differs per environment. |
|
|
234
|
+
| `ttlSeconds` | `number` | `1..3600`. Defaults to the service's 15 minutes. |
|
|
235
|
+
| `signal` | `AbortSignal` | Aborts the request. |
|
|
236
|
+
| `fetch` | `FetchLike` | Override the HTTP client. For tests. |
|
|
237
|
+
|
|
238
|
+
**Returns `WidgetToken`**
|
|
239
|
+
|
|
240
|
+
| Field | Type | |
|
|
241
|
+
| ---------------- | -------- | ----------------------------------------------- |
|
|
242
|
+
| `token` | `string` | The signed JWT to hand to the widget. |
|
|
243
|
+
| `expiresAtEpoch` | `number` | Unix epoch seconds. Use it to drive your cache. |
|
|
244
|
+
| `issuer` | `string` | The issuer that signed it. |
|
|
245
|
+
| `kid` | `string` | The signing key's id. |
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error taxonomy for `@guuey/widget-auth`.
|
|
3
|
+
*
|
|
4
|
+
* Every failure is one of these, and every one of them is a subclass of
|
|
5
|
+
* {@link WidgetAuthError} — so a caller that only wants "did minting fail?" needs
|
|
6
|
+
* one `catch` clause, while a caller that wants to react differently to a bad
|
|
7
|
+
* secret than to a transient outage can switch on the class.
|
|
8
|
+
*
|
|
9
|
+
* ## Two invariants this file exists to hold
|
|
10
|
+
*
|
|
11
|
+
* **1. A failure is never a token.** Every path here throws. There is no
|
|
12
|
+
* "degraded" return value, no empty-string token, no partially-populated result:
|
|
13
|
+
* a token handed to a browser is a bearer credential, and one minted from a
|
|
14
|
+
* misread 500 response would be a credential nobody issued.
|
|
15
|
+
*
|
|
16
|
+
* **2. The app secret never reaches an error.** It is a server-side credential
|
|
17
|
+
* whose entire value is that it exists in exactly one place, and an exception is
|
|
18
|
+
* the most likely way for it to escape into a log aggregator. Errors therefore
|
|
19
|
+
* carry only a status, a retryability flag and a message — and any message
|
|
20
|
+
* sourced from outside this package (the service's response, a transport
|
|
21
|
+
* error's text) is passed through {@link redactSecret} first, so the property
|
|
22
|
+
* holds structurally rather than by trusting the other end.
|
|
23
|
+
*
|
|
24
|
+
* `cause` is attached with the `Error` options form deliberately: that makes it
|
|
25
|
+
* non-enumerable, so a structured logger serializing the error cannot drag a
|
|
26
|
+
* transport error's request dump — headers included — into the log line.
|
|
27
|
+
*
|
|
28
|
+
* **The honest bound on that claim.** It covers what this package constructs:
|
|
29
|
+
* the message, the stack, and `JSON.stringify`. It does NOT cover
|
|
30
|
+
* `console.error(err)`, which in Node prints the cause chain through
|
|
31
|
+
* `util.inspect` and reaches the non-enumerable `cause` — so a `fetch`
|
|
32
|
+
* implementation that puts the secret in its OWN error's message escapes
|
|
33
|
+
* redaction by that path. Closing it means wrapping the cause in a redacted
|
|
34
|
+
* copy, which costs the caller the original error object (`cause.code`,
|
|
35
|
+
* `instanceof`); that trade is worth making deliberately rather than as a side
|
|
36
|
+
* effect, so it is documented here and in the README instead of half-done.
|
|
37
|
+
*/
|
|
38
|
+
/** What a caller may do about a failure, without parsing messages. */
|
|
39
|
+
export declare abstract class WidgetAuthError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* The HTTP status the token service returned, or `undefined` when the request
|
|
42
|
+
* never got that far (bad configuration, a transport failure).
|
|
43
|
+
*/
|
|
44
|
+
readonly status: number | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Whether retrying the SAME call with backoff could plausibly succeed.
|
|
47
|
+
*
|
|
48
|
+
* `false` for anything the caller must change first — a wrong secret, an app
|
|
49
|
+
* that is not wired to its issuer, a malformed request. Retrying those just
|
|
50
|
+
* burns the shared rate-limit bucket.
|
|
51
|
+
*/
|
|
52
|
+
readonly retryable: boolean;
|
|
53
|
+
protected constructor(message: string, retryable: boolean, status?: number, options?: {
|
|
54
|
+
cause?: unknown;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The call was wrong before it was ever sent — a missing or malformed `appId`,
|
|
59
|
+
* `appSecret`, `apiBaseUrl`, `userId`, or a `ttlSeconds` outside `1..3600`.
|
|
60
|
+
*
|
|
61
|
+
* Thrown before any network call, so a configuration mistake costs nothing and
|
|
62
|
+
* cannot be mistaken for a service outage.
|
|
63
|
+
*/
|
|
64
|
+
export declare class WidgetAuthConfigError extends WidgetAuthError {
|
|
65
|
+
constructor(message: string);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* HTTP 401 — the app secret was not accepted.
|
|
69
|
+
*
|
|
70
|
+
* **Deliberately indistinguishable**: a wrong secret, a revoked key and an app
|
|
71
|
+
* that was never enrolled all produce this one error, because the `appId` is
|
|
72
|
+
* caller-supplied and anything finer would turn the route into an oracle for
|
|
73
|
+
* which apps exist. So "which of the three is it?" is a question this error
|
|
74
|
+
* cannot answer by design — check the secret first, then the key's status with
|
|
75
|
+
* `guuey widget keys`.
|
|
76
|
+
*/
|
|
77
|
+
export declare class WidgetAuthCredentialError extends WidgetAuthError {
|
|
78
|
+
constructor(message: string, status: number);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* HTTP 409 — the secret was accepted, but the app is not wired to its own widget
|
|
82
|
+
* issuer, so a token minted here would be rejected by the app that received it.
|
|
83
|
+
*
|
|
84
|
+
* The message names the command that repairs it. A correctly-onboarded app never
|
|
85
|
+
* reaches this: `guuey widget keys create --audience` wires the binding in the
|
|
86
|
+
* same ceremony that mints the secret.
|
|
87
|
+
*/
|
|
88
|
+
export declare class WidgetAuthAppNotConfiguredError extends WidgetAuthError {
|
|
89
|
+
constructor(message: string, status: number);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* HTTP 400 — the token service rejected the request body.
|
|
93
|
+
*
|
|
94
|
+
* This package validates every field the service validates, so in normal use
|
|
95
|
+
* this is unreachable; seeing it means either a version skew between this
|
|
96
|
+
* package and the deployed service, or a bug here. Either way it is not
|
|
97
|
+
* something a retry fixes.
|
|
98
|
+
*/
|
|
99
|
+
export declare class WidgetAuthRequestError extends WidgetAuthError {
|
|
100
|
+
constructor(message: string, status: number);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The service failed, or answered with something this package cannot use — a
|
|
104
|
+
* 5xx, an unexpected status, a body that is not JSON, or a 200 whose shape is
|
|
105
|
+
* not a minted token.
|
|
106
|
+
*
|
|
107
|
+
* `retryable` is `true` only for 5xx. A 200 with an unusable body is NOT
|
|
108
|
+
* retryable: the request succeeded and the answer was wrong, which a retry
|
|
109
|
+
* reproduces.
|
|
110
|
+
*/
|
|
111
|
+
export declare class WidgetAuthServiceError extends WidgetAuthError {
|
|
112
|
+
constructor(message: string, status: number | undefined, retryable: boolean, options?: {
|
|
113
|
+
cause?: unknown;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The request never reached the token service — DNS, TCP, TLS, a timeout, or an
|
|
118
|
+
* aborted `AbortSignal`.
|
|
119
|
+
*
|
|
120
|
+
* The underlying error is attached as `cause` (non-enumerable, so it does not
|
|
121
|
+
* land in a serialized log line) and its text is redacted into the message.
|
|
122
|
+
*/
|
|
123
|
+
export declare class WidgetAuthNetworkError extends WidgetAuthError {
|
|
124
|
+
constructor(message: string, options?: {
|
|
125
|
+
cause?: unknown;
|
|
126
|
+
retryable?: boolean;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Was this transport failure the CALLER cancelling the request?
|
|
131
|
+
*
|
|
132
|
+
* A caller-initiated abort is the one transport failure that must not be
|
|
133
|
+
* `retryable`: the integrator asked for this mint to stop, and a retry loop
|
|
134
|
+
* keyed on `retryable` would re-issue the very call they abandoned — spending
|
|
135
|
+
* a mint (and a rate-limit slot) against their own intent. Everything else
|
|
136
|
+
* here — DNS, TCP, TLS, a timeout — genuinely may succeed on a second try.
|
|
137
|
+
*
|
|
138
|
+
* Detected structurally rather than by message text: the DOM standard's
|
|
139
|
+
* `AbortError` name is what `fetch` rejects with on `signal.abort()`, and
|
|
140
|
+
* undici/Node use the same name.
|
|
141
|
+
*/
|
|
142
|
+
export declare function isAbortError(err: unknown): boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Replace every occurrence of the app secret with `[redacted]`.
|
|
145
|
+
*
|
|
146
|
+
* Applied to every string that enters an error message from outside this
|
|
147
|
+
* package. The token service never echoes the secret and a transport error
|
|
148
|
+
* usually does not either — but "usually" is not a security property, and
|
|
149
|
+
* `fetch` implementations that quote the failing request with its headers do
|
|
150
|
+
* exist. One cheap pass makes the guarantee structural.
|
|
151
|
+
*
|
|
152
|
+
* A short or empty secret is ignored rather than replaced: redacting a 1-char
|
|
153
|
+
* string would corrupt the message without protecting anything, and only a
|
|
154
|
+
* well-formed secret is long enough to be worth hiding.
|
|
155
|
+
*/
|
|
156
|
+
export declare function redactSecret(text: string, secret: string): string;
|
|
157
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,sEAAsE;AACtE,8BAAsB,eAAgB,SAAQ,KAAK;IACjD;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAEpC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAE5B,SAAS,aACP,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,OAAO,EAClB,MAAM,CAAC,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAOhC;AAED;;;;;;GAMG;AACH,qBAAa,qBAAsB,SAAQ,eAAe;gBAC5C,OAAO,EAAE,MAAM;CAG5B;AAED;;;;;;;;;GASG;AACH,qBAAa,yBAA0B,SAAQ,eAAe;gBAChD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAG5C;AAED;;;;;;;GAOG;AACH,qBAAa,+BAAgC,SAAQ,eAAe;gBACtD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAG5C;AAED;;;;;;;GAOG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAG5C;AAED;;;;;;;;GAQG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAEvD,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,SAAS,EAAE,OAAO,EAClB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAIhC;AAED;;;;;;GAMG;AACH,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE;CAGhF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAOlD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGjE"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error taxonomy for `@guuey/widget-auth`.
|
|
3
|
+
*
|
|
4
|
+
* Every failure is one of these, and every one of them is a subclass of
|
|
5
|
+
* {@link WidgetAuthError} — so a caller that only wants "did minting fail?" needs
|
|
6
|
+
* one `catch` clause, while a caller that wants to react differently to a bad
|
|
7
|
+
* secret than to a transient outage can switch on the class.
|
|
8
|
+
*
|
|
9
|
+
* ## Two invariants this file exists to hold
|
|
10
|
+
*
|
|
11
|
+
* **1. A failure is never a token.** Every path here throws. There is no
|
|
12
|
+
* "degraded" return value, no empty-string token, no partially-populated result:
|
|
13
|
+
* a token handed to a browser is a bearer credential, and one minted from a
|
|
14
|
+
* misread 500 response would be a credential nobody issued.
|
|
15
|
+
*
|
|
16
|
+
* **2. The app secret never reaches an error.** It is a server-side credential
|
|
17
|
+
* whose entire value is that it exists in exactly one place, and an exception is
|
|
18
|
+
* the most likely way for it to escape into a log aggregator. Errors therefore
|
|
19
|
+
* carry only a status, a retryability flag and a message — and any message
|
|
20
|
+
* sourced from outside this package (the service's response, a transport
|
|
21
|
+
* error's text) is passed through {@link redactSecret} first, so the property
|
|
22
|
+
* holds structurally rather than by trusting the other end.
|
|
23
|
+
*
|
|
24
|
+
* `cause` is attached with the `Error` options form deliberately: that makes it
|
|
25
|
+
* non-enumerable, so a structured logger serializing the error cannot drag a
|
|
26
|
+
* transport error's request dump — headers included — into the log line.
|
|
27
|
+
*
|
|
28
|
+
* **The honest bound on that claim.** It covers what this package constructs:
|
|
29
|
+
* the message, the stack, and `JSON.stringify`. It does NOT cover
|
|
30
|
+
* `console.error(err)`, which in Node prints the cause chain through
|
|
31
|
+
* `util.inspect` and reaches the non-enumerable `cause` — so a `fetch`
|
|
32
|
+
* implementation that puts the secret in its OWN error's message escapes
|
|
33
|
+
* redaction by that path. Closing it means wrapping the cause in a redacted
|
|
34
|
+
* copy, which costs the caller the original error object (`cause.code`,
|
|
35
|
+
* `instanceof`); that trade is worth making deliberately rather than as a side
|
|
36
|
+
* effect, so it is documented here and in the README instead of half-done.
|
|
37
|
+
*/
|
|
38
|
+
/** What a caller may do about a failure, without parsing messages. */
|
|
39
|
+
export class WidgetAuthError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* The HTTP status the token service returned, or `undefined` when the request
|
|
42
|
+
* never got that far (bad configuration, a transport failure).
|
|
43
|
+
*/
|
|
44
|
+
status;
|
|
45
|
+
/**
|
|
46
|
+
* Whether retrying the SAME call with backoff could plausibly succeed.
|
|
47
|
+
*
|
|
48
|
+
* `false` for anything the caller must change first — a wrong secret, an app
|
|
49
|
+
* that is not wired to its issuer, a malformed request. Retrying those just
|
|
50
|
+
* burns the shared rate-limit bucket.
|
|
51
|
+
*/
|
|
52
|
+
retryable;
|
|
53
|
+
constructor(message, retryable, status, options) {
|
|
54
|
+
super(message, options);
|
|
55
|
+
this.name = new.target.name;
|
|
56
|
+
this.retryable = retryable;
|
|
57
|
+
this.status = status;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The call was wrong before it was ever sent — a missing or malformed `appId`,
|
|
62
|
+
* `appSecret`, `apiBaseUrl`, `userId`, or a `ttlSeconds` outside `1..3600`.
|
|
63
|
+
*
|
|
64
|
+
* Thrown before any network call, so a configuration mistake costs nothing and
|
|
65
|
+
* cannot be mistaken for a service outage.
|
|
66
|
+
*/
|
|
67
|
+
export class WidgetAuthConfigError extends WidgetAuthError {
|
|
68
|
+
constructor(message) {
|
|
69
|
+
super(message, false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* HTTP 401 — the app secret was not accepted.
|
|
74
|
+
*
|
|
75
|
+
* **Deliberately indistinguishable**: a wrong secret, a revoked key and an app
|
|
76
|
+
* that was never enrolled all produce this one error, because the `appId` is
|
|
77
|
+
* caller-supplied and anything finer would turn the route into an oracle for
|
|
78
|
+
* which apps exist. So "which of the three is it?" is a question this error
|
|
79
|
+
* cannot answer by design — check the secret first, then the key's status with
|
|
80
|
+
* `guuey widget keys`.
|
|
81
|
+
*/
|
|
82
|
+
export class WidgetAuthCredentialError extends WidgetAuthError {
|
|
83
|
+
constructor(message, status) {
|
|
84
|
+
super(message, false, status);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* HTTP 409 — the secret was accepted, but the app is not wired to its own widget
|
|
89
|
+
* issuer, so a token minted here would be rejected by the app that received it.
|
|
90
|
+
*
|
|
91
|
+
* The message names the command that repairs it. A correctly-onboarded app never
|
|
92
|
+
* reaches this: `guuey widget keys create --audience` wires the binding in the
|
|
93
|
+
* same ceremony that mints the secret.
|
|
94
|
+
*/
|
|
95
|
+
export class WidgetAuthAppNotConfiguredError extends WidgetAuthError {
|
|
96
|
+
constructor(message, status) {
|
|
97
|
+
super(message, false, status);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* HTTP 400 — the token service rejected the request body.
|
|
102
|
+
*
|
|
103
|
+
* This package validates every field the service validates, so in normal use
|
|
104
|
+
* this is unreachable; seeing it means either a version skew between this
|
|
105
|
+
* package and the deployed service, or a bug here. Either way it is not
|
|
106
|
+
* something a retry fixes.
|
|
107
|
+
*/
|
|
108
|
+
export class WidgetAuthRequestError extends WidgetAuthError {
|
|
109
|
+
constructor(message, status) {
|
|
110
|
+
super(message, false, status);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The service failed, or answered with something this package cannot use — a
|
|
115
|
+
* 5xx, an unexpected status, a body that is not JSON, or a 200 whose shape is
|
|
116
|
+
* not a minted token.
|
|
117
|
+
*
|
|
118
|
+
* `retryable` is `true` only for 5xx. A 200 with an unusable body is NOT
|
|
119
|
+
* retryable: the request succeeded and the answer was wrong, which a retry
|
|
120
|
+
* reproduces.
|
|
121
|
+
*/
|
|
122
|
+
export class WidgetAuthServiceError extends WidgetAuthError {
|
|
123
|
+
constructor(message, status, retryable, options) {
|
|
124
|
+
super(message, retryable, status, options);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The request never reached the token service — DNS, TCP, TLS, a timeout, or an
|
|
129
|
+
* aborted `AbortSignal`.
|
|
130
|
+
*
|
|
131
|
+
* The underlying error is attached as `cause` (non-enumerable, so it does not
|
|
132
|
+
* land in a serialized log line) and its text is redacted into the message.
|
|
133
|
+
*/
|
|
134
|
+
export class WidgetAuthNetworkError extends WidgetAuthError {
|
|
135
|
+
constructor(message, options) {
|
|
136
|
+
super(message, options?.retryable ?? true, undefined, options);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Was this transport failure the CALLER cancelling the request?
|
|
141
|
+
*
|
|
142
|
+
* A caller-initiated abort is the one transport failure that must not be
|
|
143
|
+
* `retryable`: the integrator asked for this mint to stop, and a retry loop
|
|
144
|
+
* keyed on `retryable` would re-issue the very call they abandoned — spending
|
|
145
|
+
* a mint (and a rate-limit slot) against their own intent. Everything else
|
|
146
|
+
* here — DNS, TCP, TLS, a timeout — genuinely may succeed on a second try.
|
|
147
|
+
*
|
|
148
|
+
* Detected structurally rather than by message text: the DOM standard's
|
|
149
|
+
* `AbortError` name is what `fetch` rejects with on `signal.abort()`, and
|
|
150
|
+
* undici/Node use the same name.
|
|
151
|
+
*/
|
|
152
|
+
export function isAbortError(err) {
|
|
153
|
+
return (typeof err === 'object' &&
|
|
154
|
+
err !== null &&
|
|
155
|
+
'name' in err &&
|
|
156
|
+
err.name === 'AbortError');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Replace every occurrence of the app secret with `[redacted]`.
|
|
160
|
+
*
|
|
161
|
+
* Applied to every string that enters an error message from outside this
|
|
162
|
+
* package. The token service never echoes the secret and a transport error
|
|
163
|
+
* usually does not either — but "usually" is not a security property, and
|
|
164
|
+
* `fetch` implementations that quote the failing request with its headers do
|
|
165
|
+
* exist. One cheap pass makes the guarantee structural.
|
|
166
|
+
*
|
|
167
|
+
* A short or empty secret is ignored rather than replaced: redacting a 1-char
|
|
168
|
+
* string would corrupt the message without protecting anything, and only a
|
|
169
|
+
* well-formed secret is long enough to be worth hiding.
|
|
170
|
+
*/
|
|
171
|
+
export function redactSecret(text, secret) {
|
|
172
|
+
if (secret.length < 8)
|
|
173
|
+
return text;
|
|
174
|
+
return text.split(secret).join('[redacted]');
|
|
175
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@guuey/widget-auth` — mint end-user identity tokens for a guuey embeddable
|
|
3
|
+
* widget, from your own backend.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { signUserToken } from '@guuey/widget-auth';
|
|
7
|
+
*
|
|
8
|
+
* const { token, expiresAtEpoch } = await signUserToken(
|
|
9
|
+
* { userId: user.id, name: user.name, email: user.email },
|
|
10
|
+
* { appId: process.env.GUUEY_APP_ID!, appSecret: process.env.GUUEY_APP_SECRET! },
|
|
11
|
+
* );
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## What this package does NOT do, and why that matters
|
|
15
|
+
*
|
|
16
|
+
* It holds no key material and assembles no claims. The app's RSA private key
|
|
17
|
+
* lives sealed in the platform's KMS and never leaves it, so this package is a
|
|
18
|
+
* typed, validated HTTP call to the mint route and nothing more.
|
|
19
|
+
*
|
|
20
|
+
* In particular, **the token's time claims are assembled server-side and this
|
|
21
|
+
* package must never re-implement them.** The signer sets `iat` and `nbf`
|
|
22
|
+
* backdated 60 seconds — absorbing ordinary clock drift between the signer and
|
|
23
|
+
* the agent pod, which verifies with zero clock tolerance — and derives `exp`
|
|
24
|
+
* from the real current time, so the backdating buys verification headroom
|
|
25
|
+
* without shortening the token's usable life. It also sets `iss` from the app's
|
|
26
|
+
* canonical issuer string and `aud` from the app's own configuration. Sending
|
|
27
|
+
* any of those from here is rejected outright by the signer's strict claim
|
|
28
|
+
* parser, which allowlists exactly `sub`, `name` and `email` — so the rule is
|
|
29
|
+
* enforced by the other end rather than merely stated here.
|
|
30
|
+
*
|
|
31
|
+
* Were the backdate re-implemented here anyway, the damage would not be a
|
|
32
|
+
* shorter token: `exp` does not move, so nothing expires sooner. It would WIDEN
|
|
33
|
+
* the acceptance window — `nbf` slides another 60s into the past, so the
|
|
34
|
+
* skew margin silently stops being the 60 seconds it is documented as — and
|
|
35
|
+
* `iat` would misstate the token's age to every consumer that reads it.
|
|
36
|
+
*
|
|
37
|
+
* ## The app secret is a SERVER-side credential
|
|
38
|
+
*
|
|
39
|
+
* `appSecret` authorizes minting an identity for *any* user of your app. It must
|
|
40
|
+
* live only on your backend. If it reaches a browser — bundled into frontend
|
|
41
|
+
* code, or because this package was called from the client — anyone who reads it
|
|
42
|
+
* can mint a token for any of your users, which is the entire threat model this
|
|
43
|
+
* design exists to prevent. That is why the widget asks *your* server for a
|
|
44
|
+
* token rather than minting one itself.
|
|
45
|
+
*/
|
|
46
|
+
import { WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError } from './errors.js';
|
|
47
|
+
export { WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError, };
|
|
48
|
+
/** The end-user a token is being minted for. */
|
|
49
|
+
export interface WidgetUser {
|
|
50
|
+
/**
|
|
51
|
+
* Your stable identifier for this user — it becomes the token's `sub`, and the
|
|
52
|
+
* platform derives the user's durable widget identity from it.
|
|
53
|
+
*
|
|
54
|
+
* It must be stable for the life of the account: it is what ties a returning
|
|
55
|
+
* visitor to their existing conversations, memory and files. A value that
|
|
56
|
+
* changes (a session id, an email that can be edited) silently orphans all of
|
|
57
|
+
* it and the user reappears as a stranger.
|
|
58
|
+
*/
|
|
59
|
+
userId: string;
|
|
60
|
+
/** Display name, shown in the widget. Optional. */
|
|
61
|
+
name?: string;
|
|
62
|
+
/** Email, available to the agent. Optional. */
|
|
63
|
+
email?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Where to mint, and as whom. */
|
|
66
|
+
export interface WidgetAuthConfig {
|
|
67
|
+
/** The guuey app this token is for. */
|
|
68
|
+
appId: string;
|
|
69
|
+
/**
|
|
70
|
+
* The app secret from `guuey widget keys create`. **Server-side only** — see
|
|
71
|
+
* the module docblock.
|
|
72
|
+
*/
|
|
73
|
+
appSecret: string;
|
|
74
|
+
/**
|
|
75
|
+
* The guuey API base URL, e.g. `https://api.guuey.com`. Falls back to the
|
|
76
|
+
* `GUUEY_API_URL` environment variable.
|
|
77
|
+
*
|
|
78
|
+
* There is deliberately no compiled-in default: the API base differs per
|
|
79
|
+
* environment, and a wrong built-in default would fail in a way that looks
|
|
80
|
+
* like a credential problem rather than a configuration one.
|
|
81
|
+
*/
|
|
82
|
+
apiBaseUrl?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Token lifetime in seconds, `1..3600`. Defaults to the service's 15 minutes.
|
|
85
|
+
*
|
|
86
|
+
* Shorter is safer — the token is a bearer credential held in a browser, and
|
|
87
|
+
* the widget re-requests one from you when it expires. Prefer the default over
|
|
88
|
+
* a long TTL; it is not a session length.
|
|
89
|
+
*/
|
|
90
|
+
ttlSeconds?: number;
|
|
91
|
+
/** Aborts the request. */
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
/** Override the HTTP client. Intended for tests. */
|
|
94
|
+
fetch?: FetchLike;
|
|
95
|
+
}
|
|
96
|
+
/** A minted token, exactly as the mint route returns it. */
|
|
97
|
+
export interface WidgetToken {
|
|
98
|
+
/** The signed JWT to hand to the widget. */
|
|
99
|
+
token: string;
|
|
100
|
+
/** Unix epoch seconds at which `token` stops verifying. */
|
|
101
|
+
expiresAtEpoch: number;
|
|
102
|
+
/** The issuer that signed it. */
|
|
103
|
+
issuer: string;
|
|
104
|
+
/** The signing key's id. */
|
|
105
|
+
kid: string;
|
|
106
|
+
}
|
|
107
|
+
/** The request shape this package sends. */
|
|
108
|
+
export interface WidgetAuthRequestInit {
|
|
109
|
+
method: string;
|
|
110
|
+
headers: Record<string, string>;
|
|
111
|
+
body: string;
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
}
|
|
114
|
+
/** The part of a `fetch` response this package reads. */
|
|
115
|
+
export interface WidgetAuthFetchResponse {
|
|
116
|
+
status: number;
|
|
117
|
+
json(): Promise<unknown>;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The HTTP seam. Structurally satisfied by the global `fetch`, so overriding it
|
|
121
|
+
* is only needed in tests.
|
|
122
|
+
*/
|
|
123
|
+
export type FetchLike = (url: string, init: WidgetAuthRequestInit) => Promise<WidgetAuthFetchResponse>;
|
|
124
|
+
/**
|
|
125
|
+
* Mint an end-user token for your widget.
|
|
126
|
+
*
|
|
127
|
+
* Resolves with a {@link WidgetToken}, or **throws** — always a
|
|
128
|
+
* {@link WidgetAuthError} subclass, never a partially-formed result. See
|
|
129
|
+
* `errors.ts` for the taxonomy and which failures a retry can fix.
|
|
130
|
+
*
|
|
131
|
+
* @param user the end-user this token identifies
|
|
132
|
+
* @param config the app, its secret, and where to mint
|
|
133
|
+
*/
|
|
134
|
+
export declare function signUserToken(user: WidgetUser, config: WidgetAuthConfig): Promise<WidgetToken>;
|
|
135
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,EAGL,+BAA+B,EAC/B,qBAAqB,EACrB,yBAAyB,EACzB,eAAe,EACf,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,+BAA+B,EAC/B,qBAAqB,EACrB,yBAAyB,EACzB,eAAe,EACf,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,GACvB,CAAC;AA4BF,gDAAgD;AAChD,MAAM,WAAW,UAAU;IACzB;;;;;;;;OAQG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,kCAAkC;AAClC,MAAM,WAAW,gBAAgB;IAC/B,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAED,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IAC1B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,cAAc,EAAE,MAAM,CAAC;IACvB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,4BAA4B;IAC5B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,4CAA4C;AAC5C,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,yDAAyD;AACzD,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,qBAAqB,KACxB,OAAO,CAAC,uBAAuB,CAAC,CAAC;AAWtC;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,WAAW,CAAC,CAmEtB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@guuey/widget-auth` — mint end-user identity tokens for a guuey embeddable
|
|
3
|
+
* widget, from your own backend.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { signUserToken } from '@guuey/widget-auth';
|
|
7
|
+
*
|
|
8
|
+
* const { token, expiresAtEpoch } = await signUserToken(
|
|
9
|
+
* { userId: user.id, name: user.name, email: user.email },
|
|
10
|
+
* { appId: process.env.GUUEY_APP_ID!, appSecret: process.env.GUUEY_APP_SECRET! },
|
|
11
|
+
* );
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## What this package does NOT do, and why that matters
|
|
15
|
+
*
|
|
16
|
+
* It holds no key material and assembles no claims. The app's RSA private key
|
|
17
|
+
* lives sealed in the platform's KMS and never leaves it, so this package is a
|
|
18
|
+
* typed, validated HTTP call to the mint route and nothing more.
|
|
19
|
+
*
|
|
20
|
+
* In particular, **the token's time claims are assembled server-side and this
|
|
21
|
+
* package must never re-implement them.** The signer sets `iat` and `nbf`
|
|
22
|
+
* backdated 60 seconds — absorbing ordinary clock drift between the signer and
|
|
23
|
+
* the agent pod, which verifies with zero clock tolerance — and derives `exp`
|
|
24
|
+
* from the real current time, so the backdating buys verification headroom
|
|
25
|
+
* without shortening the token's usable life. It also sets `iss` from the app's
|
|
26
|
+
* canonical issuer string and `aud` from the app's own configuration. Sending
|
|
27
|
+
* any of those from here is rejected outright by the signer's strict claim
|
|
28
|
+
* parser, which allowlists exactly `sub`, `name` and `email` — so the rule is
|
|
29
|
+
* enforced by the other end rather than merely stated here.
|
|
30
|
+
*
|
|
31
|
+
* Were the backdate re-implemented here anyway, the damage would not be a
|
|
32
|
+
* shorter token: `exp` does not move, so nothing expires sooner. It would WIDEN
|
|
33
|
+
* the acceptance window — `nbf` slides another 60s into the past, so the
|
|
34
|
+
* skew margin silently stops being the 60 seconds it is documented as — and
|
|
35
|
+
* `iat` would misstate the token's age to every consumer that reads it.
|
|
36
|
+
*
|
|
37
|
+
* ## The app secret is a SERVER-side credential
|
|
38
|
+
*
|
|
39
|
+
* `appSecret` authorizes minting an identity for *any* user of your app. It must
|
|
40
|
+
* live only on your backend. If it reaches a browser — bundled into frontend
|
|
41
|
+
* code, or because this package was called from the client — anyone who reads it
|
|
42
|
+
* can mint a token for any of your users, which is the entire threat model this
|
|
43
|
+
* design exists to prevent. That is why the widget asks *your* server for a
|
|
44
|
+
* token rather than minting one itself.
|
|
45
|
+
*/
|
|
46
|
+
import { isAbortError, redactSecret, WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError, } from './errors.js';
|
|
47
|
+
export { WidgetAuthAppNotConfiguredError, WidgetAuthConfigError, WidgetAuthCredentialError, WidgetAuthError, WidgetAuthNetworkError, WidgetAuthRequestError, WidgetAuthServiceError, };
|
|
48
|
+
/** The credential family the mint route accepts. */
|
|
49
|
+
const SECRET_PREFIX = 'guuey_widget_';
|
|
50
|
+
/** The mint route's path, appended to the resolved API base URL. */
|
|
51
|
+
const MINT_PATH = '/v1/widget/token';
|
|
52
|
+
/**
|
|
53
|
+
* Field caps, mirroring the token service's own.
|
|
54
|
+
*
|
|
55
|
+
* They exist server-side because KMS refuses to sign a message over 4096 bytes
|
|
56
|
+
* and these fields are caller-controlled. Mirroring them here turns a round trip
|
|
57
|
+
* that ends in a 400 into an immediate, local error naming the field — and the
|
|
58
|
+
* service still enforces them, so a skew between this package and a newer
|
|
59
|
+
* deployment fails safe rather than silently.
|
|
60
|
+
*/
|
|
61
|
+
const MAX_USER_ID_LENGTH = 256;
|
|
62
|
+
const MAX_NAME_LENGTH = 256;
|
|
63
|
+
const MAX_EMAIL_LENGTH = 320;
|
|
64
|
+
/** TTL bounds, mirroring the service. The default (900s) is the service's. */
|
|
65
|
+
const MIN_TTL_SECONDS = 1;
|
|
66
|
+
const MAX_TTL_SECONDS = 3600;
|
|
67
|
+
/** How much of a service-supplied message to keep in an error. */
|
|
68
|
+
const MAX_SERVICE_MESSAGE_LENGTH = 400;
|
|
69
|
+
/**
|
|
70
|
+
* Mint an end-user token for your widget.
|
|
71
|
+
*
|
|
72
|
+
* Resolves with a {@link WidgetToken}, or **throws** — always a
|
|
73
|
+
* {@link WidgetAuthError} subclass, never a partially-formed result. See
|
|
74
|
+
* `errors.ts` for the taxonomy and which failures a retry can fix.
|
|
75
|
+
*
|
|
76
|
+
* @param user the end-user this token identifies
|
|
77
|
+
* @param config the app, its secret, and where to mint
|
|
78
|
+
*/
|
|
79
|
+
export async function signUserToken(user, config) {
|
|
80
|
+
// FIRST, before any other check. Running in a browser is not one
|
|
81
|
+
// misconfiguration among several — it means the secret is already in a
|
|
82
|
+
// shipped bundle, and saying "appId is required" to someone in that position
|
|
83
|
+
// would be answering the wrong question.
|
|
84
|
+
assertNotBrowser();
|
|
85
|
+
const { appId, appSecret } = config;
|
|
86
|
+
// Validate everything before touching the network: a configuration mistake
|
|
87
|
+
// should cost nothing, name its own field, and never be confusable with an
|
|
88
|
+
// outage or a rejected credential.
|
|
89
|
+
requireNonEmpty(appId, 'appId');
|
|
90
|
+
requireNonEmpty(appSecret, 'appSecret');
|
|
91
|
+
if (!appSecret.startsWith(SECRET_PREFIX)) {
|
|
92
|
+
// Checked locally so a credential from another family is never transmitted
|
|
93
|
+
// at all — and because the service's answer would be the deliberately
|
|
94
|
+
// uninformative 401, which would send the reader hunting for the wrong bug.
|
|
95
|
+
throw new WidgetAuthConfigError(`appSecret does not look like a widget app secret (expected it to start with "${SECRET_PREFIX}"). ` +
|
|
96
|
+
'Widget secrets come from `guuey widget keys create <appId>`; a personal access token or ' +
|
|
97
|
+
'workspace API key will not work on this route.');
|
|
98
|
+
}
|
|
99
|
+
const baseUrl = resolveBaseUrl(config.apiBaseUrl);
|
|
100
|
+
const body = buildBody(appId, user, config.ttlSeconds);
|
|
101
|
+
const doFetch = resolveFetch(config.fetch);
|
|
102
|
+
const init = {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
authorization: `Bearer ${appSecret}`,
|
|
106
|
+
'content-type': 'application/json',
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
};
|
|
110
|
+
if (config.signal !== undefined)
|
|
111
|
+
init.signal = config.signal;
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await doFetch(`${baseUrl}${MINT_PATH}`, init);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// A caller-initiated abort is NOT retryable — see `isAbortError`.
|
|
118
|
+
const aborted = isAbortError(err);
|
|
119
|
+
throw new WidgetAuthNetworkError(aborted
|
|
120
|
+
? `The mint request to ${baseUrl} was aborted by the caller.`
|
|
121
|
+
: `Could not reach the guuey token service at ${baseUrl}: ${describe(err, appSecret)}`, { cause: err, retryable: !aborted });
|
|
122
|
+
}
|
|
123
|
+
// A body that is not JSON is a fact about the response, not an exception:
|
|
124
|
+
// a proxy's HTML error page is a perfectly ordinary way for this to fail, and
|
|
125
|
+
// it must map onto the taxonomy rather than escaping as a SyntaxError.
|
|
126
|
+
let payload;
|
|
127
|
+
try {
|
|
128
|
+
payload = await response.json();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
payload = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (response.status !== 200) {
|
|
134
|
+
throw mapFailure(response.status, payload, appSecret);
|
|
135
|
+
}
|
|
136
|
+
return readToken(payload);
|
|
137
|
+
}
|
|
138
|
+
// ─── Validation ────────────────────────────────────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* Refuse to run in a browser.
|
|
141
|
+
*
|
|
142
|
+
* This package's entire threat model is that `appSecret` never leaves the
|
|
143
|
+
* server. Everything else here — the redaction, the non-enumerable `cause`, the
|
|
144
|
+
* never-a-garbage-token property — defends a secret that is already in the right
|
|
145
|
+
* place. None of it helps if the secret was bundled into a page, where every
|
|
146
|
+
* visitor can read it and mint an identity for any of your users. That is the
|
|
147
|
+
* one failure this package can detect itself, so it does, loudly, instead of
|
|
148
|
+
* documenting it and hoping.
|
|
149
|
+
*
|
|
150
|
+
* **Why BOTH `window` and `document`, and not either alone.** Edge runtimes
|
|
151
|
+
* (Cloudflare Workers, Vercel Edge, Deno Deploy) are legitimate places to mint
|
|
152
|
+
* from, and some of them define a `window`-ish global while defining no `document`
|
|
153
|
+
* — testing `window` alone would refuse a correct deployment. A DOM is the thing
|
|
154
|
+
* that actually distinguishes a page from a server-side JS runtime, so the pair
|
|
155
|
+
* is the discriminator and the conjunction is deliberate, not defensive noise.
|
|
156
|
+
*
|
|
157
|
+
* Called from every exported entry path. There is one today; anything added
|
|
158
|
+
* later calls this first, for the same reason.
|
|
159
|
+
*
|
|
160
|
+
* Probed through `globalThis` by name rather than written as
|
|
161
|
+
* `typeof window !== 'undefined'` because `lib` is pinned to `["ES2022"]` with
|
|
162
|
+
* no `dom`, so those identifiers do not exist for the compiler. Adding `dom`
|
|
163
|
+
* to satisfy the check would declare browser globals throughout a package whose
|
|
164
|
+
* whole point is that it does not run in one — the wrong fix. Comparing the
|
|
165
|
+
* value against `undefined` (rather than testing key presence) keeps the
|
|
166
|
+
* `typeof` semantics: a runtime that defines `globalThis.window = undefined` is
|
|
167
|
+
* not a browser and must not be refused.
|
|
168
|
+
*/
|
|
169
|
+
function globalIsDefined(name) {
|
|
170
|
+
return Reflect.get(globalThis, name) !== undefined;
|
|
171
|
+
}
|
|
172
|
+
function assertNotBrowser() {
|
|
173
|
+
if (globalIsDefined('window') && globalIsDefined('document')) {
|
|
174
|
+
throw new WidgetAuthConfigError('@guuey/widget-auth ran in a browser, and refused. Your app secret authorizes minting ' +
|
|
175
|
+
'an identity for ANY user of your app, so if this code reached a browser the secret is ' +
|
|
176
|
+
'in your shipped bundle and every visitor can read it — rotate it now with ' +
|
|
177
|
+
'`guuey widget keys rotate <appId> --new-secret`. Mint on your server instead and hand ' +
|
|
178
|
+
'the returned token to the widget: that is what the widget asks your backend for a ' +
|
|
179
|
+
'token rather than minting one itself.');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function requireNonEmpty(value, field) {
|
|
183
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
184
|
+
throw new WidgetAuthConfigError(`${field} is required.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function requireWithin(value, max, field) {
|
|
188
|
+
if (value.length > max) {
|
|
189
|
+
throw new WidgetAuthConfigError(`${field} must be ${max} characters or less (got ${value.length}).`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the API base, trimming trailing slashes so callers can pass either
|
|
194
|
+
* form without producing a `//v1/...` path.
|
|
195
|
+
*/
|
|
196
|
+
function resolveBaseUrl(configured) {
|
|
197
|
+
const raw = configured ?? readEnv('GUUEY_API_URL');
|
|
198
|
+
if (raw === undefined || raw.length === 0) {
|
|
199
|
+
throw new WidgetAuthConfigError('No guuey API base URL. Pass `apiBaseUrl` or set the GUUEY_API_URL environment variable ' +
|
|
200
|
+
'(for example https://api.guuey.com).');
|
|
201
|
+
}
|
|
202
|
+
return raw.replace(/\/+$/, '');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Read an environment variable without assuming a Node-shaped global.
|
|
206
|
+
*
|
|
207
|
+
* The tolerance is for EDGE RUNTIMES — Cloudflare Workers, Vercel Edge, Deno
|
|
208
|
+
* Deploy — which are legitimate places to mint from and may not expose
|
|
209
|
+
* `process`. It is emphatically not tolerance for browsers, which
|
|
210
|
+
* {@link assertNotBrowser} refuses outright; an earlier version of this comment
|
|
211
|
+
* said "edge or browser bundle", which read as sanctioning exactly the
|
|
212
|
+
* deployment this package exists to prevent.
|
|
213
|
+
*/
|
|
214
|
+
function readEnv(name) {
|
|
215
|
+
if (typeof process === 'undefined')
|
|
216
|
+
return undefined;
|
|
217
|
+
return process.env?.[name];
|
|
218
|
+
}
|
|
219
|
+
function buildBody(appId, user, ttlSeconds) {
|
|
220
|
+
requireNonEmpty(user.userId, 'userId');
|
|
221
|
+
requireWithin(user.userId, MAX_USER_ID_LENGTH, 'userId');
|
|
222
|
+
const body = { appId, userId: user.userId };
|
|
223
|
+
if (user.name !== undefined) {
|
|
224
|
+
requireWithin(user.name, MAX_NAME_LENGTH, 'name');
|
|
225
|
+
body.name = user.name;
|
|
226
|
+
}
|
|
227
|
+
if (user.email !== undefined) {
|
|
228
|
+
requireWithin(user.email, MAX_EMAIL_LENGTH, 'email');
|
|
229
|
+
body.email = user.email;
|
|
230
|
+
}
|
|
231
|
+
if (ttlSeconds !== undefined) {
|
|
232
|
+
if (!Number.isInteger(ttlSeconds) ||
|
|
233
|
+
ttlSeconds < MIN_TTL_SECONDS ||
|
|
234
|
+
ttlSeconds > MAX_TTL_SECONDS) {
|
|
235
|
+
throw new WidgetAuthConfigError(`ttlSeconds must be a whole number of seconds between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS} (got ${ttlSeconds}).`);
|
|
236
|
+
}
|
|
237
|
+
body.ttlSeconds = ttlSeconds;
|
|
238
|
+
}
|
|
239
|
+
return body;
|
|
240
|
+
}
|
|
241
|
+
function resolveFetch(configured) {
|
|
242
|
+
if (configured !== undefined)
|
|
243
|
+
return configured;
|
|
244
|
+
if (typeof globalThis.fetch !== 'function') {
|
|
245
|
+
throw new WidgetAuthConfigError('No global fetch available. @guuey/widget-auth needs Node 18 or newer, or a `fetch` ' +
|
|
246
|
+
'implementation passed as `config.fetch`.');
|
|
247
|
+
}
|
|
248
|
+
const globalFetch = globalThis.fetch;
|
|
249
|
+
return (url, init) => globalFetch(url, init);
|
|
250
|
+
}
|
|
251
|
+
// ─── Response handling ─────────────────────────────────────────────────
|
|
252
|
+
/** Pull `error.message` out of the service's error envelope, if it has one. */
|
|
253
|
+
function serviceMessage(payload, secret) {
|
|
254
|
+
if (typeof payload !== 'object' || payload === null)
|
|
255
|
+
return undefined;
|
|
256
|
+
const error = payload.error;
|
|
257
|
+
if (typeof error !== 'object' || error === null)
|
|
258
|
+
return undefined;
|
|
259
|
+
const message = error.message;
|
|
260
|
+
if (typeof message !== 'string' || message.length === 0)
|
|
261
|
+
return undefined;
|
|
262
|
+
return truncate(redactSecret(message, secret), MAX_SERVICE_MESSAGE_LENGTH);
|
|
263
|
+
}
|
|
264
|
+
/** Describe a thrown value for a message, redacted and bounded. */
|
|
265
|
+
function describe(err, secret) {
|
|
266
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
267
|
+
return truncate(redactSecret(text, secret), MAX_SERVICE_MESSAGE_LENGTH);
|
|
268
|
+
}
|
|
269
|
+
function truncate(text, max) {
|
|
270
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
271
|
+
}
|
|
272
|
+
/** Map a non-200 onto the taxonomy. */
|
|
273
|
+
function mapFailure(status, payload, secret) {
|
|
274
|
+
const detail = serviceMessage(payload, secret);
|
|
275
|
+
const suffix = detail === undefined ? '' : ` ${detail}`;
|
|
276
|
+
if (status === 401) {
|
|
277
|
+
return new WidgetAuthCredentialError('The widget app secret was not accepted. It may be wrong, revoked, or for a different app — ' +
|
|
278
|
+
'these are deliberately indistinguishable, so that this route cannot be used to discover ' +
|
|
279
|
+
`which apps exist. Check the secret, then \`guuey widget keys\`.${suffix}`, status);
|
|
280
|
+
}
|
|
281
|
+
if (status === 409) {
|
|
282
|
+
return new WidgetAuthAppNotConfiguredError(`The app is not configured to accept tokens from its own widget issuer.${suffix}`, status);
|
|
283
|
+
}
|
|
284
|
+
if (status === 400) {
|
|
285
|
+
return new WidgetAuthRequestError('The token service rejected the request. @guuey/widget-auth validates these fields itself, ' +
|
|
286
|
+
`so this usually means it is out of date with the deployed API.${suffix}`, status);
|
|
287
|
+
}
|
|
288
|
+
if (status >= 500) {
|
|
289
|
+
return new WidgetAuthServiceError(`The guuey token service failed (HTTP ${status}). This is usually transient — retry with backoff.${suffix}`, status, true);
|
|
290
|
+
}
|
|
291
|
+
return new WidgetAuthServiceError(`Unexpected response from the guuey token service (HTTP ${status}).${suffix}`, status, false);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Validate a 200 body into a {@link WidgetToken}.
|
|
295
|
+
*
|
|
296
|
+
* Every field is checked before anything is returned. A 200 carrying a body this
|
|
297
|
+
* package cannot recognize is an ERROR, never a best-effort object: the caller
|
|
298
|
+
* hands the result to a browser, so a `token` that is `undefined`, empty, or a
|
|
299
|
+
* number would become an authentication failure surfacing far from its cause —
|
|
300
|
+
* or, worse, a value from a response nobody authenticated.
|
|
301
|
+
*/
|
|
302
|
+
function readToken(payload) {
|
|
303
|
+
const unusable = (why) => new WidgetAuthServiceError(`The guuey token service returned a response this package cannot use: ${why}. ` +
|
|
304
|
+
'No token was issued.', 200, false);
|
|
305
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
306
|
+
throw unusable('the body was not a JSON object');
|
|
307
|
+
}
|
|
308
|
+
const body = payload;
|
|
309
|
+
if (typeof body.token !== 'string' || body.token.length === 0) {
|
|
310
|
+
throw unusable('`token` was missing or not a non-empty string');
|
|
311
|
+
}
|
|
312
|
+
if (typeof body.expiresAtEpoch !== 'number' || !Number.isFinite(body.expiresAtEpoch)) {
|
|
313
|
+
throw unusable('`expiresAtEpoch` was missing or not a number');
|
|
314
|
+
}
|
|
315
|
+
if (typeof body.issuer !== 'string' || typeof body.kid !== 'string') {
|
|
316
|
+
throw unusable('`issuer` or `kid` was missing');
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
token: body.token,
|
|
320
|
+
expiresAtEpoch: body.expiresAtEpoch,
|
|
321
|
+
issuer: body.issuer,
|
|
322
|
+
kid: body.kid,
|
|
323
|
+
};
|
|
324
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guuey/widget-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mint end-user identity tokens for a guuey embeddable widget from your own backend. One call, zero runtime dependencies — your app secret stays on your server and the platform holds the signing key.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"prepack": "rm -rf dist && pnpm build",
|
|
27
|
+
"dev": "tsc --watch",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run && pnpm run smoke:dist",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"smoke:dist": "pnpm build && node scripts/import-smoke.mjs"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "24.12.4",
|
|
35
|
+
"typescript": "^5.0.0",
|
|
36
|
+
"vitest": "^3.0.0"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"guuey",
|
|
43
|
+
"widget",
|
|
44
|
+
"embed",
|
|
45
|
+
"auth",
|
|
46
|
+
"jwt",
|
|
47
|
+
"identity"
|
|
48
|
+
],
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/withguuey/guuey-sdks.git",
|
|
52
|
+
"directory": "packages/widget-auth"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://guuey.com",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/loqu-co/guuey/issues"
|
|
57
|
+
}
|
|
58
|
+
}
|