toddstack-accounts 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/dist/encoding.d.ts +12 -0
- package/dist/encoding.js +36 -0
- package/dist/error.d.ts +38 -0
- package/dist/error.js +22 -0
- package/dist/friends.d.ts +99 -0
- package/dist/friends.js +110 -0
- package/dist/jwks.d.ts +21 -0
- package/dist/jwks.js +68 -0
- package/dist/login.d.ts +51 -0
- package/dist/login.js +53 -0
- package/dist/mod.d.ts +28 -0
- package/dist/mod.js +27 -0
- package/dist/return-to.d.ts +5 -0
- package/dist/return-to.js +14 -0
- package/dist/tokens.d.ts +123 -0
- package/dist/tokens.js +232 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/webhook.d.ts +23 -0
- package/dist/webhook.js +63 -0
- package/package.json +19 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Todd Everett Griffin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,112 @@
|
|
|
1
|
-
#
|
|
1
|
+
# toddstack-accounts (TypeScript)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://jsr.io/@todd/toddstack-accounts)
|
|
4
|
+
[](https://www.npmjs.com/package/toddstack-accounts)
|
|
5
|
+
|
|
6
|
+
Client for Todd Griffin's Projects accounts (`accounts.toddgriffin.me`): what a project's backend needs to sign people
|
|
7
|
+
in, check tokens, verify webhooks, and use friends. The TypeScript half of `toddstack-accounts`, with the same features
|
|
8
|
+
as the [Rust crate](../../rust/toddstack_accounts/README.md). Part of
|
|
9
|
+
[toddstack](https://github.com/goddtriffin/toddstack).
|
|
10
|
+
|
|
11
|
+
Web Platform APIs only (`fetch`, WebCrypto): Node 20+, Deno, Bun. No dependencies.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx jsr add @todd/toddstack-accounts # or: deno add jsr:@todd/toddstack-accounts
|
|
15
|
+
npm install toddstack-accounts
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Web apps use the **backend-for-frontend** pattern: your server is the OIDC client and keeps the tokens; the browser only
|
|
19
|
+
ever holds your own session cookie. Native apps sign in through the system browser as public clients and send their
|
|
20
|
+
access token to your server, which checks it with `accounts.verifyAccessToken`.
|
|
21
|
+
|
|
22
|
+
## Sign in (two routes)
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Accounts, toddgriffin } from "toddstack-accounts"; // or "@todd/toddstack-accounts"
|
|
26
|
+
|
|
27
|
+
const accounts = new Accounts(toddgriffin(
|
|
28
|
+
"boggledygook",
|
|
29
|
+
process.env.ACCOUNTS_CLIENT_SECRET!, // from your env (1Password)
|
|
30
|
+
"https://www.boggledygook.com/auth/callback", // registered exactly
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
// GET /auth/login?return=/play
|
|
34
|
+
const { url, pending } = await accounts.startLogin({
|
|
35
|
+
scopes: ["profile", "offline_access", "friends"],
|
|
36
|
+
returnTo: "/play",
|
|
37
|
+
});
|
|
38
|
+
// Keep `pending` server-side or in an encrypted HttpOnly cookie, then redirect to `url`.
|
|
39
|
+
|
|
40
|
+
// GET /auth/callback?code=..&state=..&iss=..
|
|
41
|
+
const signedIn = await accounts.finishLogin(pending, new URL(request.url).search);
|
|
42
|
+
// signedIn.claims.sub is the person's permanent id: key everything on it.
|
|
43
|
+
// Keep signedIn.tokens (refreshToken rotates: always store the new one).
|
|
44
|
+
// Redirect to signedIn.returnTo (already checked to be a path on your site).
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`finishLogin` checks `state` and `iss`, exchanges the code with PKCE, and verifies the ID token (ES256 signature against
|
|
48
|
+
the published keys, issuer, audience, expiry, nonce).
|
|
49
|
+
|
|
50
|
+
| Call | What |
|
|
51
|
+
| ------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `startLogin({ prompt, loginHint, maxAge })` | `prompt: "login"` forces a fresh sign-in, `"none"` checks silently, `"create"` opens sign-up |
|
|
53
|
+
| `accounts.refresh(token)` | New tokens; an `AccountsError` with `kind: "token_refused"` means sign in again |
|
|
54
|
+
| `accounts.revoke(token)` | On sign-out |
|
|
55
|
+
| `accounts.verifyAccessToken(token)` | For your API: checks `typ`, issuer, audience = your client id, expiry |
|
|
56
|
+
| `accounts.verifyIdToken(token, nonce?)` | ID token checks on their own |
|
|
57
|
+
| `accounts.userinfo(token)` | The profile JSON |
|
|
58
|
+
| `safeReturnTo(path)` | Keeps redirects on your site |
|
|
59
|
+
|
|
60
|
+
Errors are `AccountsError` with a `kind`: `state_mismatch`, `authorization` (the person cancelled, or `login_required`
|
|
61
|
+
after `prompt: "none"`), `missing_code`, `token_refused`, `invalid_token`, `invalid_webhook`, `api` (with `status`),
|
|
62
|
+
`http`, `config`.
|
|
63
|
+
|
|
64
|
+
## Webhooks
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { verifyWebhook, WEBHOOK_HEADER } from "toddstack-accounts";
|
|
68
|
+
|
|
69
|
+
const event = await verifyWebhook(webhookSecret, request.headers.get(WEBHOOK_HEADER), await request.text());
|
|
70
|
+
switch (event.type) {
|
|
71
|
+
case "user.deleted": /* delete or anonymize event.data.sub's rows */
|
|
72
|
+
break;
|
|
73
|
+
case "user.updated": /* refresh cached username / picture */
|
|
74
|
+
break;
|
|
75
|
+
case "user.signed_out":
|
|
76
|
+
case "user.suspended":
|
|
77
|
+
case "grant.revoked": /* end event.data.sub's sessions */
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Your sessions are your own: accounts can't end them. On `user.signed_out`, `user.suspended` and `grant.revoked`, delete
|
|
83
|
+
every session you hold for `event.data.sub`. Their refresh tokens are already revoked.
|
|
84
|
+
|
|
85
|
+
Verify the raw body before parsing. Signatures older than 5 minutes are refused. Deliveries retry, so ignore repeated
|
|
86
|
+
`event.id`s.
|
|
87
|
+
|
|
88
|
+
## Friends and events
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const friends = accounts.friends(accessToken);
|
|
92
|
+
const list = await friends.list(); // only friends who also use your app
|
|
93
|
+
await friends.requestByUsername("alex");
|
|
94
|
+
await friends.postEvent({
|
|
95
|
+
kind: "beat_your_score",
|
|
96
|
+
title: "Alex beat your high score",
|
|
97
|
+
body: "4,200 points in today's daily",
|
|
98
|
+
collapseKey: "daily-2026-11-14",
|
|
99
|
+
}); // needs scope friends:events
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Also `requests`, `requestByCode`, `accept`, `decline`, `cancel`, `remove`, `block`, `unblock`, `inbox(limit)`,
|
|
103
|
+
`markRead`, `mutes`, `mute(kind)` (`ALL_KINDS` mutes everything), `unmute`.
|
|
104
|
+
|
|
105
|
+
## Tests
|
|
106
|
+
|
|
107
|
+
`new Accounts(config, { keys, fetch })` pins the signing keys and swaps `fetch`, so tests run with no network.
|
|
108
|
+
|
|
109
|
+
## Publishing
|
|
110
|
+
|
|
111
|
+
`deno.jsonc` (JSR) and `package.json` (npm) carry the same version as `version.ts`; a test checks. The npm package is
|
|
112
|
+
the `tsc` build of the same sources (`tsconfig.npm.json`, output in `dist/`, not committed).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** UTF-8 bytes of `text`. */
|
|
2
|
+
export declare function utf8(text: string): Uint8Array<ArrayBuffer>;
|
|
3
|
+
/** Text of UTF-8 `bytes`. */
|
|
4
|
+
export declare function fromUtf8(bytes: Uint8Array): string;
|
|
5
|
+
/** Unpadded base64url of `bytes`. */
|
|
6
|
+
export declare function base64url(bytes: Uint8Array): string;
|
|
7
|
+
/** Bytes of base64url (padded or not) or standard base64 `text`. Throws on malformed input. */
|
|
8
|
+
export declare function fromBase64(text: string): Uint8Array<ArrayBuffer>;
|
|
9
|
+
/** `n` cryptographically random bytes. */
|
|
10
|
+
export declare function randomBytes(n: number): Uint8Array<ArrayBuffer>;
|
|
11
|
+
/** A random base64url token with `n` bytes of entropy. */
|
|
12
|
+
export declare function randomToken(n?: number): string;
|
package/dist/encoding.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Base64url, UTF-8 and random helpers on Web Platform APIs only (Node 20+, Deno, Bun, browsers).
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
const decoder = new TextDecoder();
|
|
4
|
+
/** UTF-8 bytes of `text`. */
|
|
5
|
+
export function utf8(text) {
|
|
6
|
+
return encoder.encode(text);
|
|
7
|
+
}
|
|
8
|
+
/** Text of UTF-8 `bytes`. */
|
|
9
|
+
export function fromUtf8(bytes) {
|
|
10
|
+
return decoder.decode(bytes);
|
|
11
|
+
}
|
|
12
|
+
/** Unpadded base64url of `bytes`. */
|
|
13
|
+
export function base64url(bytes) {
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const b of bytes)
|
|
16
|
+
binary += String.fromCharCode(b);
|
|
17
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
/** Bytes of base64url (padded or not) or standard base64 `text`. Throws on malformed input. */
|
|
20
|
+
export function fromBase64(text) {
|
|
21
|
+
const standard = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
22
|
+
const padded = standard + "=".repeat((4 - (standard.length % 4)) % 4);
|
|
23
|
+
const binary = atob(padded);
|
|
24
|
+
const out = new Uint8Array(binary.length);
|
|
25
|
+
for (let i = 0; i < binary.length; i++)
|
|
26
|
+
out[i] = binary.charCodeAt(i);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** `n` cryptographically random bytes. */
|
|
30
|
+
export function randomBytes(n) {
|
|
31
|
+
return crypto.getRandomValues(new Uint8Array(n));
|
|
32
|
+
}
|
|
33
|
+
/** A random base64url token with `n` bytes of entropy. */
|
|
34
|
+
export function randomToken(n = 32) {
|
|
35
|
+
return base64url(randomBytes(n));
|
|
36
|
+
}
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** What went wrong. */
|
|
2
|
+
export type AccountsErrorKind =
|
|
3
|
+
/** The callback's `state` (or `iss`) doesn't match the pending sign-in: forged or stale. Start over. */
|
|
4
|
+
"state_mismatch"
|
|
5
|
+
/** The service sent the person back with an OAuth error (`access_denied`, `login_required` for `prompt=none`). */
|
|
6
|
+
| "authorization"
|
|
7
|
+
/** The callback had no `code`. */
|
|
8
|
+
| "missing_code"
|
|
9
|
+
/** The token endpoint refused (`invalid_grant`: expired, reused or revoked). Sign in again. */
|
|
10
|
+
| "token_refused"
|
|
11
|
+
/** A token failed verification (signature, issuer, audience, expiry, nonce). */
|
|
12
|
+
| "invalid_token"
|
|
13
|
+
/** A webhook failed verification. */
|
|
14
|
+
| "invalid_webhook"
|
|
15
|
+
/** The API refused a call; `status` and the API's message are set. */
|
|
16
|
+
| "api"
|
|
17
|
+
/** The request itself failed (network, timeout). */
|
|
18
|
+
| "http"
|
|
19
|
+
/** Bad configuration. */
|
|
20
|
+
| "config";
|
|
21
|
+
/** An error from this library. Switch on `kind`. */
|
|
22
|
+
export declare class AccountsError extends Error {
|
|
23
|
+
/** What went wrong. */
|
|
24
|
+
readonly kind: AccountsErrorKind;
|
|
25
|
+
/** The OAuth error code, for `authorization` and `token_refused`. */
|
|
26
|
+
readonly error?: string;
|
|
27
|
+
/** The OAuth `error_description`, when the service sent one. */
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
/** The HTTP status, for `api`. */
|
|
30
|
+
readonly status?: number;
|
|
31
|
+
/** Builds an error; `details` carries the OAuth code, description, HTTP status or cause. */
|
|
32
|
+
constructor(kind: AccountsErrorKind, message: string, details?: {
|
|
33
|
+
error?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
status?: number;
|
|
36
|
+
cause?: unknown;
|
|
37
|
+
});
|
|
38
|
+
}
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Everything that can go wrong talking to the accounts service. One error class with a `kind`, the
|
|
2
|
+
// TypeScript shape of the Rust crate's `Error` enum.
|
|
3
|
+
/** An error from this library. Switch on `kind`. */
|
|
4
|
+
export class AccountsError extends Error {
|
|
5
|
+
/** What went wrong. */
|
|
6
|
+
kind;
|
|
7
|
+
/** The OAuth error code, for `authorization` and `token_refused`. */
|
|
8
|
+
error;
|
|
9
|
+
/** The OAuth `error_description`, when the service sent one. */
|
|
10
|
+
description;
|
|
11
|
+
/** The HTTP status, for `api`. */
|
|
12
|
+
status;
|
|
13
|
+
/** Builds an error; `details` carries the OAuth code, description, HTTP status or cause. */
|
|
14
|
+
constructor(kind, message, details = {}) {
|
|
15
|
+
super(message, details.cause === undefined ? undefined : { cause: details.cause });
|
|
16
|
+
this.name = "AccountsError";
|
|
17
|
+
this.kind = kind;
|
|
18
|
+
this.error = details.error;
|
|
19
|
+
this.description = details.description;
|
|
20
|
+
this.status = details.status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Accounts } from "./tokens.ts";
|
|
2
|
+
/** A friend, request or blocked person as your app sees them. */
|
|
3
|
+
export interface Person {
|
|
4
|
+
/** Their permanent id. */
|
|
5
|
+
sub: string;
|
|
6
|
+
/** Their unique username. */
|
|
7
|
+
username: string;
|
|
8
|
+
/** Display name, if set. */
|
|
9
|
+
name: string | null;
|
|
10
|
+
/** Profile picture URL, if set. */
|
|
11
|
+
picture: string | null;
|
|
12
|
+
/** When the friendship, request or block started (RFC 3339). */
|
|
13
|
+
since: string;
|
|
14
|
+
}
|
|
15
|
+
/** Pending friend requests. */
|
|
16
|
+
export interface Requests {
|
|
17
|
+
/** Requests sent to the person. */
|
|
18
|
+
incoming: Person[];
|
|
19
|
+
/** Requests the person sent. */
|
|
20
|
+
outgoing: Person[];
|
|
21
|
+
}
|
|
22
|
+
/** What sending a request did: `accepted` means they had already asked, so you're friends now. */
|
|
23
|
+
export type Sent = "requested" | "accepted" | "already_friends";
|
|
24
|
+
/** An event for friends' inboxes ("Alex beat your high score"). */
|
|
25
|
+
export interface FriendEvent {
|
|
26
|
+
/** 1-40 of `a-z`, `0-9`, `_`. People can mute a kind. */
|
|
27
|
+
kind: string;
|
|
28
|
+
/** 1-80 characters. */
|
|
29
|
+
title: string;
|
|
30
|
+
/** Up to 280 characters. */
|
|
31
|
+
body?: string;
|
|
32
|
+
/** An `https` link into your app. */
|
|
33
|
+
url?: string;
|
|
34
|
+
/** A newer event with the same key replaces an unread older one. */
|
|
35
|
+
collapseKey?: string;
|
|
36
|
+
/** Only these friends (`sub`s); all friends who use your app if omitted. */
|
|
37
|
+
recipients?: string[];
|
|
38
|
+
}
|
|
39
|
+
/** One item in the person's inbox from your app. */
|
|
40
|
+
export interface InboxItem {
|
|
41
|
+
/** Item id (for `markRead`). */
|
|
42
|
+
id: string;
|
|
43
|
+
/** The friend it came from (`sub`), null for system items. */
|
|
44
|
+
from: string | null;
|
|
45
|
+
/** That friend's username. */
|
|
46
|
+
fromUsername: string | null;
|
|
47
|
+
/** The event kind. */
|
|
48
|
+
kind: string;
|
|
49
|
+
/** The headline. */
|
|
50
|
+
title: string;
|
|
51
|
+
/** The text (may be empty). */
|
|
52
|
+
body: string;
|
|
53
|
+
/** An `https` link into your app. */
|
|
54
|
+
url: string | null;
|
|
55
|
+
/** When it was posted (RFC 3339). */
|
|
56
|
+
createdAt: string;
|
|
57
|
+
/** When the person read it (RFC 3339), null if unread. */
|
|
58
|
+
readAt: string | null;
|
|
59
|
+
}
|
|
60
|
+
/** Mutes every event kind from your app. */
|
|
61
|
+
export declare const ALL_KINDS = "*";
|
|
62
|
+
/** The API for one person (their access token). Get one from `accounts.friends(token)`. */
|
|
63
|
+
export declare class Friends {
|
|
64
|
+
#private;
|
|
65
|
+
/** The API for the person whose access token this is. */
|
|
66
|
+
constructor(accounts: Accounts, accessToken: string);
|
|
67
|
+
/** Friends who also use your app. */
|
|
68
|
+
list(): Promise<Person[]>;
|
|
69
|
+
/** Incoming and outgoing requests. */
|
|
70
|
+
requests(): Promise<Requests>;
|
|
71
|
+
/** Sends a request by exact username. Not found also covers blocks and privacy settings. */
|
|
72
|
+
requestByUsername(username: string): Promise<Sent>;
|
|
73
|
+
/** Sends a request by friend code (works even when the person only takes requests from people with their code). */
|
|
74
|
+
requestByCode(friendCode: string): Promise<Sent>;
|
|
75
|
+
/** Accepts `sub`'s request. */
|
|
76
|
+
accept(sub: string): Promise<void>;
|
|
77
|
+
/** Declines `sub`'s request. */
|
|
78
|
+
decline(sub: string): Promise<void>;
|
|
79
|
+
/** Cancels a request you sent. */
|
|
80
|
+
cancel(sub: string): Promise<void>;
|
|
81
|
+
/** Ends a friendship. */
|
|
82
|
+
remove(sub: string): Promise<void>;
|
|
83
|
+
/** Blocks: ends the friendship and requests, and stops events both ways. */
|
|
84
|
+
block(sub: string): Promise<void>;
|
|
85
|
+
/** Undoes `block`. */
|
|
86
|
+
unblock(sub: string): Promise<void>;
|
|
87
|
+
/** Posts an event; returns how many friends' inboxes it reached. Rate limit: 60 per person per hour per app. */
|
|
88
|
+
postEvent(event: FriendEvent): Promise<number>;
|
|
89
|
+
/** The person's inbox items from your app, newest first. */
|
|
90
|
+
inbox(limit: number): Promise<InboxItem[]>;
|
|
91
|
+
/** Marks an inbox item read. */
|
|
92
|
+
markRead(id: string): Promise<void>;
|
|
93
|
+
/** The event kinds from your app the person has muted (`ALL_KINDS` means all of them). */
|
|
94
|
+
mutes(): Promise<string[]>;
|
|
95
|
+
/** Stops friends' events of `kind` from your app reaching the person (`ALL_KINDS` for every kind). */
|
|
96
|
+
mute(kind: string): Promise<void>;
|
|
97
|
+
/** Undoes `mute`. */
|
|
98
|
+
unmute(kind: string): Promise<void>;
|
|
99
|
+
}
|
package/dist/friends.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The friends, events and inbox API, called with a person's access token (scopes `friends`, and
|
|
2
|
+
// `friends:events` to post events). Your app only ever sees friends who also use your app.
|
|
3
|
+
import { AccountsError } from "./error.js";
|
|
4
|
+
/** Mutes every event kind from your app. */
|
|
5
|
+
export const ALL_KINDS = "*";
|
|
6
|
+
function field(value, name) {
|
|
7
|
+
const out = value?.[name];
|
|
8
|
+
if (out === undefined) {
|
|
9
|
+
throw new AccountsError("api", `accounts API returned 200: missing field \`${name}\``, { status: 200 });
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/** The API for one person (their access token). Get one from `accounts.friends(token)`. */
|
|
14
|
+
export class Friends {
|
|
15
|
+
#accounts;
|
|
16
|
+
#token;
|
|
17
|
+
#base;
|
|
18
|
+
/** The API for the person whose access token this is. */
|
|
19
|
+
constructor(accounts, accessToken) {
|
|
20
|
+
this.#accounts = accounts;
|
|
21
|
+
this.#token = accessToken;
|
|
22
|
+
this.#base = `${accounts.config.issuer.replace(/\/oidc$/, "")}/api/v1`;
|
|
23
|
+
}
|
|
24
|
+
#call(method, path, body) {
|
|
25
|
+
return this.#accounts.call(this.#token, method, `${this.#base}${path}`, body);
|
|
26
|
+
}
|
|
27
|
+
/** Friends who also use your app. */
|
|
28
|
+
async list() {
|
|
29
|
+
return field(await this.#call("GET", "/friends"), "friends");
|
|
30
|
+
}
|
|
31
|
+
/** Incoming and outgoing requests. */
|
|
32
|
+
async requests() {
|
|
33
|
+
const value = await this.#call("GET", "/friends/requests");
|
|
34
|
+
return { incoming: field(value, "incoming"), outgoing: field(value, "outgoing") };
|
|
35
|
+
}
|
|
36
|
+
/** Sends a request by exact username. Not found also covers blocks and privacy settings. */
|
|
37
|
+
async requestByUsername(username) {
|
|
38
|
+
return field(await this.#call("POST", "/friends/requests", { username }), "result");
|
|
39
|
+
}
|
|
40
|
+
/** Sends a request by friend code (works even when the person only takes requests from people with their code). */
|
|
41
|
+
async requestByCode(friendCode) {
|
|
42
|
+
return field(await this.#call("POST", "/friends/requests", { friend_code: friendCode }), "result");
|
|
43
|
+
}
|
|
44
|
+
/** Accepts `sub`'s request. */
|
|
45
|
+
async accept(sub) {
|
|
46
|
+
await this.#call("POST", `/friends/requests/${encodeURIComponent(sub)}/accept`);
|
|
47
|
+
}
|
|
48
|
+
/** Declines `sub`'s request. */
|
|
49
|
+
async decline(sub) {
|
|
50
|
+
await this.#call("POST", `/friends/requests/${encodeURIComponent(sub)}/decline`);
|
|
51
|
+
}
|
|
52
|
+
/** Cancels a request you sent. */
|
|
53
|
+
async cancel(sub) {
|
|
54
|
+
await this.#call("DELETE", `/friends/requests/${encodeURIComponent(sub)}`);
|
|
55
|
+
}
|
|
56
|
+
/** Ends a friendship. */
|
|
57
|
+
async remove(sub) {
|
|
58
|
+
await this.#call("DELETE", `/friends/${encodeURIComponent(sub)}`);
|
|
59
|
+
}
|
|
60
|
+
/** Blocks: ends the friendship and requests, and stops events both ways. */
|
|
61
|
+
async block(sub) {
|
|
62
|
+
await this.#call("POST", "/blocks", { sub });
|
|
63
|
+
}
|
|
64
|
+
/** Undoes `block`. */
|
|
65
|
+
async unblock(sub) {
|
|
66
|
+
await this.#call("DELETE", `/blocks/${encodeURIComponent(sub)}`);
|
|
67
|
+
}
|
|
68
|
+
/** Posts an event; returns how many friends' inboxes it reached. Rate limit: 60 per person per hour per app. */
|
|
69
|
+
async postEvent(event) {
|
|
70
|
+
const wire = { kind: event.kind, title: event.title, body: event.body ?? "" };
|
|
71
|
+
if (event.url !== undefined)
|
|
72
|
+
wire.url = event.url;
|
|
73
|
+
if (event.collapseKey !== undefined)
|
|
74
|
+
wire.collapse_key = event.collapseKey;
|
|
75
|
+
if (event.recipients !== undefined)
|
|
76
|
+
wire.recipients = event.recipients;
|
|
77
|
+
return field(await this.#call("POST", "/events", wire), "delivered");
|
|
78
|
+
}
|
|
79
|
+
/** The person's inbox items from your app, newest first. */
|
|
80
|
+
async inbox(limit) {
|
|
81
|
+
const items = field(await this.#call("GET", `/inbox?limit=${Math.floor(limit)}`), "items");
|
|
82
|
+
return items.map((i) => ({
|
|
83
|
+
id: i.id,
|
|
84
|
+
from: i.from,
|
|
85
|
+
fromUsername: i.from_username,
|
|
86
|
+
kind: i.kind,
|
|
87
|
+
title: i.title,
|
|
88
|
+
body: i.body,
|
|
89
|
+
url: i.url,
|
|
90
|
+
createdAt: i.created_at,
|
|
91
|
+
readAt: i.read_at,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
/** Marks an inbox item read. */
|
|
95
|
+
async markRead(id) {
|
|
96
|
+
await this.#call("POST", `/inbox/${encodeURIComponent(id)}/read`);
|
|
97
|
+
}
|
|
98
|
+
/** The event kinds from your app the person has muted (`ALL_KINDS` means all of them). */
|
|
99
|
+
async mutes() {
|
|
100
|
+
return field(await this.#call("GET", "/mutes"), "mutes");
|
|
101
|
+
}
|
|
102
|
+
/** Stops friends' events of `kind` from your app reaching the person (`ALL_KINDS` for every kind). */
|
|
103
|
+
async mute(kind) {
|
|
104
|
+
await this.#call("PUT", `/mutes/${encodeURIComponent(kind)}`);
|
|
105
|
+
}
|
|
106
|
+
/** Undoes `mute`. */
|
|
107
|
+
async unmute(kind) {
|
|
108
|
+
await this.#call("DELETE", `/mutes/${encodeURIComponent(kind)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/jwks.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One key from a JWKS document. */
|
|
2
|
+
export interface Jwk extends JsonWebKey {
|
|
3
|
+
/** Key id (the JWT header's `kid`). */
|
|
4
|
+
kid?: string;
|
|
5
|
+
}
|
|
6
|
+
/** A JWKS document. */
|
|
7
|
+
export interface JwkSet {
|
|
8
|
+
/** The keys. */
|
|
9
|
+
keys: Jwk[];
|
|
10
|
+
}
|
|
11
|
+
/** A key cache: remote (fetched from the issuer) or pinned (tests, offline use; never refetched). */
|
|
12
|
+
export declare class Keys {
|
|
13
|
+
#private;
|
|
14
|
+
private constructor();
|
|
15
|
+
/** Keys fetched from `url`. */
|
|
16
|
+
static remote(url: string, fetcher: typeof fetch): Keys;
|
|
17
|
+
/** Fixed keys, never fetched. */
|
|
18
|
+
static pinned(set: JwkSet): Keys;
|
|
19
|
+
/** The key for `kid`, fetching the key set if it's missing or stale. */
|
|
20
|
+
get(kid: string): Promise<CryptoKey>;
|
|
21
|
+
}
|
package/dist/jwks.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The accounts service's published signing keys, cached. An unknown `kid` triggers one refetch (keys rotate),
|
|
2
|
+
// at most once a minute.
|
|
3
|
+
import { AccountsError } from "./error.js";
|
|
4
|
+
const REFRESH_EVERY_MS = 3600_000;
|
|
5
|
+
const MIN_REFETCH_MS = 60_000;
|
|
6
|
+
/** A key cache: remote (fetched from the issuer) or pinned (tests, offline use; never refetched). */
|
|
7
|
+
export class Keys {
|
|
8
|
+
#url;
|
|
9
|
+
#fetch;
|
|
10
|
+
#cache;
|
|
11
|
+
#imported = new Map();
|
|
12
|
+
constructor(url, set, fetcher) {
|
|
13
|
+
this.#url = url;
|
|
14
|
+
this.#fetch = fetcher;
|
|
15
|
+
this.#cache = set ? { at: Date.now(), set } : null;
|
|
16
|
+
}
|
|
17
|
+
/** Keys fetched from `url`. */
|
|
18
|
+
static remote(url, fetcher) {
|
|
19
|
+
return new Keys(url, null, fetcher);
|
|
20
|
+
}
|
|
21
|
+
/** Fixed keys, never fetched. */
|
|
22
|
+
static pinned(set) {
|
|
23
|
+
return new Keys(null, set, fetch);
|
|
24
|
+
}
|
|
25
|
+
#lookup(kid) {
|
|
26
|
+
const jwk = this.#cache?.set.keys.find((k) => k.kid === kid && k.kty === "EC" && k.crv === "P-256");
|
|
27
|
+
if (!jwk)
|
|
28
|
+
return null;
|
|
29
|
+
const id = JSON.stringify([jwk.kid, jwk.x, jwk.y]);
|
|
30
|
+
let key = this.#imported.get(id);
|
|
31
|
+
if (!key) {
|
|
32
|
+
const { kid: _kid, alg: _alg, use: _use, key_ops: _ops, ...material } = jwk;
|
|
33
|
+
key = crypto.subtle.importKey("jwk", material, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
34
|
+
this.#imported.set(id, key);
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
/** The key for `kid`, fetching the key set if it's missing or stale. */
|
|
39
|
+
async get(kid) {
|
|
40
|
+
const age = this.#cache ? Date.now() - this.#cache.at : null;
|
|
41
|
+
const stale = age === null || age > REFRESH_EVERY_MS;
|
|
42
|
+
if (!stale || this.#url === null) {
|
|
43
|
+
const key = this.#lookup(kid);
|
|
44
|
+
if (key)
|
|
45
|
+
return await key;
|
|
46
|
+
if (this.#url === null)
|
|
47
|
+
throw new AccountsError("invalid_token", "token rejected: unknown signing key");
|
|
48
|
+
}
|
|
49
|
+
// Unknown kid on a fresh cache: refetch, but not more than once a minute.
|
|
50
|
+
if (stale || (age !== null && age > MIN_REFETCH_MS)) {
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = await this.#fetch(this.#url, { signal: AbortSignal.timeout(10_000) });
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
throw new AccountsError("http", `http: ${cause}`, { cause });
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new AccountsError("http", `http: JWKS fetch returned ${response.status}`, { status: response.status });
|
|
60
|
+
}
|
|
61
|
+
this.#cache = { at: Date.now(), set: (await response.json()) };
|
|
62
|
+
}
|
|
63
|
+
const key = this.#lookup(kid);
|
|
64
|
+
if (!key)
|
|
65
|
+
throw new AccountsError("invalid_token", "token rejected: unknown signing key");
|
|
66
|
+
return await key;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Config } from "./tokens.ts";
|
|
2
|
+
/** What to remember between `startLogin` and the callback. Plain JSON: safe to serialize. */
|
|
3
|
+
export interface PendingLogin {
|
|
4
|
+
/** Echoed back in the callback; must match. */
|
|
5
|
+
state: string;
|
|
6
|
+
/** Must come back inside the ID token. */
|
|
7
|
+
nonce: string;
|
|
8
|
+
/** The PKCE verifier. Never send it to the browser. */
|
|
9
|
+
verifier: string;
|
|
10
|
+
/** Where to go after sign-in: a path on your site, already checked. */
|
|
11
|
+
returnTo: string | null;
|
|
12
|
+
}
|
|
13
|
+
/** Options beyond the defaults. */
|
|
14
|
+
export interface LoginOptions {
|
|
15
|
+
/** Scopes to ask for; `openid` is always added. */
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
/** A path on your site to return to; anything off-site is dropped. */
|
|
18
|
+
returnTo?: string | null;
|
|
19
|
+
/** `login` forces a fresh sign-in; `none` checks silently (no page shown); `create` opens the sign-up page. */
|
|
20
|
+
prompt?: "login" | "none" | "create";
|
|
21
|
+
/** Prefills the email field. */
|
|
22
|
+
loginHint?: string;
|
|
23
|
+
/** Require a sign-in within this many seconds. */
|
|
24
|
+
maxAge?: number;
|
|
25
|
+
}
|
|
26
|
+
/** A started sign-in: redirect to `url`, keep `pending`. */
|
|
27
|
+
export interface StartedLogin {
|
|
28
|
+
/** Where to send the person. */
|
|
29
|
+
url: string;
|
|
30
|
+
/** What to keep until the callback. */
|
|
31
|
+
pending: PendingLogin;
|
|
32
|
+
}
|
|
33
|
+
/** The PKCE S256 challenge for `verifier`. */
|
|
34
|
+
export declare function pkceChallenge(verifier: string): Promise<string>;
|
|
35
|
+
/** Starts a sign-in. */
|
|
36
|
+
export declare function startLogin(config: Config, options?: LoginOptions): Promise<StartedLogin>;
|
|
37
|
+
/** What the callback query says. */
|
|
38
|
+
export interface Callback {
|
|
39
|
+
/** The authorization code. */
|
|
40
|
+
code: string | null;
|
|
41
|
+
/** The `state` to match against the pending sign-in. */
|
|
42
|
+
state: string | null;
|
|
43
|
+
/** The issuer (RFC 9207), when sent. */
|
|
44
|
+
iss: string | null;
|
|
45
|
+
/** An OAuth error code, when the sign-in failed. */
|
|
46
|
+
error: string | null;
|
|
47
|
+
/** The error's description. */
|
|
48
|
+
errorDescription: string | null;
|
|
49
|
+
}
|
|
50
|
+
/** Reads a callback query string (with or without `?`) or `URLSearchParams`. */
|
|
51
|
+
export declare function parseCallback(query: string | URLSearchParams): Callback;
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The sign-in round trip, as two steps around a redirect.
|
|
2
|
+
//
|
|
3
|
+
// 1. `startLogin` builds the URL to send the person to and a `PendingLogin`. Keep the pending login
|
|
4
|
+
// server-side (or in an encrypted, HttpOnly cookie): it holds the PKCE verifier, which must never reach the
|
|
5
|
+
// browser's JS.
|
|
6
|
+
// 2. When the person comes back to your redirect URI, pass the pending login and the callback query to
|
|
7
|
+
// `Accounts.finishLogin`.
|
|
8
|
+
import { base64url, randomToken, utf8 } from "./encoding.js";
|
|
9
|
+
import { safeReturnTo } from "./return-to.js";
|
|
10
|
+
/** The PKCE S256 challenge for `verifier`. */
|
|
11
|
+
export async function pkceChallenge(verifier) {
|
|
12
|
+
return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(verifier))));
|
|
13
|
+
}
|
|
14
|
+
/** Starts a sign-in. */
|
|
15
|
+
export async function startLogin(config, options = {}) {
|
|
16
|
+
const pending = {
|
|
17
|
+
state: randomToken(),
|
|
18
|
+
nonce: randomToken(),
|
|
19
|
+
verifier: randomToken(),
|
|
20
|
+
returnTo: options.returnTo ? safeReturnTo(options.returnTo) : null,
|
|
21
|
+
};
|
|
22
|
+
const scopes = [...(options.scopes ?? [])];
|
|
23
|
+
if (!scopes.includes("openid"))
|
|
24
|
+
scopes.unshift("openid");
|
|
25
|
+
const url = new URL(`${config.issuer}/auth`);
|
|
26
|
+
const q = url.searchParams;
|
|
27
|
+
q.append("client_id", config.clientId);
|
|
28
|
+
q.append("redirect_uri", config.redirectUri);
|
|
29
|
+
q.append("response_type", "code");
|
|
30
|
+
q.append("scope", scopes.join(" "));
|
|
31
|
+
q.append("state", pending.state);
|
|
32
|
+
q.append("nonce", pending.nonce);
|
|
33
|
+
q.append("code_challenge", await pkceChallenge(pending.verifier));
|
|
34
|
+
q.append("code_challenge_method", "S256");
|
|
35
|
+
if (options.prompt)
|
|
36
|
+
q.append("prompt", options.prompt);
|
|
37
|
+
if (options.loginHint)
|
|
38
|
+
q.append("login_hint", options.loginHint);
|
|
39
|
+
if (options.maxAge !== undefined)
|
|
40
|
+
q.append("max_age", String(options.maxAge));
|
|
41
|
+
return { url: url.toString(), pending };
|
|
42
|
+
}
|
|
43
|
+
/** Reads a callback query string (with or without `?`) or `URLSearchParams`. */
|
|
44
|
+
export function parseCallback(query) {
|
|
45
|
+
const q = typeof query === "string" ? new URLSearchParams(query.replace(/^\?/, "")) : query;
|
|
46
|
+
return {
|
|
47
|
+
code: q.get("code"),
|
|
48
|
+
state: q.get("state"),
|
|
49
|
+
iss: q.get("iss"),
|
|
50
|
+
error: q.get("error"),
|
|
51
|
+
errorDescription: q.get("error_description"),
|
|
52
|
+
};
|
|
53
|
+
}
|
package/dist/mod.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for Todd Griffin's Projects accounts (`accounts.toddgriffin.me`): what a project's backend needs to
|
|
3
|
+
* sign people in, check tokens, verify webhooks, and use friends. The TypeScript half of `toddstack-accounts`
|
|
4
|
+
* (same features as the Rust crate). Web Platform APIs only: runs on Node 20+, Deno and Bun.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { Accounts, toddgriffin } from "@todd/toddstack-accounts";
|
|
8
|
+
*
|
|
9
|
+
* const accounts = new Accounts(toddgriffin("boggledygook", clientSecret, "https://www.boggledygook.com/auth/callback"));
|
|
10
|
+
*
|
|
11
|
+
* // GET /auth/login: redirect to `url`; keep `pending` server-side or in an encrypted HttpOnly cookie.
|
|
12
|
+
* const { url, pending } = await accounts.startLogin({ scopes: ["profile", "offline_access"], returnTo: "/play" });
|
|
13
|
+
*
|
|
14
|
+
* // GET /auth/callback?code=..&state=..
|
|
15
|
+
* const signedIn = await accounts.finishLogin(pending, new URL(request.url).search);
|
|
16
|
+
* console.log(signedIn.claims.sub);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
export { AccountsError, type AccountsErrorKind } from "./error.ts";
|
|
22
|
+
export { ALL_KINDS, type FriendEvent, Friends, type InboxItem, type Person, type Requests, type Sent, } from "./friends.ts";
|
|
23
|
+
export type { Jwk, JwkSet } from "./jwks.ts";
|
|
24
|
+
export { type Callback, type LoginOptions, parseCallback, type PendingLogin, pkceChallenge, type StartedLogin, startLogin, } from "./login.ts";
|
|
25
|
+
export { safeReturnTo } from "./return-to.ts";
|
|
26
|
+
export { type AccessClaims, Accounts, type AccountsOptions, type Config, hasScope, type IdClaims, ISSUER, type SignedIn, toddgriffin, type TokenSet, } from "./tokens.ts";
|
|
27
|
+
export { VERSION } from "./version.ts";
|
|
28
|
+
export { verifyWebhook, WEBHOOK_HEADER, WEBHOOK_TOLERANCE_SECONDS, type WebhookEvent } from "./webhook.ts";
|
package/dist/mod.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for Todd Griffin's Projects accounts (`accounts.toddgriffin.me`): what a project's backend needs to
|
|
3
|
+
* sign people in, check tokens, verify webhooks, and use friends. The TypeScript half of `toddstack-accounts`
|
|
4
|
+
* (same features as the Rust crate). Web Platform APIs only: runs on Node 20+, Deno and Bun.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { Accounts, toddgriffin } from "@todd/toddstack-accounts";
|
|
8
|
+
*
|
|
9
|
+
* const accounts = new Accounts(toddgriffin("boggledygook", clientSecret, "https://www.boggledygook.com/auth/callback"));
|
|
10
|
+
*
|
|
11
|
+
* // GET /auth/login: redirect to `url`; keep `pending` server-side or in an encrypted HttpOnly cookie.
|
|
12
|
+
* const { url, pending } = await accounts.startLogin({ scopes: ["profile", "offline_access"], returnTo: "/play" });
|
|
13
|
+
*
|
|
14
|
+
* // GET /auth/callback?code=..&state=..
|
|
15
|
+
* const signedIn = await accounts.finishLogin(pending, new URL(request.url).search);
|
|
16
|
+
* console.log(signedIn.claims.sub);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
export { AccountsError } from "./error.js";
|
|
22
|
+
export { ALL_KINDS, Friends, } from "./friends.js";
|
|
23
|
+
export { parseCallback, pkceChallenge, startLogin, } from "./login.js";
|
|
24
|
+
export { safeReturnTo } from "./return-to.js";
|
|
25
|
+
export { Accounts, hasScope, ISSUER, toddgriffin, } from "./tokens.js";
|
|
26
|
+
export { VERSION } from "./version.js";
|
|
27
|
+
export { verifyWebhook, WEBHOOK_HEADER, WEBHOOK_TOLERANCE_SECONDS } from "./webhook.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Post-sign-in redirects that can't leave your site.
|
|
2
|
+
/**
|
|
3
|
+
* `target` if it is a path on this site, else null. Refuses `//host`, `/\host`, absolute URLs, control
|
|
4
|
+
* characters and anything over 1000 bytes: the shapes open-redirect attacks use.
|
|
5
|
+
*/
|
|
6
|
+
export function safeReturnTo(target) {
|
|
7
|
+
const ok = target.startsWith("/") &&
|
|
8
|
+
!target.startsWith("//") &&
|
|
9
|
+
!target.startsWith("/\\") &&
|
|
10
|
+
// deno-lint-ignore no-control-regex
|
|
11
|
+
!/[\u0000-\u001f\u007f-\u009f\\]/.test(target) &&
|
|
12
|
+
new TextEncoder().encode(target).length < 1000;
|
|
13
|
+
return ok ? target : null;
|
|
14
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Friends } from "./friends.ts";
|
|
2
|
+
import { type JwkSet } from "./jwks.ts";
|
|
3
|
+
import { type LoginOptions, type PendingLogin, type StartedLogin } from "./login.ts";
|
|
4
|
+
/** The production issuer. */
|
|
5
|
+
export declare const ISSUER = "https://accounts.toddgriffin.me/oidc";
|
|
6
|
+
/** Your app's registration with the accounts service. */
|
|
7
|
+
export interface Config {
|
|
8
|
+
/** `https://accounts.toddgriffin.me/oidc` in production. */
|
|
9
|
+
issuer: string;
|
|
10
|
+
/** Your client id, as registered. */
|
|
11
|
+
clientId: string;
|
|
12
|
+
/** Null for public clients (native and desktop apps). */
|
|
13
|
+
clientSecret: string | null;
|
|
14
|
+
/** Exactly as registered. */
|
|
15
|
+
redirectUri: string;
|
|
16
|
+
}
|
|
17
|
+
/** A config for the production accounts service. */
|
|
18
|
+
export declare function toddgriffin(clientId: string, clientSecret: string | null, redirectUri: string): Config;
|
|
19
|
+
/** The token endpoint's answer. */
|
|
20
|
+
export interface TokenSet {
|
|
21
|
+
/** For calls to the accounts API (10 minutes). */
|
|
22
|
+
accessToken: string;
|
|
23
|
+
/** The signed ID token. */
|
|
24
|
+
idToken: string;
|
|
25
|
+
/**
|
|
26
|
+
* Present when `offline_access` was granted. Rotates on every refresh: always store the new one, and never
|
|
27
|
+
* reuse an old one (that revokes the whole family).
|
|
28
|
+
*/
|
|
29
|
+
refreshToken: string | null;
|
|
30
|
+
/** Access token lifetime, seconds. */
|
|
31
|
+
expiresIn: number;
|
|
32
|
+
/** The scopes granted, space-separated. */
|
|
33
|
+
scope: string;
|
|
34
|
+
}
|
|
35
|
+
/** Verified ID token claims (standard claim names). */
|
|
36
|
+
export interface IdClaims {
|
|
37
|
+
/** The person's permanent id. Key everything on this. */
|
|
38
|
+
sub: string;
|
|
39
|
+
/** Their username (scope `profile`). */
|
|
40
|
+
username?: string;
|
|
41
|
+
/** Display name (scope `profile`). */
|
|
42
|
+
name?: string;
|
|
43
|
+
/** Picture URL (scope `profile`). */
|
|
44
|
+
picture?: string;
|
|
45
|
+
/** Email (scope `email`). */
|
|
46
|
+
email?: string;
|
|
47
|
+
/** Whether the email was verified. */
|
|
48
|
+
email_verified?: boolean;
|
|
49
|
+
/** When they last proved who they are (Unix seconds). */
|
|
50
|
+
auth_time?: number;
|
|
51
|
+
/** How: `pwd`, `otp`, `hwk` (passkey), `fed` (social), `mfa`, ... */
|
|
52
|
+
amr: string[];
|
|
53
|
+
/** The nonce from the sign-in request. */
|
|
54
|
+
nonce?: string;
|
|
55
|
+
/** Any other claim. */
|
|
56
|
+
[claim: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
/** Verified access token claims (RFC 9068). */
|
|
59
|
+
export interface AccessClaims {
|
|
60
|
+
/** The person's permanent id. */
|
|
61
|
+
sub: string;
|
|
62
|
+
/** The app the token was issued to (always your client id once verified). */
|
|
63
|
+
client_id: string;
|
|
64
|
+
/** The scopes granted, space-separated; see `hasScope`. */
|
|
65
|
+
scope: string;
|
|
66
|
+
/** Expiry, Unix seconds. */
|
|
67
|
+
exp: number;
|
|
68
|
+
/** Any other claim. */
|
|
69
|
+
[claim: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
/** Whether `claims` grant `scope`. */
|
|
72
|
+
export declare function hasScope(claims: AccessClaims, scope: string): boolean;
|
|
73
|
+
/** A finished sign-in. */
|
|
74
|
+
export interface SignedIn {
|
|
75
|
+
/** Verified ID token claims. */
|
|
76
|
+
claims: IdClaims;
|
|
77
|
+
/** The tokens; store the refresh token if you need it. */
|
|
78
|
+
tokens: TokenSet;
|
|
79
|
+
/** The checked path from `startLogin`, if any. */
|
|
80
|
+
returnTo: string | null;
|
|
81
|
+
}
|
|
82
|
+
/** Construction options. */
|
|
83
|
+
export interface AccountsOptions {
|
|
84
|
+
/** Verify tokens against these keys and never fetch them: for tests, or a service that pins keys. */
|
|
85
|
+
keys?: JwkSet;
|
|
86
|
+
/** The `fetch` to use (defaults to the global one). */
|
|
87
|
+
fetch?: typeof fetch;
|
|
88
|
+
}
|
|
89
|
+
/** The client. Create one per process and share it; it caches the signing keys. */
|
|
90
|
+
export declare class Accounts {
|
|
91
|
+
#private;
|
|
92
|
+
/** This client's registration. */
|
|
93
|
+
readonly config: Config;
|
|
94
|
+
/** A client for `config`. Throws `config` if the client id is empty. */
|
|
95
|
+
constructor(config: Config, options?: AccountsOptions);
|
|
96
|
+
/** Starts a sign-in: redirect the person to `url` and keep `pending` (see `startLogin`). */
|
|
97
|
+
startLogin(options?: LoginOptions): Promise<StartedLogin>;
|
|
98
|
+
/**
|
|
99
|
+
* Completes a sign-in from the callback's query: checks `state` and `iss`, exchanges the code, and verifies
|
|
100
|
+
* the ID token (including the nonce).
|
|
101
|
+
*/
|
|
102
|
+
finishLogin(pending: PendingLogin, callbackQuery: string | URLSearchParams): Promise<SignedIn>;
|
|
103
|
+
/**
|
|
104
|
+
* Swaps a refresh token for new tokens. Store the new refresh token. A `token_refused` error
|
|
105
|
+
* (`invalid_grant`) means it's expired, reused or the person removed your app: sign them in again.
|
|
106
|
+
*/
|
|
107
|
+
refresh(refreshToken: string): Promise<TokenSet>;
|
|
108
|
+
/** Revokes a refresh token (and its whole family), e.g. on sign-out. */
|
|
109
|
+
revoke(refreshToken: string): Promise<void>;
|
|
110
|
+
/** Verifies an ID token: signature, ES256, issuer, audience (your client id), expiry, and the nonce if given. */
|
|
111
|
+
verifyIdToken(token: string, nonce?: string): Promise<IdClaims>;
|
|
112
|
+
/**
|
|
113
|
+
* Verifies an access token sent to *your* API (for example by your native app): signature, `typ: at+jwt`,
|
|
114
|
+
* issuer, audience = your client id, expiry. Tokens issued to other apps are refused.
|
|
115
|
+
*/
|
|
116
|
+
verifyAccessToken(token: string): Promise<AccessClaims>;
|
|
117
|
+
/** The person's profile from `/me`. */
|
|
118
|
+
userinfo(accessToken: string): Promise<Record<string, unknown>>;
|
|
119
|
+
/** The friends, events and inbox API for one person. */
|
|
120
|
+
friends(accessToken: string): Friends;
|
|
121
|
+
/** One authenticated JSON call; errors carry the API's message. Used by `Friends`. */
|
|
122
|
+
call(token: string, method: string, url: string, body?: unknown): Promise<unknown>;
|
|
123
|
+
}
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Tokens: exchanging codes, refreshing, revoking, and verifying ID and access tokens.
|
|
2
|
+
import { fromBase64, fromUtf8, utf8 } from "./encoding.js";
|
|
3
|
+
import { AccountsError } from "./error.js";
|
|
4
|
+
import { Friends } from "./friends.js";
|
|
5
|
+
import { Keys } from "./jwks.js";
|
|
6
|
+
import { parseCallback, startLogin } from "./login.js";
|
|
7
|
+
import { VERSION } from "./version.js";
|
|
8
|
+
/** The production issuer. */
|
|
9
|
+
export const ISSUER = "https://accounts.toddgriffin.me/oidc";
|
|
10
|
+
/** A config for the production accounts service. */
|
|
11
|
+
export function toddgriffin(clientId, clientSecret, redirectUri) {
|
|
12
|
+
return { issuer: ISSUER, clientId, clientSecret, redirectUri };
|
|
13
|
+
}
|
|
14
|
+
/** Whether `claims` grant `scope`. */
|
|
15
|
+
export function hasScope(claims, scope) {
|
|
16
|
+
return claims.scope.split(" ").includes(scope);
|
|
17
|
+
}
|
|
18
|
+
/** Allowed clock skew for `exp`, seconds. */
|
|
19
|
+
const LEEWAY_SECONDS = 30;
|
|
20
|
+
/** The client. Create one per process and share it; it caches the signing keys. */
|
|
21
|
+
export class Accounts {
|
|
22
|
+
/** This client's registration. */
|
|
23
|
+
config;
|
|
24
|
+
#fetch;
|
|
25
|
+
#keys;
|
|
26
|
+
/** A client for `config`. Throws `config` if the client id is empty. */
|
|
27
|
+
constructor(config, options = {}) {
|
|
28
|
+
if (!config.clientId)
|
|
29
|
+
throw new AccountsError("config", "configuration: clientId is required");
|
|
30
|
+
this.config = { ...config, issuer: config.issuer.replace(/\/+$/, "") };
|
|
31
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
32
|
+
this.#keys = options.keys ? Keys.pinned(options.keys) : Keys.remote(`${this.config.issuer}/jwks`, this.#fetch);
|
|
33
|
+
}
|
|
34
|
+
/** Starts a sign-in: redirect the person to `url` and keep `pending` (see `startLogin`). */
|
|
35
|
+
startLogin(options = {}) {
|
|
36
|
+
return startLogin(this.config, options);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Completes a sign-in from the callback's query: checks `state` and `iss`, exchanges the code, and verifies
|
|
40
|
+
* the ID token (including the nonce).
|
|
41
|
+
*/
|
|
42
|
+
async finishLogin(pending, callbackQuery) {
|
|
43
|
+
const callback = parseCallback(callbackQuery);
|
|
44
|
+
const mismatch = "the sign-in callback doesn't match this browser's pending sign-in";
|
|
45
|
+
if (callback.state !== pending.state)
|
|
46
|
+
throw new AccountsError("state_mismatch", mismatch);
|
|
47
|
+
if (callback.iss !== null && callback.iss !== this.config.issuer) {
|
|
48
|
+
throw new AccountsError("state_mismatch", mismatch);
|
|
49
|
+
}
|
|
50
|
+
if (callback.error !== null) {
|
|
51
|
+
throw new AccountsError("authorization", `sign-in failed: ${callback.error}`, {
|
|
52
|
+
error: callback.error,
|
|
53
|
+
description: callback.errorDescription ?? undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (callback.code === null)
|
|
57
|
+
throw new AccountsError("missing_code", "the callback has no code");
|
|
58
|
+
const tokens = await this.#tokenRequest({
|
|
59
|
+
grant_type: "authorization_code",
|
|
60
|
+
code: callback.code,
|
|
61
|
+
redirect_uri: this.config.redirectUri,
|
|
62
|
+
code_verifier: pending.verifier,
|
|
63
|
+
});
|
|
64
|
+
const claims = await this.verifyIdToken(tokens.idToken, pending.nonce);
|
|
65
|
+
return { claims, tokens, returnTo: pending.returnTo };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Swaps a refresh token for new tokens. Store the new refresh token. A `token_refused` error
|
|
69
|
+
* (`invalid_grant`) means it's expired, reused or the person removed your app: sign them in again.
|
|
70
|
+
*/
|
|
71
|
+
refresh(refreshToken) {
|
|
72
|
+
return this.#tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
73
|
+
}
|
|
74
|
+
/** Revokes a refresh token (and its whole family), e.g. on sign-out. */
|
|
75
|
+
async revoke(refreshToken) {
|
|
76
|
+
const response = await this.#post(`${this.config.issuer}/token/revocation`, {
|
|
77
|
+
token: refreshToken,
|
|
78
|
+
token_type_hint: "refresh_token",
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new AccountsError("api", `accounts API returned ${response.status}`, { status: response.status });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Verifies an ID token: signature, ES256, issuer, audience (your client id), expiry, and the nonce if given. */
|
|
85
|
+
async verifyIdToken(token, nonce) {
|
|
86
|
+
const claims = await this.#decode(token, null);
|
|
87
|
+
if (nonce !== undefined && claims.nonce !== nonce) {
|
|
88
|
+
throw new AccountsError("invalid_token", "token rejected: nonce mismatch");
|
|
89
|
+
}
|
|
90
|
+
return { ...claims, amr: Array.isArray(claims.amr) ? claims.amr : [] };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Verifies an access token sent to *your* API (for example by your native app): signature, `typ: at+jwt`,
|
|
94
|
+
* issuer, audience = your client id, expiry. Tokens issued to other apps are refused.
|
|
95
|
+
*/
|
|
96
|
+
async verifyAccessToken(token) {
|
|
97
|
+
return (await this.#decode(token, "at+jwt"));
|
|
98
|
+
}
|
|
99
|
+
/** The person's profile from `/me`. */
|
|
100
|
+
userinfo(accessToken) {
|
|
101
|
+
return this.call(accessToken, "GET", `${this.config.issuer}/me`);
|
|
102
|
+
}
|
|
103
|
+
/** The friends, events and inbox API for one person. */
|
|
104
|
+
friends(accessToken) {
|
|
105
|
+
return new Friends(this, accessToken);
|
|
106
|
+
}
|
|
107
|
+
/** One authenticated JSON call; errors carry the API's message. Used by `Friends`. */
|
|
108
|
+
async call(token, method, url, body) {
|
|
109
|
+
const headers = {
|
|
110
|
+
authorization: `Bearer ${token}`,
|
|
111
|
+
"user-agent": `toddstack-accounts/${VERSION}`,
|
|
112
|
+
};
|
|
113
|
+
if (body !== undefined)
|
|
114
|
+
headers["content-type"] = "application/json";
|
|
115
|
+
const response = await this.#send(url, {
|
|
116
|
+
method,
|
|
117
|
+
headers,
|
|
118
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
119
|
+
});
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
let message = text;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(text);
|
|
125
|
+
if (typeof parsed?.error === "string")
|
|
126
|
+
message = parsed.error;
|
|
127
|
+
}
|
|
128
|
+
catch { /* not JSON: keep the text */ }
|
|
129
|
+
throw new AccountsError("api", `accounts API returned ${response.status}: ${message}`, {
|
|
130
|
+
status: response.status,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (text === "")
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(text);
|
|
137
|
+
}
|
|
138
|
+
catch (cause) {
|
|
139
|
+
throw new AccountsError("api", `accounts API returned ${response.status}: ${cause}`, {
|
|
140
|
+
status: response.status,
|
|
141
|
+
cause,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async #send(url, init) {
|
|
146
|
+
try {
|
|
147
|
+
return await this.#fetch(url, { ...init, signal: AbortSignal.timeout(10_000) });
|
|
148
|
+
}
|
|
149
|
+
catch (cause) {
|
|
150
|
+
throw new AccountsError("http", `http: ${cause}`, { cause });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** A form POST with client authentication (Basic for confidential clients, `client_id` for public ones). */
|
|
154
|
+
#post(url, params) {
|
|
155
|
+
const form = new URLSearchParams(params);
|
|
156
|
+
const headers = {
|
|
157
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
158
|
+
"user-agent": `toddstack-accounts/${VERSION}`,
|
|
159
|
+
};
|
|
160
|
+
if (this.config.clientSecret !== null) {
|
|
161
|
+
// RFC 6749 2.3.1: form-encode both parts before Basic.
|
|
162
|
+
const enc = (s) => new URLSearchParams({ v: s }).toString().slice(2);
|
|
163
|
+
headers.authorization = `Basic ${btoa(`${enc(this.config.clientId)}:${enc(this.config.clientSecret)}`)}`;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
form.append("client_id", this.config.clientId);
|
|
167
|
+
}
|
|
168
|
+
return this.#send(url, { method: "POST", headers, body: form.toString() });
|
|
169
|
+
}
|
|
170
|
+
async #tokenRequest(params) {
|
|
171
|
+
const response = await this.#post(`${this.config.issuer}/token`, params);
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
let error = `http_${response.status}`;
|
|
174
|
+
let description;
|
|
175
|
+
try {
|
|
176
|
+
const body = await response.json();
|
|
177
|
+
if (typeof body?.error === "string") {
|
|
178
|
+
error = body.error;
|
|
179
|
+
description = typeof body.error_description === "string" ? body.error_description : undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* not JSON */ }
|
|
183
|
+
throw new AccountsError("token_refused", `token request refused: ${error}`, { error, description });
|
|
184
|
+
}
|
|
185
|
+
const raw = (await response.json());
|
|
186
|
+
return {
|
|
187
|
+
accessToken: raw.access_token,
|
|
188
|
+
idToken: raw.id_token,
|
|
189
|
+
refreshToken: raw.refresh_token ?? null,
|
|
190
|
+
expiresIn: raw.expires_in,
|
|
191
|
+
scope: raw.scope,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
async #decode(token, typ) {
|
|
195
|
+
const reject = (why) => new AccountsError("invalid_token", `token rejected: ${why}`);
|
|
196
|
+
const parts = token.split(".");
|
|
197
|
+
if (parts.length !== 3)
|
|
198
|
+
throw reject("malformed token");
|
|
199
|
+
let header;
|
|
200
|
+
let claims;
|
|
201
|
+
let signature;
|
|
202
|
+
try {
|
|
203
|
+
header = JSON.parse(fromUtf8(fromBase64(parts[0])));
|
|
204
|
+
claims = JSON.parse(fromUtf8(fromBase64(parts[1])));
|
|
205
|
+
signature = fromBase64(parts[2]);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw reject("malformed token");
|
|
209
|
+
}
|
|
210
|
+
if (header.alg !== "ES256")
|
|
211
|
+
throw reject("unexpected algorithm");
|
|
212
|
+
if (typ !== null && header.typ?.toLowerCase() !== typ)
|
|
213
|
+
throw reject("not an access token");
|
|
214
|
+
if (!header.kid)
|
|
215
|
+
throw reject("no kid");
|
|
216
|
+
const key = await this.#keys.get(header.kid);
|
|
217
|
+
const ok = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature, utf8(`${parts[0]}.${parts[1]}`));
|
|
218
|
+
if (!ok)
|
|
219
|
+
throw reject("InvalidSignature");
|
|
220
|
+
if (claims.iss !== this.config.issuer)
|
|
221
|
+
throw reject("InvalidIssuer");
|
|
222
|
+
const aud = claims.aud;
|
|
223
|
+
if (!(aud === this.config.clientId || (Array.isArray(aud) && aud.includes(this.config.clientId)))) {
|
|
224
|
+
throw reject("InvalidAudience");
|
|
225
|
+
}
|
|
226
|
+
if (typeof claims.exp !== "number")
|
|
227
|
+
throw reject("Missing required claim: exp");
|
|
228
|
+
if (claims.exp < Math.floor(Date.now() / 1000) - LEEWAY_SECONDS)
|
|
229
|
+
throw reject("ExpiredSignature");
|
|
230
|
+
return claims;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The header name. */
|
|
2
|
+
export declare const WEBHOOK_HEADER = "X-Accounts-Signature";
|
|
3
|
+
/** How old a signature may be, seconds. */
|
|
4
|
+
export declare const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
5
|
+
/** A verified event. */
|
|
6
|
+
export interface WebhookEvent {
|
|
7
|
+
/** Unique per event; repeated on retries. */
|
|
8
|
+
id: string;
|
|
9
|
+
/** `user.updated`, `user.deleted`, `user.suspended`, `user.reactivated`, `user.signed_out`, or `grant.revoked`. */
|
|
10
|
+
type: string;
|
|
11
|
+
/** Unix seconds. */
|
|
12
|
+
createdAt: number;
|
|
13
|
+
/** Always has `sub` (the person the event is about); `user.updated` adds the fields that changed. */
|
|
14
|
+
data: {
|
|
15
|
+
sub?: string;
|
|
16
|
+
[field: string]: unknown;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Checks the signature and age, then parses the event. `body` is the raw request body (text or bytes);
|
|
21
|
+
* `now` is the current Unix time in seconds (a parameter so tests can fix it).
|
|
22
|
+
*/
|
|
23
|
+
export declare function verifyWebhook(secret: string | Uint8Array, signatureHeader: string | null | undefined, body: string | Uint8Array, now?: number): Promise<WebhookEvent>;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Verifying webhooks from the accounts service.
|
|
2
|
+
//
|
|
3
|
+
// Each delivery is a POST with JSON and `X-Accounts-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of
|
|
4
|
+
// "<t>.<raw body>">` under your app's webhook secret. Verify against the raw body, before parsing. Deliveries
|
|
5
|
+
// can repeat (retries); use the event `id` to ignore duplicates.
|
|
6
|
+
import { utf8 } from "./encoding.js";
|
|
7
|
+
import { AccountsError } from "./error.js";
|
|
8
|
+
/** The header name. */
|
|
9
|
+
export const WEBHOOK_HEADER = "X-Accounts-Signature";
|
|
10
|
+
/** How old a signature may be, seconds. */
|
|
11
|
+
export const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
12
|
+
function reject(why) {
|
|
13
|
+
return new AccountsError("invalid_webhook", `webhook rejected: ${why}`);
|
|
14
|
+
}
|
|
15
|
+
function fromHex(hex) {
|
|
16
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex))
|
|
17
|
+
return null;
|
|
18
|
+
const out = new Uint8Array(hex.length / 2);
|
|
19
|
+
for (let i = 0; i < out.length; i++)
|
|
20
|
+
out[i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Checks the signature and age, then parses the event. `body` is the raw request body (text or bytes);
|
|
25
|
+
* `now` is the current Unix time in seconds (a parameter so tests can fix it).
|
|
26
|
+
*/
|
|
27
|
+
export async function verifyWebhook(secret, signatureHeader, body, now = Math.floor(Date.now() / 1000)) {
|
|
28
|
+
let t = null;
|
|
29
|
+
let v1 = null;
|
|
30
|
+
for (const part of (signatureHeader ?? "").split(",")) {
|
|
31
|
+
const eq = part.indexOf("=");
|
|
32
|
+
if (eq < 0)
|
|
33
|
+
continue;
|
|
34
|
+
const [k, value] = [part.slice(0, eq).trim(), part.slice(eq + 1).trim()];
|
|
35
|
+
if (k === "t")
|
|
36
|
+
t = /^-?\d+$/.test(value) ? Number(value) : null;
|
|
37
|
+
if (k === "v1")
|
|
38
|
+
v1 = fromHex(value);
|
|
39
|
+
}
|
|
40
|
+
if (t === null || v1 === null)
|
|
41
|
+
throw reject("malformed signature header");
|
|
42
|
+
if (Math.abs(now - t) > WEBHOOK_TOLERANCE_SECONDS)
|
|
43
|
+
throw reject("signature too old (or from the future)");
|
|
44
|
+
const bodyBytes = typeof body === "string" ? utf8(body) : body;
|
|
45
|
+
const signed = new Uint8Array([...utf8(`${t}.`), ...bodyBytes]);
|
|
46
|
+
const keyBytes = typeof secret === "string" ? utf8(secret) : new Uint8Array(secret);
|
|
47
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
48
|
+
// WebCrypto's verify compares in constant time.
|
|
49
|
+
if (!(await crypto.subtle.verify("HMAC", key, v1, signed)))
|
|
50
|
+
throw reject("signature mismatch");
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw reject("body is not an event");
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw?.id !== "string" || typeof raw.type !== "string" || typeof raw.created_at !== "number" ||
|
|
59
|
+
raw.data === undefined) {
|
|
60
|
+
throw reject("body is not an event");
|
|
61
|
+
}
|
|
62
|
+
return { id: raw.id, type: raw.type, createdAt: raw.created_at, data: raw.data };
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toddstack-accounts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client for Todd Griffin's Projects accounts: OIDC sign-in, token checks, signed webhooks, friends and events.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Todd Everett Griffin",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/goddtriffin/toddstack.git",
|
|
10
|
+
"directory": "typescript/accounts"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/goddtriffin/toddstack/tree/main/typescript/accounts",
|
|
13
|
+
"keywords": ["oidc", "accounts", "friends", "toddstack"],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": { ".": { "types": "./dist/mod.d.ts", "default": "./dist/mod.js" } },
|
|
16
|
+
"main": "./dist/mod.js",
|
|
17
|
+
"types": "./dist/mod.d.ts",
|
|
18
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"engines": { "node": ">=20" }
|
|
21
|
+
}
|