@zero-server/sdk 0.9.0 → 0.9.2
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 -21
- package/README.md +460 -437
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +460 -460
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +136 -136
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +254 -254
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/auth/oauth.js
CHANGED
|
@@ -1,362 +1,362 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module auth/oauth
|
|
3
|
-
* @description Zero-dependency OAuth 2.0 client with PKCE support.
|
|
4
|
-
* Built-in provider presets for Google, GitHub, Microsoft, and Apple.
|
|
5
|
-
* Uses the Authorization Code flow with PKCE for maximum security.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* const { oauth } = require('@zero-server/sdk');
|
|
9
|
-
* const github = oauth({
|
|
10
|
-
* provider: 'github',
|
|
11
|
-
* clientId: process.env.GITHUB_CLIENT_ID,
|
|
12
|
-
* clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
13
|
-
* callbackUrl: 'https://myapp.com/auth/github/callback',
|
|
14
|
-
* });
|
|
15
|
-
*
|
|
16
|
-
* app.get('/auth/github', (req, res) => {
|
|
17
|
-
* const { url, state, codeVerifier } = github.authorize({ scope: 'user:email' });
|
|
18
|
-
* req.session.set('oauth_state', state);
|
|
19
|
-
* req.session.set('oauth_verifier', codeVerifier);
|
|
20
|
-
* res.redirect(url);
|
|
21
|
-
* });
|
|
22
|
-
*
|
|
23
|
-
* app.get('/auth/github/callback', async (req, res) => {
|
|
24
|
-
* const tokens = await github.callback(req.query, {
|
|
25
|
-
* state: req.session.get('oauth_state'),
|
|
26
|
-
* codeVerifier: req.session.get('oauth_verifier'),
|
|
27
|
-
* });
|
|
28
|
-
* const user = await github.userInfo(tokens.access_token);
|
|
29
|
-
* res.json(user);
|
|
30
|
-
* });
|
|
31
|
-
*/
|
|
32
|
-
const crypto = require('crypto');
|
|
33
|
-
const log = require('../debug')('zero:oauth');
|
|
34
|
-
|
|
35
|
-
// -- Built-in Provider Presets -----------------------------------
|
|
36
|
-
|
|
37
|
-
/** @private */
|
|
38
|
-
const PROVIDERS = {
|
|
39
|
-
google: {
|
|
40
|
-
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
41
|
-
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
42
|
-
userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
43
|
-
scope: 'openid profile email',
|
|
44
|
-
pkce: true,
|
|
45
|
-
},
|
|
46
|
-
github: {
|
|
47
|
-
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
|
48
|
-
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
49
|
-
userInfoUrl: 'https://api.github.com/user',
|
|
50
|
-
scope: 'read:user user:email',
|
|
51
|
-
pkce: false, // GitHub doesn't support PKCE
|
|
52
|
-
},
|
|
53
|
-
microsoft: {
|
|
54
|
-
authorizeUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
55
|
-
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
56
|
-
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
57
|
-
scope: 'openid profile email',
|
|
58
|
-
pkce: true,
|
|
59
|
-
},
|
|
60
|
-
apple: {
|
|
61
|
-
authorizeUrl: 'https://appleid.apple.com/auth/authorize',
|
|
62
|
-
tokenUrl: 'https://appleid.apple.com/auth/token',
|
|
63
|
-
userInfoUrl: null, // Apple returns user info in the id_token
|
|
64
|
-
scope: 'name email',
|
|
65
|
-
pkce: true,
|
|
66
|
-
responseMode: 'form_post',
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// -- PKCE Helpers ------------------------------------------------
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Generate a PKCE code verifier and challenge.
|
|
74
|
-
*
|
|
75
|
-
* @param {number} [length=64] - Verifier length (43–128 per RFC 7636).
|
|
76
|
-
* @returns {{ codeVerifier: string, codeChallenge: string }}
|
|
77
|
-
*
|
|
78
|
-
* @example
|
|
79
|
-
* const { codeVerifier, codeChallenge } = generatePKCE();
|
|
80
|
-
*/
|
|
81
|
-
function generatePKCE(length = 64)
|
|
82
|
-
{
|
|
83
|
-
const verifier = crypto.randomBytes(length).toString('base64url').slice(0, Math.max(43, Math.min(length, 128)));
|
|
84
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
85
|
-
return { codeVerifier: verifier, codeChallenge: challenge };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Generate a cryptographically random state parameter.
|
|
90
|
-
*
|
|
91
|
-
* @param {number} [bytes=32] - Entropy bytes.
|
|
92
|
-
* @returns {string} URL-safe random string.
|
|
93
|
-
*/
|
|
94
|
-
function generateState(bytes = 32)
|
|
95
|
-
{
|
|
96
|
-
return crypto.randomBytes(bytes).toString('base64url');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// -- OAuth2 Client -----------------------------------------------
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Create an OAuth 2.0 client for Authorization Code flow (+ PKCE).
|
|
103
|
-
*
|
|
104
|
-
* @param {object} opts - Configuration.
|
|
105
|
-
* @param {string} [opts.provider] - Built-in provider name (`'google'`, `'github'`, `'microsoft'`, `'apple'`).
|
|
106
|
-
* @param {string} opts.clientId - OAuth client ID.
|
|
107
|
-
* @param {string} opts.clientSecret - OAuth client secret.
|
|
108
|
-
* @param {string} opts.callbackUrl - Redirect URI.
|
|
109
|
-
* @param {string} [opts.authorizeUrl] - Authorization endpoint URL (overrides provider).
|
|
110
|
-
* @param {string} [opts.tokenUrl] - Token endpoint URL (overrides provider).
|
|
111
|
-
* @param {string} [opts.userInfoUrl] - UserInfo endpoint URL (overrides provider).
|
|
112
|
-
* @param {string} [opts.scope] - Default scopes (space-separated).
|
|
113
|
-
* @param {boolean} [opts.pkce=true] - Enable PKCE (default: true if provider supports it).
|
|
114
|
-
* @param {string} [opts.responseMode] - Response mode override (e.g. `'form_post'` for Apple).
|
|
115
|
-
* @param {Function} [opts.fetcher] - Custom HTTP fetch function (default: built-in fetch).
|
|
116
|
-
* @param {number} [opts.timeout=10000] - HTTP request timeout in ms.
|
|
117
|
-
* @returns {{ authorize: Function, callback: Function, refresh: Function, userInfo: Function }}
|
|
118
|
-
*
|
|
119
|
-
* @example
|
|
120
|
-
* const client = oauth({
|
|
121
|
-
* provider: 'google',
|
|
122
|
-
* clientId: 'xxx',
|
|
123
|
-
* clientSecret: 'yyy',
|
|
124
|
-
* callbackUrl: 'https://app.com/callback',
|
|
125
|
-
* });
|
|
126
|
-
*
|
|
127
|
-
* // Build auth URL
|
|
128
|
-
* const { url, state, codeVerifier } = client.authorize();
|
|
129
|
-
*
|
|
130
|
-
* // Exchange code for tokens
|
|
131
|
-
* const tokens = await client.callback(req.query, { state: savedState, codeVerifier });
|
|
132
|
-
*/
|
|
133
|
-
function oauth(opts = {})
|
|
134
|
-
{
|
|
135
|
-
const preset = opts.provider ? PROVIDERS[opts.provider] : {};
|
|
136
|
-
if (opts.provider && !preset) throw new Error(`Unknown OAuth provider: ${opts.provider}`);
|
|
137
|
-
|
|
138
|
-
const config = {
|
|
139
|
-
authorizeUrl: opts.authorizeUrl || preset.authorizeUrl,
|
|
140
|
-
tokenUrl: opts.tokenUrl || preset.tokenUrl,
|
|
141
|
-
userInfoUrl: opts.userInfoUrl || preset.userInfoUrl || null,
|
|
142
|
-
clientId: opts.clientId,
|
|
143
|
-
clientSecret: opts.clientSecret,
|
|
144
|
-
callbackUrl: opts.callbackUrl,
|
|
145
|
-
scope: opts.scope || preset.scope || '',
|
|
146
|
-
pkce: opts.pkce !== undefined ? opts.pkce : (preset.pkce !== false),
|
|
147
|
-
responseMode: opts.responseMode || preset.responseMode || null,
|
|
148
|
-
timeout: opts.timeout || 10000,
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
if (!config.clientId) throw new Error('oauth() requires clientId');
|
|
152
|
-
if (!config.callbackUrl) throw new Error('oauth() requires callbackUrl');
|
|
153
|
-
if (!config.authorizeUrl) throw new Error('oauth() requires authorizeUrl');
|
|
154
|
-
if (!config.tokenUrl) throw new Error('oauth() requires tokenUrl');
|
|
155
|
-
|
|
156
|
-
const fetchFn = opts.fetcher || require('../fetch');
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
/**
|
|
160
|
-
* Build the authorization URL the user should be redirected to.
|
|
161
|
-
*
|
|
162
|
-
* @param {object} [params] - Extra parameters.
|
|
163
|
-
* @param {string} [params.scope] - Override scopes.
|
|
164
|
-
* @param {string} [params.state] - Override state (auto-generated if omitted).
|
|
165
|
-
* @param {object} [params.extra] - Additional query params to include.
|
|
166
|
-
* @returns {{ url: string, state: string, codeVerifier?: string }}
|
|
167
|
-
*/
|
|
168
|
-
authorize(params = {})
|
|
169
|
-
{
|
|
170
|
-
const state = params.state || generateState();
|
|
171
|
-
const scope = params.scope || config.scope;
|
|
172
|
-
|
|
173
|
-
const query = new URLSearchParams({
|
|
174
|
-
response_type: 'code',
|
|
175
|
-
client_id: config.clientId,
|
|
176
|
-
redirect_uri: config.callbackUrl,
|
|
177
|
-
state,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
if (scope) query.set('scope', scope);
|
|
181
|
-
if (config.responseMode) query.set('response_mode', config.responseMode);
|
|
182
|
-
|
|
183
|
-
let codeVerifier;
|
|
184
|
-
if (config.pkce)
|
|
185
|
-
{
|
|
186
|
-
const pkce = generatePKCE();
|
|
187
|
-
codeVerifier = pkce.codeVerifier;
|
|
188
|
-
query.set('code_challenge', pkce.codeChallenge);
|
|
189
|
-
query.set('code_challenge_method', 'S256');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Merge extra params
|
|
193
|
-
if (params.extra)
|
|
194
|
-
{
|
|
195
|
-
for (const [k, v] of Object.entries(params.extra)) query.set(k, v);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const sep = config.authorizeUrl.includes('?') ? '&' : '?';
|
|
199
|
-
const url = `${config.authorizeUrl}${sep}${query.toString()}`;
|
|
200
|
-
|
|
201
|
-
log.debug('authorize URL built for %s', config.clientId);
|
|
202
|
-
return { url, state, codeVerifier };
|
|
203
|
-
},
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Exchange an authorization code for tokens.
|
|
207
|
-
* Validates the state parameter to prevent CSRF.
|
|
208
|
-
*
|
|
209
|
-
* @param {object} query - Callback query/body params (`{ code, state }`).
|
|
210
|
-
* @param {object} [verify] - Verification context.
|
|
211
|
-
* @param {string} [verify.state] - Expected state (must match `query.state`).
|
|
212
|
-
* @param {string} [verify.codeVerifier] - PKCE code verifier.
|
|
213
|
-
* @returns {Promise<{ access_token: string, token_type: string, expires_in?: number, refresh_token?: string, id_token?: string, scope?: string }>}
|
|
214
|
-
* @throws {Error} If state mismatches, code is missing, or token exchange fails.
|
|
215
|
-
*/
|
|
216
|
-
async callback(query, verify = {})
|
|
217
|
-
{
|
|
218
|
-
if (!query || !query.code)
|
|
219
|
-
{
|
|
220
|
-
const errMsg = query?.error_description || query?.error || 'No authorization code received';
|
|
221
|
-
throw _oauthError(errMsg, 'OAUTH_NO_CODE');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Validate state (CSRF prevention)
|
|
225
|
-
if (verify.state && query.state !== verify.state)
|
|
226
|
-
{
|
|
227
|
-
throw _oauthError('State mismatch — possible CSRF attack', 'OAUTH_STATE_MISMATCH');
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const body = {
|
|
231
|
-
grant_type: 'authorization_code',
|
|
232
|
-
code: query.code,
|
|
233
|
-
redirect_uri: config.callbackUrl,
|
|
234
|
-
client_id: config.clientId,
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
if (config.clientSecret) body.client_secret = config.clientSecret;
|
|
238
|
-
|
|
239
|
-
if (verify.codeVerifier)
|
|
240
|
-
{
|
|
241
|
-
body.code_verifier = verify.codeVerifier;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
log.debug('exchanging code for tokens at %s', config.tokenUrl);
|
|
245
|
-
const res = await fetchFn(config.tokenUrl, {
|
|
246
|
-
method: 'POST',
|
|
247
|
-
headers: {
|
|
248
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
249
|
-
'Accept': 'application/json',
|
|
250
|
-
},
|
|
251
|
-
body: new URLSearchParams(body).toString(),
|
|
252
|
-
timeout: config.timeout,
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
const text = await res.text();
|
|
256
|
-
let tokens;
|
|
257
|
-
try
|
|
258
|
-
{
|
|
259
|
-
tokens = JSON.parse(text);
|
|
260
|
-
}
|
|
261
|
-
catch (_)
|
|
262
|
-
{
|
|
263
|
-
// Some providers (GitHub) return url-encoded responses
|
|
264
|
-
tokens = Object.fromEntries(new URLSearchParams(text));
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (tokens.error)
|
|
268
|
-
{
|
|
269
|
-
throw _oauthError(tokens.error_description || tokens.error, 'OAUTH_TOKEN_ERROR');
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
log.debug('tokens received: type=%s', tokens.token_type);
|
|
273
|
-
return tokens;
|
|
274
|
-
},
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Refresh an access token using a refresh token.
|
|
278
|
-
*
|
|
279
|
-
* @param {string} refreshToken - The refresh token.
|
|
280
|
-
* @returns {Promise<object>} New token set.
|
|
281
|
-
* @throws {Error} If the refresh fails.
|
|
282
|
-
*/
|
|
283
|
-
async refresh(refreshToken)
|
|
284
|
-
{
|
|
285
|
-
const body = {
|
|
286
|
-
grant_type: 'refresh_token',
|
|
287
|
-
refresh_token: refreshToken,
|
|
288
|
-
client_id: config.clientId,
|
|
289
|
-
};
|
|
290
|
-
if (config.clientSecret) body.client_secret = config.clientSecret;
|
|
291
|
-
|
|
292
|
-
log.debug('refreshing token at %s', config.tokenUrl);
|
|
293
|
-
const res = await fetchFn(config.tokenUrl, {
|
|
294
|
-
method: 'POST',
|
|
295
|
-
headers: {
|
|
296
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
297
|
-
'Accept': 'application/json',
|
|
298
|
-
},
|
|
299
|
-
body: new URLSearchParams(body).toString(),
|
|
300
|
-
timeout: config.timeout,
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
const tokens = await res.json();
|
|
304
|
-
if (tokens.error)
|
|
305
|
-
{
|
|
306
|
-
throw _oauthError(tokens.error_description || tokens.error, 'OAUTH_REFRESH_ERROR');
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
return tokens;
|
|
310
|
-
},
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Fetch user profile from the provider's userInfo endpoint.
|
|
314
|
-
*
|
|
315
|
-
* @param {string} accessToken - OAuth access token.
|
|
316
|
-
* @returns {Promise<object>} User profile object (provider-specific schema).
|
|
317
|
-
* @throws {Error} If no userInfoUrl is configured or the request fails.
|
|
318
|
-
*/
|
|
319
|
-
async userInfo(accessToken)
|
|
320
|
-
{
|
|
321
|
-
if (!config.userInfoUrl)
|
|
322
|
-
{
|
|
323
|
-
throw _oauthError('No userInfo endpoint configured for this provider', 'OAUTH_NO_USERINFO');
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const res = await fetchFn(config.userInfoUrl, {
|
|
327
|
-
headers: {
|
|
328
|
-
'Authorization': `Bearer ${accessToken}`,
|
|
329
|
-
'Accept': 'application/json',
|
|
330
|
-
},
|
|
331
|
-
timeout: config.timeout,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
if (!res.ok)
|
|
335
|
-
{
|
|
336
|
-
throw _oauthError(`UserInfo request failed: ${res.status}`, 'OAUTH_USERINFO_FAILED');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return res.json();
|
|
340
|
-
},
|
|
341
|
-
|
|
342
|
-
/** The resolved configuration (read-only). */
|
|
343
|
-
config: Object.freeze({ ...config }),
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// -- Helpers ---------------------------------------------------------
|
|
348
|
-
|
|
349
|
-
/** @private */
|
|
350
|
-
function _oauthError(message, code)
|
|
351
|
-
{
|
|
352
|
-
const err = new Error(message);
|
|
353
|
-
err.code = code;
|
|
354
|
-
return err;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
module.exports = {
|
|
358
|
-
oauth,
|
|
359
|
-
generatePKCE,
|
|
360
|
-
generateState,
|
|
361
|
-
PROVIDERS,
|
|
362
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* @module auth/oauth
|
|
3
|
+
* @description Zero-dependency OAuth 2.0 client with PKCE support.
|
|
4
|
+
* Built-in provider presets for Google, GitHub, Microsoft, and Apple.
|
|
5
|
+
* Uses the Authorization Code flow with PKCE for maximum security.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { oauth } = require('@zero-server/sdk');
|
|
9
|
+
* const github = oauth({
|
|
10
|
+
* provider: 'github',
|
|
11
|
+
* clientId: process.env.GITHUB_CLIENT_ID,
|
|
12
|
+
* clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
13
|
+
* callbackUrl: 'https://myapp.com/auth/github/callback',
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* app.get('/auth/github', (req, res) => {
|
|
17
|
+
* const { url, state, codeVerifier } = github.authorize({ scope: 'user:email' });
|
|
18
|
+
* req.session.set('oauth_state', state);
|
|
19
|
+
* req.session.set('oauth_verifier', codeVerifier);
|
|
20
|
+
* res.redirect(url);
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* app.get('/auth/github/callback', async (req, res) => {
|
|
24
|
+
* const tokens = await github.callback(req.query, {
|
|
25
|
+
* state: req.session.get('oauth_state'),
|
|
26
|
+
* codeVerifier: req.session.get('oauth_verifier'),
|
|
27
|
+
* });
|
|
28
|
+
* const user = await github.userInfo(tokens.access_token);
|
|
29
|
+
* res.json(user);
|
|
30
|
+
* });
|
|
31
|
+
*/
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const log = require('../debug')('zero:oauth');
|
|
34
|
+
|
|
35
|
+
// -- Built-in Provider Presets -----------------------------------
|
|
36
|
+
|
|
37
|
+
/** @private */
|
|
38
|
+
const PROVIDERS = {
|
|
39
|
+
google: {
|
|
40
|
+
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
41
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
42
|
+
userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
43
|
+
scope: 'openid profile email',
|
|
44
|
+
pkce: true,
|
|
45
|
+
},
|
|
46
|
+
github: {
|
|
47
|
+
authorizeUrl: 'https://github.com/login/oauth/authorize',
|
|
48
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
49
|
+
userInfoUrl: 'https://api.github.com/user',
|
|
50
|
+
scope: 'read:user user:email',
|
|
51
|
+
pkce: false, // GitHub doesn't support PKCE
|
|
52
|
+
},
|
|
53
|
+
microsoft: {
|
|
54
|
+
authorizeUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
55
|
+
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
56
|
+
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
57
|
+
scope: 'openid profile email',
|
|
58
|
+
pkce: true,
|
|
59
|
+
},
|
|
60
|
+
apple: {
|
|
61
|
+
authorizeUrl: 'https://appleid.apple.com/auth/authorize',
|
|
62
|
+
tokenUrl: 'https://appleid.apple.com/auth/token',
|
|
63
|
+
userInfoUrl: null, // Apple returns user info in the id_token
|
|
64
|
+
scope: 'name email',
|
|
65
|
+
pkce: true,
|
|
66
|
+
responseMode: 'form_post',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// -- PKCE Helpers ------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a PKCE code verifier and challenge.
|
|
74
|
+
*
|
|
75
|
+
* @param {number} [length=64] - Verifier length (43–128 per RFC 7636).
|
|
76
|
+
* @returns {{ codeVerifier: string, codeChallenge: string }}
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* const { codeVerifier, codeChallenge } = generatePKCE();
|
|
80
|
+
*/
|
|
81
|
+
function generatePKCE(length = 64)
|
|
82
|
+
{
|
|
83
|
+
const verifier = crypto.randomBytes(length).toString('base64url').slice(0, Math.max(43, Math.min(length, 128)));
|
|
84
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
85
|
+
return { codeVerifier: verifier, codeChallenge: challenge };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Generate a cryptographically random state parameter.
|
|
90
|
+
*
|
|
91
|
+
* @param {number} [bytes=32] - Entropy bytes.
|
|
92
|
+
* @returns {string} URL-safe random string.
|
|
93
|
+
*/
|
|
94
|
+
function generateState(bytes = 32)
|
|
95
|
+
{
|
|
96
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// -- OAuth2 Client -----------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create an OAuth 2.0 client for Authorization Code flow (+ PKCE).
|
|
103
|
+
*
|
|
104
|
+
* @param {object} opts - Configuration.
|
|
105
|
+
* @param {string} [opts.provider] - Built-in provider name (`'google'`, `'github'`, `'microsoft'`, `'apple'`).
|
|
106
|
+
* @param {string} opts.clientId - OAuth client ID.
|
|
107
|
+
* @param {string} opts.clientSecret - OAuth client secret.
|
|
108
|
+
* @param {string} opts.callbackUrl - Redirect URI.
|
|
109
|
+
* @param {string} [opts.authorizeUrl] - Authorization endpoint URL (overrides provider).
|
|
110
|
+
* @param {string} [opts.tokenUrl] - Token endpoint URL (overrides provider).
|
|
111
|
+
* @param {string} [opts.userInfoUrl] - UserInfo endpoint URL (overrides provider).
|
|
112
|
+
* @param {string} [opts.scope] - Default scopes (space-separated).
|
|
113
|
+
* @param {boolean} [opts.pkce=true] - Enable PKCE (default: true if provider supports it).
|
|
114
|
+
* @param {string} [opts.responseMode] - Response mode override (e.g. `'form_post'` for Apple).
|
|
115
|
+
* @param {Function} [opts.fetcher] - Custom HTTP fetch function (default: built-in fetch).
|
|
116
|
+
* @param {number} [opts.timeout=10000] - HTTP request timeout in ms.
|
|
117
|
+
* @returns {{ authorize: Function, callback: Function, refresh: Function, userInfo: Function }}
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* const client = oauth({
|
|
121
|
+
* provider: 'google',
|
|
122
|
+
* clientId: 'xxx',
|
|
123
|
+
* clientSecret: 'yyy',
|
|
124
|
+
* callbackUrl: 'https://app.com/callback',
|
|
125
|
+
* });
|
|
126
|
+
*
|
|
127
|
+
* // Build auth URL
|
|
128
|
+
* const { url, state, codeVerifier } = client.authorize();
|
|
129
|
+
*
|
|
130
|
+
* // Exchange code for tokens
|
|
131
|
+
* const tokens = await client.callback(req.query, { state: savedState, codeVerifier });
|
|
132
|
+
*/
|
|
133
|
+
function oauth(opts = {})
|
|
134
|
+
{
|
|
135
|
+
const preset = opts.provider ? PROVIDERS[opts.provider] : {};
|
|
136
|
+
if (opts.provider && !preset) throw new Error(`Unknown OAuth provider: ${opts.provider}`);
|
|
137
|
+
|
|
138
|
+
const config = {
|
|
139
|
+
authorizeUrl: opts.authorizeUrl || preset.authorizeUrl,
|
|
140
|
+
tokenUrl: opts.tokenUrl || preset.tokenUrl,
|
|
141
|
+
userInfoUrl: opts.userInfoUrl || preset.userInfoUrl || null,
|
|
142
|
+
clientId: opts.clientId,
|
|
143
|
+
clientSecret: opts.clientSecret,
|
|
144
|
+
callbackUrl: opts.callbackUrl,
|
|
145
|
+
scope: opts.scope || preset.scope || '',
|
|
146
|
+
pkce: opts.pkce !== undefined ? opts.pkce : (preset.pkce !== false),
|
|
147
|
+
responseMode: opts.responseMode || preset.responseMode || null,
|
|
148
|
+
timeout: opts.timeout || 10000,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (!config.clientId) throw new Error('oauth() requires clientId');
|
|
152
|
+
if (!config.callbackUrl) throw new Error('oauth() requires callbackUrl');
|
|
153
|
+
if (!config.authorizeUrl) throw new Error('oauth() requires authorizeUrl');
|
|
154
|
+
if (!config.tokenUrl) throw new Error('oauth() requires tokenUrl');
|
|
155
|
+
|
|
156
|
+
const fetchFn = opts.fetcher || require('../fetch');
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
/**
|
|
160
|
+
* Build the authorization URL the user should be redirected to.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} [params] - Extra parameters.
|
|
163
|
+
* @param {string} [params.scope] - Override scopes.
|
|
164
|
+
* @param {string} [params.state] - Override state (auto-generated if omitted).
|
|
165
|
+
* @param {object} [params.extra] - Additional query params to include.
|
|
166
|
+
* @returns {{ url: string, state: string, codeVerifier?: string }}
|
|
167
|
+
*/
|
|
168
|
+
authorize(params = {})
|
|
169
|
+
{
|
|
170
|
+
const state = params.state || generateState();
|
|
171
|
+
const scope = params.scope || config.scope;
|
|
172
|
+
|
|
173
|
+
const query = new URLSearchParams({
|
|
174
|
+
response_type: 'code',
|
|
175
|
+
client_id: config.clientId,
|
|
176
|
+
redirect_uri: config.callbackUrl,
|
|
177
|
+
state,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (scope) query.set('scope', scope);
|
|
181
|
+
if (config.responseMode) query.set('response_mode', config.responseMode);
|
|
182
|
+
|
|
183
|
+
let codeVerifier;
|
|
184
|
+
if (config.pkce)
|
|
185
|
+
{
|
|
186
|
+
const pkce = generatePKCE();
|
|
187
|
+
codeVerifier = pkce.codeVerifier;
|
|
188
|
+
query.set('code_challenge', pkce.codeChallenge);
|
|
189
|
+
query.set('code_challenge_method', 'S256');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Merge extra params
|
|
193
|
+
if (params.extra)
|
|
194
|
+
{
|
|
195
|
+
for (const [k, v] of Object.entries(params.extra)) query.set(k, v);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const sep = config.authorizeUrl.includes('?') ? '&' : '?';
|
|
199
|
+
const url = `${config.authorizeUrl}${sep}${query.toString()}`;
|
|
200
|
+
|
|
201
|
+
log.debug('authorize URL built for %s', config.clientId);
|
|
202
|
+
return { url, state, codeVerifier };
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Exchange an authorization code for tokens.
|
|
207
|
+
* Validates the state parameter to prevent CSRF.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} query - Callback query/body params (`{ code, state }`).
|
|
210
|
+
* @param {object} [verify] - Verification context.
|
|
211
|
+
* @param {string} [verify.state] - Expected state (must match `query.state`).
|
|
212
|
+
* @param {string} [verify.codeVerifier] - PKCE code verifier.
|
|
213
|
+
* @returns {Promise<{ access_token: string, token_type: string, expires_in?: number, refresh_token?: string, id_token?: string, scope?: string }>}
|
|
214
|
+
* @throws {Error} If state mismatches, code is missing, or token exchange fails.
|
|
215
|
+
*/
|
|
216
|
+
async callback(query, verify = {})
|
|
217
|
+
{
|
|
218
|
+
if (!query || !query.code)
|
|
219
|
+
{
|
|
220
|
+
const errMsg = query?.error_description || query?.error || 'No authorization code received';
|
|
221
|
+
throw _oauthError(errMsg, 'OAUTH_NO_CODE');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate state (CSRF prevention)
|
|
225
|
+
if (verify.state && query.state !== verify.state)
|
|
226
|
+
{
|
|
227
|
+
throw _oauthError('State mismatch — possible CSRF attack', 'OAUTH_STATE_MISMATCH');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const body = {
|
|
231
|
+
grant_type: 'authorization_code',
|
|
232
|
+
code: query.code,
|
|
233
|
+
redirect_uri: config.callbackUrl,
|
|
234
|
+
client_id: config.clientId,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (config.clientSecret) body.client_secret = config.clientSecret;
|
|
238
|
+
|
|
239
|
+
if (verify.codeVerifier)
|
|
240
|
+
{
|
|
241
|
+
body.code_verifier = verify.codeVerifier;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
log.debug('exchanging code for tokens at %s', config.tokenUrl);
|
|
245
|
+
const res = await fetchFn(config.tokenUrl, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: {
|
|
248
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
249
|
+
'Accept': 'application/json',
|
|
250
|
+
},
|
|
251
|
+
body: new URLSearchParams(body).toString(),
|
|
252
|
+
timeout: config.timeout,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const text = await res.text();
|
|
256
|
+
let tokens;
|
|
257
|
+
try
|
|
258
|
+
{
|
|
259
|
+
tokens = JSON.parse(text);
|
|
260
|
+
}
|
|
261
|
+
catch (_)
|
|
262
|
+
{
|
|
263
|
+
// Some providers (GitHub) return url-encoded responses
|
|
264
|
+
tokens = Object.fromEntries(new URLSearchParams(text));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (tokens.error)
|
|
268
|
+
{
|
|
269
|
+
throw _oauthError(tokens.error_description || tokens.error, 'OAUTH_TOKEN_ERROR');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
log.debug('tokens received: type=%s', tokens.token_type);
|
|
273
|
+
return tokens;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Refresh an access token using a refresh token.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} refreshToken - The refresh token.
|
|
280
|
+
* @returns {Promise<object>} New token set.
|
|
281
|
+
* @throws {Error} If the refresh fails.
|
|
282
|
+
*/
|
|
283
|
+
async refresh(refreshToken)
|
|
284
|
+
{
|
|
285
|
+
const body = {
|
|
286
|
+
grant_type: 'refresh_token',
|
|
287
|
+
refresh_token: refreshToken,
|
|
288
|
+
client_id: config.clientId,
|
|
289
|
+
};
|
|
290
|
+
if (config.clientSecret) body.client_secret = config.clientSecret;
|
|
291
|
+
|
|
292
|
+
log.debug('refreshing token at %s', config.tokenUrl);
|
|
293
|
+
const res = await fetchFn(config.tokenUrl, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: {
|
|
296
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
297
|
+
'Accept': 'application/json',
|
|
298
|
+
},
|
|
299
|
+
body: new URLSearchParams(body).toString(),
|
|
300
|
+
timeout: config.timeout,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const tokens = await res.json();
|
|
304
|
+
if (tokens.error)
|
|
305
|
+
{
|
|
306
|
+
throw _oauthError(tokens.error_description || tokens.error, 'OAUTH_REFRESH_ERROR');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return tokens;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Fetch user profile from the provider's userInfo endpoint.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} accessToken - OAuth access token.
|
|
316
|
+
* @returns {Promise<object>} User profile object (provider-specific schema).
|
|
317
|
+
* @throws {Error} If no userInfoUrl is configured or the request fails.
|
|
318
|
+
*/
|
|
319
|
+
async userInfo(accessToken)
|
|
320
|
+
{
|
|
321
|
+
if (!config.userInfoUrl)
|
|
322
|
+
{
|
|
323
|
+
throw _oauthError('No userInfo endpoint configured for this provider', 'OAUTH_NO_USERINFO');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const res = await fetchFn(config.userInfoUrl, {
|
|
327
|
+
headers: {
|
|
328
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
329
|
+
'Accept': 'application/json',
|
|
330
|
+
},
|
|
331
|
+
timeout: config.timeout,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!res.ok)
|
|
335
|
+
{
|
|
336
|
+
throw _oauthError(`UserInfo request failed: ${res.status}`, 'OAUTH_USERINFO_FAILED');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return res.json();
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/** The resolved configuration (read-only). */
|
|
343
|
+
config: Object.freeze({ ...config }),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// -- Helpers ---------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/** @private */
|
|
350
|
+
function _oauthError(message, code)
|
|
351
|
+
{
|
|
352
|
+
const err = new Error(message);
|
|
353
|
+
err.code = code;
|
|
354
|
+
return err;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
oauth,
|
|
359
|
+
generatePKCE,
|
|
360
|
+
generateState,
|
|
361
|
+
PROVIDERS,
|
|
362
|
+
};
|