@shaferllc/keel 0.58.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 +68 -2
- package/dist/core/auth.js +115 -3
- 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 +90 -4
- package/dist/core/crypto.js +150 -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 +19 -8
- package/dist/core/index.js +10 -5
- 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,173 @@
|
|
|
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
|
+
/** An OAuth token set returned by the provider's token endpoint. */
|
|
24
|
+
export interface OAuthToken {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
tokenType?: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
/** Seconds until the access token expires, if the provider says. */
|
|
29
|
+
expiresIn?: number;
|
|
30
|
+
scope?: string;
|
|
31
|
+
/** The raw token response, for provider-specific fields. */
|
|
32
|
+
raw: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A provider's user, normalized to a common shape across every driver. `Token`
|
|
36
|
+
* is the OAuth2 `OAuthToken` by default, or an `OAuth1Token` for OAuth 1.0a
|
|
37
|
+
* providers.
|
|
38
|
+
*/
|
|
39
|
+
export interface SocialUser<Token = OAuthToken> {
|
|
40
|
+
/** The provider's stable id for this user (always a string). */
|
|
41
|
+
id: string;
|
|
42
|
+
email: string | null;
|
|
43
|
+
name: string | null;
|
|
44
|
+
/** Username / handle (e.g. GitHub login, Discord username). */
|
|
45
|
+
nickname: string | null;
|
|
46
|
+
avatarUrl: string | null;
|
|
47
|
+
/** The token used to fetch this profile — for calling the provider's API. */
|
|
48
|
+
token: Token;
|
|
49
|
+
/** The raw provider profile, for fields not in the normalized shape. */
|
|
50
|
+
raw: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
export interface OAuthConfig {
|
|
53
|
+
clientId: string;
|
|
54
|
+
clientSecret: string;
|
|
55
|
+
/** The callback URL registered with the provider. */
|
|
56
|
+
redirectUri: string;
|
|
57
|
+
/** Override the provider's default scopes. */
|
|
58
|
+
scopes?: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface RedirectOptions {
|
|
61
|
+
/** A CSRF `state` value — generate with `oauthState()`, stash it, verify on callback. */
|
|
62
|
+
state?: string;
|
|
63
|
+
/** Scopes for this redirect (overrides config + provider defaults). */
|
|
64
|
+
scopes?: string[];
|
|
65
|
+
/** Extra query parameters to add to the authorize URL (e.g. `prompt`, `access_type`). */
|
|
66
|
+
params?: Record<string, string>;
|
|
67
|
+
}
|
|
68
|
+
/** The provider-specific bits an `OAuthDriver` needs. */
|
|
69
|
+
export interface ProviderSpec {
|
|
70
|
+
name: string;
|
|
71
|
+
authorizeUrl: string;
|
|
72
|
+
tokenUrl: string;
|
|
73
|
+
defaultScopes: string[];
|
|
74
|
+
/** How scopes are joined in the URL — space for most, comma for a few. */
|
|
75
|
+
scopeSeparator?: string;
|
|
76
|
+
/** Fetch and normalize the provider's user for an access token. */
|
|
77
|
+
fetchUser(token: OAuthToken): Promise<SocialUser>;
|
|
78
|
+
}
|
|
79
|
+
/** Thrown when the token exchange or profile fetch fails. */
|
|
80
|
+
export declare class OAuthError extends Error {
|
|
81
|
+
readonly provider: string;
|
|
82
|
+
constructor(message: string, provider: string);
|
|
83
|
+
}
|
|
84
|
+
/** A random, URL-safe `state` for CSRF protection — stash it, then verify on callback. */
|
|
85
|
+
export declare function oauthState(bytes?: number): string;
|
|
86
|
+
/** A generic OAuth 2.0 authorization-code driver. */
|
|
87
|
+
export declare class OAuthDriver {
|
|
88
|
+
private spec;
|
|
89
|
+
private config;
|
|
90
|
+
constructor(spec: ProviderSpec, config: OAuthConfig);
|
|
91
|
+
/** Build the provider's authorize URL to redirect the user to. */
|
|
92
|
+
redirect(options?: RedirectOptions): string;
|
|
93
|
+
/** Exchange an authorization `code` (from the callback) for an access token. */
|
|
94
|
+
exchangeCode(code: string): Promise<OAuthToken>;
|
|
95
|
+
/** Fetch the normalized user for an already-obtained access token. */
|
|
96
|
+
userFromToken(token: OAuthToken): Promise<SocialUser>;
|
|
97
|
+
/** The full callback step: exchange the `code`, then fetch the user. */
|
|
98
|
+
user(code: string): Promise<SocialUser>;
|
|
99
|
+
}
|
|
100
|
+
/** Build a driver for any OAuth2 provider from a spec + config. */
|
|
101
|
+
export declare function oauthDriver(spec: ProviderSpec, config: OAuthConfig): OAuthDriver;
|
|
102
|
+
/** GitHub OAuth (`user:email` gives access to a verified primary email). */
|
|
103
|
+
export declare function github(config: OAuthConfig): OAuthDriver;
|
|
104
|
+
/** Google OAuth / OpenID Connect. */
|
|
105
|
+
export declare function google(config: OAuthConfig): OAuthDriver;
|
|
106
|
+
/** Discord OAuth. */
|
|
107
|
+
export declare function discord(config: OAuthConfig): OAuthDriver;
|
|
108
|
+
export interface OAuth1Config {
|
|
109
|
+
/** Consumer (API) key. */
|
|
110
|
+
clientId: string;
|
|
111
|
+
/** Consumer (API) secret. */
|
|
112
|
+
clientSecret: string;
|
|
113
|
+
/** The `oauth_callback` URL registered with the provider. */
|
|
114
|
+
redirectUri: string;
|
|
115
|
+
}
|
|
116
|
+
/** An OAuth 1.0a token pair — both the request token and the final access token. */
|
|
117
|
+
export interface OAuth1Token {
|
|
118
|
+
token: string;
|
|
119
|
+
tokenSecret: string;
|
|
120
|
+
raw: Record<string, string>;
|
|
121
|
+
}
|
|
122
|
+
export interface OAuth1ProviderSpec {
|
|
123
|
+
name: string;
|
|
124
|
+
requestTokenUrl: string;
|
|
125
|
+
authorizeUrl: string;
|
|
126
|
+
accessTokenUrl: string;
|
|
127
|
+
fetchUser(token: OAuth1Token, driver: OAuth1Driver): Promise<SocialUser<OAuth1Token>>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Compute an OAuth 1.0a HMAC-SHA1 signature (RFC 5849). `params` holds every
|
|
131
|
+
* signed parameter with *raw* (unencoded) values — the oauth_* fields plus any
|
|
132
|
+
* query/body params, minus `oauth_signature`. Exposed for signing custom API
|
|
133
|
+
* requests beyond the built-in flow.
|
|
134
|
+
*/
|
|
135
|
+
export declare function oauth1Signature(input: {
|
|
136
|
+
method: string;
|
|
137
|
+
url: string;
|
|
138
|
+
params: Record<string, string>;
|
|
139
|
+
consumerSecret: string;
|
|
140
|
+
tokenSecret?: string;
|
|
141
|
+
}): Promise<string>;
|
|
142
|
+
/** A generic OAuth 1.0a driver. */
|
|
143
|
+
export declare class OAuth1Driver {
|
|
144
|
+
private spec;
|
|
145
|
+
private config;
|
|
146
|
+
constructor(spec: OAuth1ProviderSpec, config: OAuth1Config);
|
|
147
|
+
/** Sign a request and build its `Authorization: OAuth …` header. */
|
|
148
|
+
private authHeader;
|
|
149
|
+
/** Step 1 — obtain a temporary request token. Stash its `tokenSecret` for the callback. */
|
|
150
|
+
requestToken(): Promise<OAuth1Token>;
|
|
151
|
+
/** Step 2 — the URL to send the user to, carrying the request token. */
|
|
152
|
+
redirect(requestToken: string | OAuth1Token): string;
|
|
153
|
+
/** Step 3 — swap the callback's `oauth_token` + `oauth_verifier` for an access token. */
|
|
154
|
+
accessToken(oauthToken: string, verifier: string, requestTokenSecret: string): Promise<OAuth1Token>;
|
|
155
|
+
/** The full callback step: exchange for an access token, then fetch the user. */
|
|
156
|
+
user(oauthToken: string, verifier: string, requestTokenSecret: string): Promise<SocialUser<OAuth1Token>>;
|
|
157
|
+
/** A signed GET against the provider's API on the user's behalf (for `fetchUser`). */
|
|
158
|
+
get(url: string, token: OAuth1Token): Promise<Record<string, unknown>>;
|
|
159
|
+
}
|
|
160
|
+
/** Build a driver for any OAuth 1.0a provider from a spec + config. */
|
|
161
|
+
export declare function oauth1Driver(spec: OAuth1ProviderSpec, config: OAuth1Config): OAuth1Driver;
|
|
162
|
+
/** Twitter / X (OAuth 1.0a). Enable "Request email" in your app settings for `email`. */
|
|
163
|
+
export declare function twitter(config: OAuth1Config): OAuth1Driver;
|
|
164
|
+
/** All social providers under one namespace: `social.github({...})`, `social.twitter({...})`. */
|
|
165
|
+
export declare const social: {
|
|
166
|
+
github: typeof github;
|
|
167
|
+
google: typeof google;
|
|
168
|
+
discord: typeof discord;
|
|
169
|
+
driver: typeof oauthDriver;
|
|
170
|
+
state: typeof oauthState;
|
|
171
|
+
twitter: typeof twitter;
|
|
172
|
+
driver1: typeof oauth1Driver;
|
|
173
|
+
};
|
|
@@ -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[]>;
|