@shaferllc/keel 0.59.0 → 0.66.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/dist/core/application.js +12 -0
- package/dist/core/auth.d.ts +47 -0
- package/dist/core/auth.js +77 -0
- package/dist/core/authorization.d.ts +9 -1
- package/dist/core/authorization.js +22 -2
- package/dist/core/container.d.ts +20 -0
- package/dist/core/container.js +52 -0
- package/dist/core/cors.d.ts +29 -0
- package/dist/core/cors.js +72 -0
- package/dist/core/crypto.d.ts +40 -4
- package/dist/core/crypto.js +66 -6
- package/dist/core/csrf.d.ts +25 -0
- package/dist/core/csrf.js +78 -0
- package/dist/core/database.d.ts +49 -4
- package/dist/core/database.js +89 -21
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/index.d.ts +18 -8
- package/dist/core/index.js +9 -4
- package/dist/core/model.d.ts +2 -0
- package/dist/core/model.js +16 -14
- package/dist/core/provider.d.ts +7 -0
- package/dist/core/provider.js +7 -0
- package/dist/core/rate-limit.js +3 -0
- package/dist/core/relations.js +13 -13
- package/dist/core/request.d.ts +26 -0
- package/dist/core/request.js +77 -0
- package/dist/core/shield.d.ts +39 -0
- package/dist/core/shield.js +60 -0
- package/dist/core/social.d.ts +173 -0
- package/dist/core/social.js +337 -0
- package/dist/core/tokens.d.ts +74 -0
- package/dist/core/tokens.js +155 -0
- package/dist/db/d1.d.ts +32 -0
- package/dist/db/d1.js +26 -0
- package/dist/db/libsql.d.ts +29 -0
- package/dist/db/libsql.js +32 -0
- package/dist/db/pg.d.ts +29 -0
- package/dist/db/pg.js +33 -0
- package/package.json +13 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". Like Adonis
|
|
3
|
+
* Ally, this owns the OAuth dance only: it hands you a normalized `SocialUser`,
|
|
4
|
+
* and *you* find-or-create your own user and log them in (with a session,
|
|
5
|
+
* `jwt`, or an access `token`). It stores nothing.
|
|
6
|
+
*
|
|
7
|
+
* const github = social.github({ clientId, clientSecret, redirectUri });
|
|
8
|
+
*
|
|
9
|
+
* // 1. send the user off to the provider
|
|
10
|
+
* router.get("/auth/github", () => redirect(github.redirect({ state })));
|
|
11
|
+
*
|
|
12
|
+
* // 2. handle the callback
|
|
13
|
+
* router.get("/auth/github/callback", async () => {
|
|
14
|
+
* const gh = await github.user(request.query("code")); // { id, email, name, … }
|
|
15
|
+
* const user = await users.firstOrCreate({ github_id: gh.id }, { email: gh.email });
|
|
16
|
+
* auth().login(user.id);
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* Every driver is `fetch`-based — no SDK, no native deps — so it runs on Node and
|
|
20
|
+
* the edge alike. Presets cover GitHub, Google, and Discord; build your own with
|
|
21
|
+
* `oauthDriver()` for anything else OAuth2.
|
|
22
|
+
*/
|
|
23
|
+
/** Thrown when the token exchange or profile fetch fails. */
|
|
24
|
+
export class OAuthError extends Error {
|
|
25
|
+
provider;
|
|
26
|
+
constructor(message, provider) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.provider = provider;
|
|
29
|
+
this.name = "OAuthError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A random, URL-safe `state` for CSRF protection — stash it, then verify on callback. */
|
|
33
|
+
export function oauthState(bytes = 16) {
|
|
34
|
+
const raw = crypto.getRandomValues(new Uint8Array(bytes));
|
|
35
|
+
let s = "";
|
|
36
|
+
for (const b of raw)
|
|
37
|
+
s += String.fromCharCode(b);
|
|
38
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
39
|
+
}
|
|
40
|
+
/** A generic OAuth 2.0 authorization-code driver. */
|
|
41
|
+
export class OAuthDriver {
|
|
42
|
+
spec;
|
|
43
|
+
config;
|
|
44
|
+
constructor(spec, config) {
|
|
45
|
+
this.spec = spec;
|
|
46
|
+
this.config = config;
|
|
47
|
+
}
|
|
48
|
+
/** Build the provider's authorize URL to redirect the user to. */
|
|
49
|
+
redirect(options = {}) {
|
|
50
|
+
const scopes = options.scopes ?? this.config.scopes ?? this.spec.defaultScopes;
|
|
51
|
+
const url = new URL(this.spec.authorizeUrl);
|
|
52
|
+
url.searchParams.set("client_id", this.config.clientId);
|
|
53
|
+
url.searchParams.set("redirect_uri", this.config.redirectUri);
|
|
54
|
+
url.searchParams.set("response_type", "code");
|
|
55
|
+
if (scopes.length)
|
|
56
|
+
url.searchParams.set("scope", scopes.join(this.spec.scopeSeparator ?? " "));
|
|
57
|
+
if (options.state)
|
|
58
|
+
url.searchParams.set("state", options.state);
|
|
59
|
+
for (const [key, value] of Object.entries(options.params ?? {}))
|
|
60
|
+
url.searchParams.set(key, value);
|
|
61
|
+
return url.toString();
|
|
62
|
+
}
|
|
63
|
+
/** Exchange an authorization `code` (from the callback) for an access token. */
|
|
64
|
+
async exchangeCode(code) {
|
|
65
|
+
const res = await fetch(this.spec.tokenUrl, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
68
|
+
body: new URLSearchParams({
|
|
69
|
+
client_id: this.config.clientId,
|
|
70
|
+
client_secret: this.config.clientSecret,
|
|
71
|
+
code,
|
|
72
|
+
redirect_uri: this.config.redirectUri,
|
|
73
|
+
grant_type: "authorization_code",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const data = (await res.json().catch(() => ({})));
|
|
77
|
+
if (!res.ok || data.error) {
|
|
78
|
+
throw new OAuthError(`Token exchange failed: ${data.error_description ?? data.error ?? res.status}`, this.spec.name);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
accessToken: String(data.access_token),
|
|
82
|
+
tokenType: data.token_type,
|
|
83
|
+
refreshToken: data.refresh_token,
|
|
84
|
+
expiresIn: data.expires_in,
|
|
85
|
+
scope: data.scope,
|
|
86
|
+
raw: data,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Fetch the normalized user for an already-obtained access token. */
|
|
90
|
+
userFromToken(token) {
|
|
91
|
+
return this.spec.fetchUser(token);
|
|
92
|
+
}
|
|
93
|
+
/** The full callback step: exchange the `code`, then fetch the user. */
|
|
94
|
+
async user(code) {
|
|
95
|
+
return this.userFromToken(await this.exchangeCode(code));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Build a driver for any OAuth2 provider from a spec + config. */
|
|
99
|
+
export function oauthDriver(spec, config) {
|
|
100
|
+
return new OAuthDriver(spec, config);
|
|
101
|
+
}
|
|
102
|
+
/* -------------------------------- helpers ------------------------------- */
|
|
103
|
+
async function getJson(url, token, headers = {}) {
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
headers: { authorization: `Bearer ${token.accessToken}`, accept: "application/json", ...headers },
|
|
106
|
+
});
|
|
107
|
+
return (await res.json().catch(() => ({})));
|
|
108
|
+
}
|
|
109
|
+
/* ------------------------------- providers ------------------------------ */
|
|
110
|
+
/** GitHub OAuth (`user:email` gives access to a verified primary email). */
|
|
111
|
+
export function github(config) {
|
|
112
|
+
return new OAuthDriver({
|
|
113
|
+
name: "github",
|
|
114
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
115
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
116
|
+
defaultScopes: ["read:user", "user:email"],
|
|
117
|
+
async fetchUser(token) {
|
|
118
|
+
const headers = { "user-agent": "keel", accept: "application/vnd.github+json" };
|
|
119
|
+
const data = await getJson("https://api.github.com/user", token, headers);
|
|
120
|
+
let email = data.email ?? null;
|
|
121
|
+
if (!email) {
|
|
122
|
+
// The public profile hides email unless set — pull the verified primary.
|
|
123
|
+
const emails = (await getJson("https://api.github.com/user/emails", token, headers));
|
|
124
|
+
if (Array.isArray(emails)) {
|
|
125
|
+
email = emails.find((e) => e.primary && e.verified)?.email ?? emails[0]?.email ?? null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
id: String(data.id),
|
|
130
|
+
email,
|
|
131
|
+
name: data.name ?? null,
|
|
132
|
+
nickname: data.login ?? null,
|
|
133
|
+
avatarUrl: data.avatar_url ?? null,
|
|
134
|
+
token,
|
|
135
|
+
raw: data,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
}, config);
|
|
139
|
+
}
|
|
140
|
+
/** Google OAuth / OpenID Connect. */
|
|
141
|
+
export function google(config) {
|
|
142
|
+
return new OAuthDriver({
|
|
143
|
+
name: "google",
|
|
144
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
145
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
146
|
+
defaultScopes: ["openid", "email", "profile"],
|
|
147
|
+
async fetchUser(token) {
|
|
148
|
+
const data = await getJson("https://openidconnect.googleapis.com/v1/userinfo", token);
|
|
149
|
+
return {
|
|
150
|
+
id: String(data.sub),
|
|
151
|
+
email: data.email ?? null,
|
|
152
|
+
name: data.name ?? null,
|
|
153
|
+
nickname: data.given_name ?? data.email ?? null,
|
|
154
|
+
avatarUrl: data.picture ?? null,
|
|
155
|
+
token,
|
|
156
|
+
raw: data,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
}, config);
|
|
160
|
+
}
|
|
161
|
+
/** Discord OAuth. */
|
|
162
|
+
export function discord(config) {
|
|
163
|
+
return new OAuthDriver({
|
|
164
|
+
name: "discord",
|
|
165
|
+
authorizeUrl: "https://discord.com/oauth2/authorize",
|
|
166
|
+
tokenUrl: "https://discord.com/api/oauth2/token",
|
|
167
|
+
defaultScopes: ["identify", "email"],
|
|
168
|
+
async fetchUser(token) {
|
|
169
|
+
const data = await getJson("https://discord.com/api/users/@me", token);
|
|
170
|
+
const id = String(data.id);
|
|
171
|
+
const avatar = data.avatar;
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
email: data.email ?? null,
|
|
175
|
+
name: data.global_name ?? data.username ?? null,
|
|
176
|
+
nickname: data.username ?? null,
|
|
177
|
+
avatarUrl: avatar ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.png` : null,
|
|
178
|
+
token,
|
|
179
|
+
raw: data,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
}, config);
|
|
183
|
+
}
|
|
184
|
+
/** Percent-encode per RFC 3986 (OAuth's stricter rules — encodes `!*'()` too). */
|
|
185
|
+
function pctEncode(value) {
|
|
186
|
+
return encodeURIComponent(value).replace(/[!*'()]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
187
|
+
}
|
|
188
|
+
function bytesToB64(bytes) {
|
|
189
|
+
let s = "";
|
|
190
|
+
for (const b of bytes)
|
|
191
|
+
s += String.fromCharCode(b);
|
|
192
|
+
return btoa(s);
|
|
193
|
+
}
|
|
194
|
+
async function hmacSha1(base, key) {
|
|
195
|
+
const cryptoKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
196
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(base));
|
|
197
|
+
return bytesToB64(new Uint8Array(sig));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Compute an OAuth 1.0a HMAC-SHA1 signature (RFC 5849). `params` holds every
|
|
201
|
+
* signed parameter with *raw* (unencoded) values — the oauth_* fields plus any
|
|
202
|
+
* query/body params, minus `oauth_signature`. Exposed for signing custom API
|
|
203
|
+
* requests beyond the built-in flow.
|
|
204
|
+
*/
|
|
205
|
+
export async function oauth1Signature(input) {
|
|
206
|
+
const paramString = Object.keys(input.params)
|
|
207
|
+
.sort()
|
|
208
|
+
.map((k) => `${pctEncode(k)}=${pctEncode(input.params[k])}`)
|
|
209
|
+
.join("&");
|
|
210
|
+
const baseUrl = input.url.split(/[?#]/)[0]; // the base string excludes query/fragment
|
|
211
|
+
const base = [input.method.toUpperCase(), pctEncode(baseUrl), pctEncode(paramString)].join("&");
|
|
212
|
+
const key = `${pctEncode(input.consumerSecret)}&${pctEncode(input.tokenSecret ?? "")}`;
|
|
213
|
+
return hmacSha1(base, key);
|
|
214
|
+
}
|
|
215
|
+
function oauthNonce() {
|
|
216
|
+
return bytesToB64(crypto.getRandomValues(new Uint8Array(16))).replace(/[^A-Za-z0-9]/g, "");
|
|
217
|
+
}
|
|
218
|
+
function parseForm(body) {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const pair of body.split("&")) {
|
|
221
|
+
const i = pair.indexOf("=");
|
|
222
|
+
if (i === -1)
|
|
223
|
+
continue;
|
|
224
|
+
out[decodeURIComponent(pair.slice(0, i))] = decodeURIComponent(pair.slice(i + 1));
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
/** A generic OAuth 1.0a driver. */
|
|
229
|
+
export class OAuth1Driver {
|
|
230
|
+
spec;
|
|
231
|
+
config;
|
|
232
|
+
constructor(spec, config) {
|
|
233
|
+
this.spec = spec;
|
|
234
|
+
this.config = config;
|
|
235
|
+
}
|
|
236
|
+
/** Sign a request and build its `Authorization: OAuth …` header. */
|
|
237
|
+
async authHeader(method, url, extraOauth, tokenSecret, queryParams = {}) {
|
|
238
|
+
const oauth = {
|
|
239
|
+
oauth_consumer_key: this.config.clientId,
|
|
240
|
+
oauth_nonce: oauthNonce(),
|
|
241
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
242
|
+
oauth_timestamp: String(Math.floor(Date.now() / 1000)),
|
|
243
|
+
oauth_version: "1.0",
|
|
244
|
+
...extraOauth,
|
|
245
|
+
};
|
|
246
|
+
const signature = await oauth1Signature({
|
|
247
|
+
method,
|
|
248
|
+
url,
|
|
249
|
+
params: { ...oauth, ...queryParams },
|
|
250
|
+
consumerSecret: this.config.clientSecret,
|
|
251
|
+
tokenSecret,
|
|
252
|
+
});
|
|
253
|
+
const header = { ...oauth, oauth_signature: signature };
|
|
254
|
+
return ("OAuth " +
|
|
255
|
+
Object.keys(header)
|
|
256
|
+
.sort()
|
|
257
|
+
.map((k) => `${pctEncode(k)}="${pctEncode(header[k])}"`)
|
|
258
|
+
.join(", "));
|
|
259
|
+
}
|
|
260
|
+
/** Step 1 — obtain a temporary request token. Stash its `tokenSecret` for the callback. */
|
|
261
|
+
async requestToken() {
|
|
262
|
+
const header = await this.authHeader("POST", this.spec.requestTokenUrl, { oauth_callback: this.config.redirectUri }, "");
|
|
263
|
+
const res = await fetch(this.spec.requestTokenUrl, { method: "POST", headers: { authorization: header } });
|
|
264
|
+
const body = await res.text();
|
|
265
|
+
if (!res.ok)
|
|
266
|
+
throw new OAuthError(`Request token failed: ${res.status} ${body}`, this.spec.name);
|
|
267
|
+
const parsed = parseForm(body);
|
|
268
|
+
return { token: parsed.oauth_token ?? "", tokenSecret: parsed.oauth_token_secret ?? "", raw: parsed };
|
|
269
|
+
}
|
|
270
|
+
/** Step 2 — the URL to send the user to, carrying the request token. */
|
|
271
|
+
redirect(requestToken) {
|
|
272
|
+
const t = typeof requestToken === "string" ? requestToken : requestToken.token;
|
|
273
|
+
const url = new URL(this.spec.authorizeUrl);
|
|
274
|
+
url.searchParams.set("oauth_token", t);
|
|
275
|
+
return url.toString();
|
|
276
|
+
}
|
|
277
|
+
/** Step 3 — swap the callback's `oauth_token` + `oauth_verifier` for an access token. */
|
|
278
|
+
async accessToken(oauthToken, verifier, requestTokenSecret) {
|
|
279
|
+
const header = await this.authHeader("POST", this.spec.accessTokenUrl, { oauth_token: oauthToken, oauth_verifier: verifier }, requestTokenSecret);
|
|
280
|
+
const res = await fetch(this.spec.accessTokenUrl, { method: "POST", headers: { authorization: header } });
|
|
281
|
+
const body = await res.text();
|
|
282
|
+
if (!res.ok)
|
|
283
|
+
throw new OAuthError(`Access token failed: ${res.status} ${body}`, this.spec.name);
|
|
284
|
+
const parsed = parseForm(body);
|
|
285
|
+
return { token: parsed.oauth_token ?? "", tokenSecret: parsed.oauth_token_secret ?? "", raw: parsed };
|
|
286
|
+
}
|
|
287
|
+
/** The full callback step: exchange for an access token, then fetch the user. */
|
|
288
|
+
async user(oauthToken, verifier, requestTokenSecret) {
|
|
289
|
+
const token = await this.accessToken(oauthToken, verifier, requestTokenSecret);
|
|
290
|
+
return this.spec.fetchUser(token, this);
|
|
291
|
+
}
|
|
292
|
+
/** A signed GET against the provider's API on the user's behalf (for `fetchUser`). */
|
|
293
|
+
async get(url, token) {
|
|
294
|
+
const query = {};
|
|
295
|
+
new URL(url).searchParams.forEach((v, k) => (query[k] = v));
|
|
296
|
+
const header = await this.authHeader("GET", url, { oauth_token: token.token }, token.tokenSecret, query);
|
|
297
|
+
const res = await fetch(url, { headers: { authorization: header } });
|
|
298
|
+
return (await res.json().catch(() => ({})));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Build a driver for any OAuth 1.0a provider from a spec + config. */
|
|
302
|
+
export function oauth1Driver(spec, config) {
|
|
303
|
+
return new OAuth1Driver(spec, config);
|
|
304
|
+
}
|
|
305
|
+
/** Twitter / X (OAuth 1.0a). Enable "Request email" in your app settings for `email`. */
|
|
306
|
+
export function twitter(config) {
|
|
307
|
+
return new OAuth1Driver({
|
|
308
|
+
name: "twitter",
|
|
309
|
+
requestTokenUrl: "https://api.twitter.com/oauth/request_token",
|
|
310
|
+
authorizeUrl: "https://api.twitter.com/oauth/authenticate",
|
|
311
|
+
accessTokenUrl: "https://api.twitter.com/oauth/access_token",
|
|
312
|
+
async fetchUser(token, driver) {
|
|
313
|
+
const data = await driver.get("https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true", token);
|
|
314
|
+
return {
|
|
315
|
+
id: String(data.id_str ?? data.id),
|
|
316
|
+
email: data.email ?? null,
|
|
317
|
+
name: data.name ?? null,
|
|
318
|
+
nickname: data.screen_name ?? null,
|
|
319
|
+
avatarUrl: data.profile_image_url_https ?? null,
|
|
320
|
+
token,
|
|
321
|
+
raw: data,
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
}, config);
|
|
325
|
+
}
|
|
326
|
+
/** All social providers under one namespace: `social.github({...})`, `social.twitter({...})`. */
|
|
327
|
+
export const social = {
|
|
328
|
+
// OAuth 2.0
|
|
329
|
+
github,
|
|
330
|
+
google,
|
|
331
|
+
discord,
|
|
332
|
+
driver: oauthDriver,
|
|
333
|
+
state: oauthState,
|
|
334
|
+
// OAuth 1.0a
|
|
335
|
+
twitter,
|
|
336
|
+
driver1: oauth1Driver,
|
|
337
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal access tokens — opaque, database-backed bearer tokens for API and
|
|
3
|
+
* mobile clients, an alternative to the stateless [`jwt`](./crypto.ts). Unlike a
|
|
4
|
+
* JWT, an opaque token can be *revoked* instantly (it's a row you delete), carries
|
|
5
|
+
* *abilities* (scopes), and tracks *last used* — at the cost of a lookup per
|
|
6
|
+
* request. Built on the `db()` layer, so it runs anywhere a `Connection` does.
|
|
7
|
+
*
|
|
8
|
+
* const { token } = await createToken(user.id, { abilities: ["posts:write"] });
|
|
9
|
+
* // → "keel_<selector>.<verifier>" — show once, never recoverable
|
|
10
|
+
*
|
|
11
|
+
* const record = await verifyToken(token); // { tokenableId, abilities, … } | null
|
|
12
|
+
* tokenAllows(record, "posts:write"); // true
|
|
13
|
+
*
|
|
14
|
+
* Pair it with `tokenAuth()` in ./auth.ts to protect routes. The token splits
|
|
15
|
+
* into a public *selector* (indexed, for lookup) and a secret *verifier* (stored
|
|
16
|
+
* only as a SHA-256 hash) — so a leaked database can't mint working tokens, and
|
|
17
|
+
* verification needs no `RETURNING`/auto-increment (portable across every driver).
|
|
18
|
+
*
|
|
19
|
+
* Expected table (`personal_access_tokens` by default), all timestamps epoch-ms:
|
|
20
|
+
* selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
|
|
21
|
+
* abilities TEXT (JSON), last_used_at INTEGER, expires_at INTEGER, created_at INTEGER
|
|
22
|
+
*/
|
|
23
|
+
/** A verified token, as returned by `verifyToken`. */
|
|
24
|
+
export interface AccessToken {
|
|
25
|
+
/** Public lookup key (the part before the `.`). Pass to `revokeToken`. */
|
|
26
|
+
selector: string;
|
|
27
|
+
/** The id of the entity the token belongs to (usually a user id). */
|
|
28
|
+
tokenableId: string;
|
|
29
|
+
/** Optional human label, for a "your tokens" management screen. */
|
|
30
|
+
name: string | null;
|
|
31
|
+
/** Granted abilities/scopes; `["*"]` means all. */
|
|
32
|
+
abilities: string[];
|
|
33
|
+
/** Epoch-ms of last successful use, or null if never (updated on verify). */
|
|
34
|
+
lastUsedAt: number | null;
|
|
35
|
+
/** Epoch-ms expiry, or null for a token that never expires. */
|
|
36
|
+
expiresAt: number | null;
|
|
37
|
+
}
|
|
38
|
+
export interface CreateTokenOptions {
|
|
39
|
+
/** Abilities/scopes to grant. Defaults to `["*"]` (everything). */
|
|
40
|
+
abilities?: string[];
|
|
41
|
+
/** Lifetime — seconds (number) or a duration string (`"30d"`, `"12h"`). No expiry if omitted. */
|
|
42
|
+
expiresIn?: number | string;
|
|
43
|
+
/** A human label for the token. */
|
|
44
|
+
name?: string;
|
|
45
|
+
/** Which registered connection to store the token on. */
|
|
46
|
+
connection?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface IssuedToken {
|
|
49
|
+
/** The plaintext token — `keel_<selector>.<verifier>`. Shown once; store the hash only. */
|
|
50
|
+
token: string;
|
|
51
|
+
selector: string;
|
|
52
|
+
abilities: string[];
|
|
53
|
+
expiresAt: number | null;
|
|
54
|
+
}
|
|
55
|
+
/** Change the table personal access tokens are stored in (default `personal_access_tokens`). */
|
|
56
|
+
export declare function setTokensTable(name: string): void;
|
|
57
|
+
/** Mint a new access token for an entity. Returns the plaintext once — persist nothing but the hash. */
|
|
58
|
+
export declare function createToken(tokenableId: string | number, options?: CreateTokenOptions): Promise<IssuedToken>;
|
|
59
|
+
/**
|
|
60
|
+
* Verify a plaintext token and return its record, or `null` if it's malformed,
|
|
61
|
+
* unknown, tampered, or expired. On success it stamps `last_used_at`. An expired
|
|
62
|
+
* token is deleted in passing, so the table self-prunes as stale tokens are tried.
|
|
63
|
+
*/
|
|
64
|
+
export declare function verifyToken(token: string, connection?: string): Promise<AccessToken | null>;
|
|
65
|
+
/** Whether a verified token grants an ability (`["*"]` grants everything). */
|
|
66
|
+
export declare function tokenAllows(token: AccessToken | null | undefined, ability: string): boolean;
|
|
67
|
+
/** The negation of `tokenAllows`. */
|
|
68
|
+
export declare function tokenDenies(token: AccessToken | null | undefined, ability: string): boolean;
|
|
69
|
+
/** Revoke a single token by its selector (the part before the `.`). */
|
|
70
|
+
export declare function revokeToken(selector: string, connection?: string): Promise<void>;
|
|
71
|
+
/** Revoke every token belonging to an entity — a "log out everywhere" switch. */
|
|
72
|
+
export declare function revokeTokens(tokenableId: string | number, connection?: string): Promise<void>;
|
|
73
|
+
/** List an entity's tokens (metadata only — the secret is never stored). */
|
|
74
|
+
export declare function listTokens(tokenableId: string | number, connection?: string): Promise<AccessToken[]>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal access tokens — opaque, database-backed bearer tokens for API and
|
|
3
|
+
* mobile clients, an alternative to the stateless [`jwt`](./crypto.ts). Unlike a
|
|
4
|
+
* JWT, an opaque token can be *revoked* instantly (it's a row you delete), carries
|
|
5
|
+
* *abilities* (scopes), and tracks *last used* — at the cost of a lookup per
|
|
6
|
+
* request. Built on the `db()` layer, so it runs anywhere a `Connection` does.
|
|
7
|
+
*
|
|
8
|
+
* const { token } = await createToken(user.id, { abilities: ["posts:write"] });
|
|
9
|
+
* // → "keel_<selector>.<verifier>" — show once, never recoverable
|
|
10
|
+
*
|
|
11
|
+
* const record = await verifyToken(token); // { tokenableId, abilities, … } | null
|
|
12
|
+
* tokenAllows(record, "posts:write"); // true
|
|
13
|
+
*
|
|
14
|
+
* Pair it with `tokenAuth()` in ./auth.ts to protect routes. The token splits
|
|
15
|
+
* into a public *selector* (indexed, for lookup) and a secret *verifier* (stored
|
|
16
|
+
* only as a SHA-256 hash) — so a leaked database can't mint working tokens, and
|
|
17
|
+
* verification needs no `RETURNING`/auto-increment (portable across every driver).
|
|
18
|
+
*
|
|
19
|
+
* Expected table (`personal_access_tokens` by default), all timestamps epoch-ms:
|
|
20
|
+
* selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
|
|
21
|
+
* abilities TEXT (JSON), last_used_at INTEGER, expires_at INTEGER, created_at INTEGER
|
|
22
|
+
*/
|
|
23
|
+
import { db } from "./database.js";
|
|
24
|
+
/** The table tokens are stored in; override with `setTokensTable`. */
|
|
25
|
+
let table = "personal_access_tokens";
|
|
26
|
+
/** Change the table personal access tokens are stored in (default `personal_access_tokens`). */
|
|
27
|
+
export function setTokensTable(name) {
|
|
28
|
+
table = name;
|
|
29
|
+
}
|
|
30
|
+
/* -------------------------------- crypto -------------------------------- */
|
|
31
|
+
const DURATION = /^(\d+)\s*(s|m|h|d)$/;
|
|
32
|
+
const UNIT = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
33
|
+
function seconds(value) {
|
|
34
|
+
if (typeof value === "number")
|
|
35
|
+
return value;
|
|
36
|
+
const match = DURATION.exec(value.trim());
|
|
37
|
+
if (!match)
|
|
38
|
+
throw new Error(`Invalid duration "${value}" (use e.g. 3600, "30m", "12h", "30d").`);
|
|
39
|
+
return Number(match[1]) * UNIT[match[2]];
|
|
40
|
+
}
|
|
41
|
+
function base64url(bytes) {
|
|
42
|
+
let s = "";
|
|
43
|
+
for (const b of bytes)
|
|
44
|
+
s += String.fromCharCode(b);
|
|
45
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
46
|
+
}
|
|
47
|
+
function randomToken(size) {
|
|
48
|
+
return base64url(crypto.getRandomValues(new Uint8Array(size)));
|
|
49
|
+
}
|
|
50
|
+
/** SHA-256 of the verifier, base64url — what we actually persist. */
|
|
51
|
+
async function sha256(value) {
|
|
52
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
53
|
+
return base64url(new Uint8Array(digest));
|
|
54
|
+
}
|
|
55
|
+
/** Constant-time string compare, so a bad hash can't be timed byte-by-byte. */
|
|
56
|
+
function safeEqual(a, b) {
|
|
57
|
+
if (a.length !== b.length)
|
|
58
|
+
return false;
|
|
59
|
+
let result = 0;
|
|
60
|
+
for (let i = 0; i < a.length; i++)
|
|
61
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
62
|
+
return result === 0;
|
|
63
|
+
}
|
|
64
|
+
/* ------------------------------- lifecycle ------------------------------ */
|
|
65
|
+
/** Mint a new access token for an entity. Returns the plaintext once — persist nothing but the hash. */
|
|
66
|
+
export async function createToken(tokenableId, options = {}) {
|
|
67
|
+
const selector = randomToken(12);
|
|
68
|
+
const verifier = randomToken(24);
|
|
69
|
+
const abilities = options.abilities ?? ["*"];
|
|
70
|
+
const expiresAt = options.expiresIn != null ? Date.now() + seconds(options.expiresIn) * 1000 : null;
|
|
71
|
+
await db(table, options.connection).insert({
|
|
72
|
+
selector,
|
|
73
|
+
hash: await sha256(verifier),
|
|
74
|
+
tokenable_id: String(tokenableId),
|
|
75
|
+
name: options.name ?? null,
|
|
76
|
+
abilities: JSON.stringify(abilities),
|
|
77
|
+
last_used_at: null,
|
|
78
|
+
expires_at: expiresAt,
|
|
79
|
+
created_at: Date.now(),
|
|
80
|
+
});
|
|
81
|
+
return { token: `keel_${selector}.${verifier}`, selector, abilities, expiresAt };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Verify a plaintext token and return its record, or `null` if it's malformed,
|
|
85
|
+
* unknown, tampered, or expired. On success it stamps `last_used_at`. An expired
|
|
86
|
+
* token is deleted in passing, so the table self-prunes as stale tokens are tried.
|
|
87
|
+
*/
|
|
88
|
+
export async function verifyToken(token, connection) {
|
|
89
|
+
const match = /^keel_([^.]+)\.(.+)$/.exec(token);
|
|
90
|
+
if (!match)
|
|
91
|
+
return null;
|
|
92
|
+
const selector = match[1];
|
|
93
|
+
const verifier = match[2];
|
|
94
|
+
const row = await db(table, connection).where("selector", selector).first();
|
|
95
|
+
if (!row)
|
|
96
|
+
return null;
|
|
97
|
+
if (!safeEqual(await sha256(verifier), String(row.hash)))
|
|
98
|
+
return null;
|
|
99
|
+
const expiresAt = row.expires_at != null ? Number(row.expires_at) : null;
|
|
100
|
+
if (expiresAt != null && Date.now() >= expiresAt) {
|
|
101
|
+
await db(table, connection).where("selector", selector).delete();
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
await db(table, connection).where("selector", selector).update({ last_used_at: now });
|
|
106
|
+
return {
|
|
107
|
+
selector,
|
|
108
|
+
tokenableId: String(row.tokenable_id),
|
|
109
|
+
name: row.name ?? null,
|
|
110
|
+
abilities: parseAbilities(row.abilities),
|
|
111
|
+
lastUsedAt: now,
|
|
112
|
+
expiresAt,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Whether a verified token grants an ability (`["*"]` grants everything). */
|
|
116
|
+
export function tokenAllows(token, ability) {
|
|
117
|
+
if (!token)
|
|
118
|
+
return false;
|
|
119
|
+
return token.abilities.includes("*") || token.abilities.includes(ability);
|
|
120
|
+
}
|
|
121
|
+
/** The negation of `tokenAllows`. */
|
|
122
|
+
export function tokenDenies(token, ability) {
|
|
123
|
+
return !tokenAllows(token, ability);
|
|
124
|
+
}
|
|
125
|
+
/** Revoke a single token by its selector (the part before the `.`). */
|
|
126
|
+
export async function revokeToken(selector, connection) {
|
|
127
|
+
await db(table, connection).where("selector", selector).delete();
|
|
128
|
+
}
|
|
129
|
+
/** Revoke every token belonging to an entity — a "log out everywhere" switch. */
|
|
130
|
+
export async function revokeTokens(tokenableId, connection) {
|
|
131
|
+
await db(table, connection).where("tokenable_id", String(tokenableId)).delete();
|
|
132
|
+
}
|
|
133
|
+
/** List an entity's tokens (metadata only — the secret is never stored). */
|
|
134
|
+
export async function listTokens(tokenableId, connection) {
|
|
135
|
+
const rows = await db(table, connection).where("tokenable_id", String(tokenableId)).get();
|
|
136
|
+
return rows.map((row) => ({
|
|
137
|
+
selector: String(row.selector),
|
|
138
|
+
tokenableId: String(row.tokenable_id),
|
|
139
|
+
name: row.name ?? null,
|
|
140
|
+
abilities: parseAbilities(row.abilities),
|
|
141
|
+
lastUsedAt: row.last_used_at != null ? Number(row.last_used_at) : null,
|
|
142
|
+
expiresAt: row.expires_at != null ? Number(row.expires_at) : null,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
function parseAbilities(value) {
|
|
146
|
+
if (typeof value !== "string")
|
|
147
|
+
return Array.isArray(value) ? value : ["*"];
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(value);
|
|
150
|
+
return Array.isArray(parsed) ? parsed : ["*"];
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return ["*"];
|
|
154
|
+
}
|
|
155
|
+
}
|
package/dist/db/d1.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Keel `Connection` for Cloudflare D1. Pass your D1 binding (from `env.DB`)
|
|
3
|
+
* and register it — dialect `"sqlite"`:
|
|
4
|
+
*
|
|
5
|
+
* import { d1Connection } from "@shaferllc/keel/db/d1";
|
|
6
|
+
* import { setConnection } from "@shaferllc/keel/core";
|
|
7
|
+
*
|
|
8
|
+
* setConnection(d1Connection(env.DB), "sqlite");
|
|
9
|
+
*
|
|
10
|
+
* The binding is duck-typed — this module imports no driver and no
|
|
11
|
+
* `@cloudflare/workers-types`, so it stays edge-native and dependency-free. Any
|
|
12
|
+
* object shaped like a D1 database works.
|
|
13
|
+
*/
|
|
14
|
+
import type { Connection, Row } from "../core/database.js";
|
|
15
|
+
/** The slice of the D1 `Database` API this adapter uses. */
|
|
16
|
+
export interface D1Like {
|
|
17
|
+
prepare(sql: string): {
|
|
18
|
+
bind(...values: unknown[]): {
|
|
19
|
+
all<T = Row>(): Promise<{
|
|
20
|
+
results?: T[];
|
|
21
|
+
}>;
|
|
22
|
+
run(): Promise<{
|
|
23
|
+
meta: {
|
|
24
|
+
changes?: number;
|
|
25
|
+
last_row_id?: number;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Build a `Connection` backed by a Cloudflare D1 binding. */
|
|
32
|
+
export declare function d1Connection(database: D1Like): Connection;
|
package/dist/db/d1.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Keel `Connection` for Cloudflare D1. Pass your D1 binding (from `env.DB`)
|
|
3
|
+
* and register it — dialect `"sqlite"`:
|
|
4
|
+
*
|
|
5
|
+
* import { d1Connection } from "@shaferllc/keel/db/d1";
|
|
6
|
+
* import { setConnection } from "@shaferllc/keel/core";
|
|
7
|
+
*
|
|
8
|
+
* setConnection(d1Connection(env.DB), "sqlite");
|
|
9
|
+
*
|
|
10
|
+
* The binding is duck-typed — this module imports no driver and no
|
|
11
|
+
* `@cloudflare/workers-types`, so it stays edge-native and dependency-free. Any
|
|
12
|
+
* object shaped like a D1 database works.
|
|
13
|
+
*/
|
|
14
|
+
/** Build a `Connection` backed by a Cloudflare D1 binding. */
|
|
15
|
+
export function d1Connection(database) {
|
|
16
|
+
return {
|
|
17
|
+
async select(sql, bindings) {
|
|
18
|
+
const { results } = await database.prepare(sql).bind(...bindings).all();
|
|
19
|
+
return results ?? [];
|
|
20
|
+
},
|
|
21
|
+
async write(sql, bindings) {
|
|
22
|
+
const { meta } = await database.prepare(sql).bind(...bindings).run();
|
|
23
|
+
return { rowsAffected: meta.changes ?? 0, insertId: meta.last_row_id };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Keel `Connection` for libSQL / Turso. Pass an `@libsql/client` client (it
|
|
3
|
+
* runs on Node and the edge — Turso speaks HTTP) and register it with dialect
|
|
4
|
+
* `"sqlite"`:
|
|
5
|
+
*
|
|
6
|
+
* import { libsqlConnection } from "@shaferllc/keel/db/libsql";
|
|
7
|
+
* import { setConnection } from "@shaferllc/keel/core";
|
|
8
|
+
* import { createClient } from "@libsql/client";
|
|
9
|
+
*
|
|
10
|
+
* const client = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN });
|
|
11
|
+
* setConnection(libsqlConnection(client), "sqlite");
|
|
12
|
+
*
|
|
13
|
+
* The client is duck-typed — this module imports no driver, so it never bundles
|
|
14
|
+
* `@libsql/client`.
|
|
15
|
+
*/
|
|
16
|
+
import type { Connection, Row } from "../core/database.js";
|
|
17
|
+
/** The slice of the `@libsql/client` API this adapter uses. */
|
|
18
|
+
export interface LibSqlLike {
|
|
19
|
+
execute(stmt: {
|
|
20
|
+
sql: string;
|
|
21
|
+
args: unknown[];
|
|
22
|
+
}): Promise<{
|
|
23
|
+
rows: Row[];
|
|
24
|
+
rowsAffected: number;
|
|
25
|
+
lastInsertRowid?: bigint | number;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/** Build a `Connection` backed by an `@libsql/client` client. */
|
|
29
|
+
export declare function libsqlConnection(client: LibSqlLike): Connection;
|