chadstart 1.0.1 → 1.0.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/.env.example CHANGED
@@ -53,3 +53,23 @@ TOKEN_SECRET_KEY=replace-with-a-long-random-secret
53
53
  # 💡 Bugsink (https://www.bugsink.com) is a self-hosted alternative to Sentry
54
54
  # that uses the same Sentry SDK — just point SENTRY_DSN at your Bugsink instance.
55
55
  # SENTRY_DSN=https://xxxxx@oXXXXX.ingest.sentry.io/XXXXXXX
56
+
57
+ # Optional: OAuth / Social Login (powered by grant)
58
+ # For each provider, set OAUTH_<PROVIDER>_KEY and OAUTH_<PROVIDER>_SECRET.
59
+ # Provider names must be uppercase (e.g. GOOGLE, GITHUB, FACEBOOK).
60
+ # See docs/oauth.md for the full list of 200+ supported providers.
61
+ #
62
+ # OAUTH_GOOGLE_KEY=your-google-client-id
63
+ # OAUTH_GOOGLE_SECRET=your-google-client-secret
64
+ # OAUTH_GITHUB_KEY=your-github-client-id
65
+ # OAUTH_GITHUB_SECRET=your-github-client-secret
66
+ # OAUTH_FACEBOOK_KEY=your-facebook-app-id
67
+ # OAUTH_FACEBOOK_SECRET=your-facebook-app-secret
68
+ # OAUTH_DISCORD_KEY=your-discord-client-id
69
+ # OAUTH_DISCORD_SECRET=your-discord-client-secret
70
+ # OAUTH_APPLE_KEY=your-apple-client-id
71
+ # OAUTH_APPLE_SECRET=your-apple-client-secret
72
+ # OAUTH_MICROSOFT_KEY=your-microsoft-client-id
73
+ # OAUTH_MICROSOFT_SECRET=your-microsoft-client-secret
74
+ # OAUTH_TWITTER_KEY=your-twitter-api-key
75
+ # OAUTH_TWITTER_SECRET=your-twitter-api-secret
@@ -414,3 +414,55 @@ sentry:
414
414
  environment: production # Label sent to Sentry. Defaults to NODE_ENV.
415
415
  tracesSampleRate: 1.0 # Fraction of transactions to sample (0.0–1.0)
416
416
  debug: false # Enable Sentry SDK debug logging
417
+
418
+ # ── OAuth / Social Login ─────────────────────────────────────────────────────
419
+ # Powered by the "grant" library — supports 200+ OAuth providers.
420
+ # Secrets (client keys / secrets) MUST be set via environment variables:
421
+ # OAUTH_<PROVIDER>_KEY — client / app ID
422
+ # OAUTH_<PROVIDER>_SECRET — client / app secret
423
+ # See docs/oauth.md for the full provider list and setup guides.
424
+
425
+ oauth:
426
+ # Which authenticable entity to create/find users in (default: first authenticable entity).
427
+ entity: User
428
+
429
+ # Where to redirect after successful login. The JWT token is appended as ?token=...
430
+ # If omitted, the callback returns JSON instead.
431
+ successRedirect: /login?success=true
432
+
433
+ # Where to redirect on error. The error message is appended as ?error=...
434
+ errorRedirect: /login?error=true
435
+
436
+ # Default settings applied to all providers.
437
+ defaults:
438
+ transport: querystring
439
+
440
+ # Configure each provider you want to support.
441
+ # The provider names must match grant's provider names (lowercase).
442
+ # Full list: https://github.com/simov/grant#200-supported-providers
443
+ providers:
444
+ google:
445
+ scope:
446
+ - openid
447
+ - email
448
+ - profile
449
+ custom_params:
450
+ access_type: offline
451
+ # key and secret via: OAUTH_GOOGLE_KEY, OAUTH_GOOGLE_SECRET
452
+
453
+ github:
454
+ scope:
455
+ - user:email
456
+ # key and secret via: OAUTH_GITHUB_KEY, OAUTH_GITHUB_SECRET
457
+
458
+ # facebook:
459
+ # scope:
460
+ # - email
461
+ # - public_profile
462
+ # key and secret via: OAUTH_FACEBOOK_KEY, OAUTH_FACEBOOK_SECRET
463
+
464
+ # discord:
465
+ # scope:
466
+ # - identify
467
+ # - email
468
+ # key and secret via: OAUTH_DISCORD_KEY, OAUTH_DISCORD_SECRET
@@ -124,6 +124,43 @@
124
124
  "description": "Enable Sentry SDK debug logging."
125
125
  }
126
126
  }
127
+ },
128
+ "oauth": {
129
+ "type": "object",
130
+ "description": "OAuth / social login configuration powered by the grant library. Secrets (client keys and secrets) must be supplied via OAUTH_<PROVIDER>_KEY and OAUTH_<PROVIDER>_SECRET environment variables.",
131
+ "additionalProperties": false,
132
+ "properties": {
133
+ "entity": {
134
+ "type": "string",
135
+ "description": "Name of the authenticable entity to use for OAuth users (e.g. 'User'). Defaults to the first authenticable entity."
136
+ },
137
+ "successRedirect": {
138
+ "type": "string",
139
+ "description": "URL to redirect to after successful OAuth login. The JWT token is appended as a ?token= query parameter. If omitted, returns JSON."
140
+ },
141
+ "errorRedirect": {
142
+ "type": "string",
143
+ "description": "URL to redirect to on OAuth error. The error message is appended as an ?error= query parameter. If omitted, returns JSON error."
144
+ },
145
+ "defaults": {
146
+ "type": "object",
147
+ "description": "Default settings applied to all providers (e.g. transport, scope).",
148
+ "properties": {
149
+ "transport": {
150
+ "type": "string",
151
+ "enum": ["querystring", "session"],
152
+ "default": "querystring"
153
+ }
154
+ }
155
+ },
156
+ "providers": {
157
+ "type": "object",
158
+ "description": "Map of OAuth provider names to their configuration. Provider names must match grant's supported provider list (e.g. google, github, facebook). See https://www.npmjs.com/package/grant for all 200+ supported providers.",
159
+ "additionalProperties": {
160
+ "$ref": "#/$defs/oauthProvider"
161
+ }
162
+ }
163
+ }
127
164
  }
128
165
  },
129
166
  "$defs": {
@@ -362,6 +399,31 @@
362
399
  "limit": { "type": "integer", "description": "Maximum number of requests allowed in the time window." },
363
400
  "ttl": { "type": "integer", "description": "Time window in milliseconds." }
364
401
  }
402
+ },
403
+ "oauthProvider": {
404
+ "type": "object",
405
+ "description": "Configuration for a single OAuth provider. The key and secret should be set via OAUTH_<PROVIDER>_KEY and OAUTH_<PROVIDER>_SECRET environment variables.",
406
+ "properties": {
407
+ "key": { "type": "string", "description": "OAuth client/app ID. Prefer using OAUTH_<PROVIDER>_KEY env var instead." },
408
+ "secret": { "type": "string", "description": "OAuth client/app secret. Prefer using OAUTH_<PROVIDER>_SECRET env var instead." },
409
+ "scope": {
410
+ "oneOf": [
411
+ { "type": "string" },
412
+ { "type": "array", "items": { "type": "string" } }
413
+ ],
414
+ "description": "OAuth scopes to request (e.g. 'openid email profile')."
415
+ },
416
+ "callback": { "type": "string", "description": "Custom callback URL path. Defaults to /api/auth/oauth/callback." },
417
+ "custom_params": { "type": "object", "description": "Extra query parameters to send to the authorization URL." },
418
+ "subdomain": { "type": "string", "description": "Subdomain for providers that require one (e.g. Shopify)." },
419
+ "nonce": { "type": "boolean", "description": "Enable nonce generation (required by some OIDC providers)." },
420
+ "pkce": { "type": "boolean", "description": "Enable PKCE (Proof Key for Code Exchange) for enhanced security." },
421
+ "response": {
422
+ "type": "array",
423
+ "items": { "type": "string" },
424
+ "description": "Data to include in the callback (e.g. ['tokens', 'profile'])."
425
+ }
426
+ }
365
427
  }
366
428
  }
367
429
  }
@@ -155,6 +155,7 @@ function buildCore(config) {
155
155
  port: parseInt(process.env.CHADSTART_PORT || process.env.PORT || config.port || 3000, 10),
156
156
  rateLimits,
157
157
  telemetry,
158
+ oauth: config.oauth || null,
158
159
  admin: {
159
160
  enable_app: adminCfg.enable_app !== false,
160
161
  enable_entity: adminCfg.enable_entity !== false,
package/core/oauth.js ADDED
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * OAuth / Social Login via the "grant" library.
5
+ *
6
+ * When the YAML config contains an `oauth` section, this module:
7
+ * 1. Mounts the Grant middleware at /connect (handles the redirect dance).
8
+ * 2. Registers a callback route at /api/auth/oauth/callback that
9
+ * finds-or-creates the user and returns a JWT.
10
+ *
11
+ * Secrets (client IDs, client secrets) must be supplied via environment
12
+ * variables — never place them in the YAML file.
13
+ */
14
+
15
+ const crypto = require('crypto');
16
+ const Grant = require('grant').express();
17
+ const rateLimit = require('express-rate-limit');
18
+ const { signToken } = require('./auth');
19
+ const db = require('./db');
20
+ const logger = require('../utils/logger');
21
+
22
+ const oauthLimiter = rateLimit({
23
+ windowMs: 15 * 60 * 1000,
24
+ max: 30,
25
+ standardHeaders: true,
26
+ legacyHeaders: false,
27
+ message: { error: 'Too many OAuth requests, please try again later.' },
28
+ });
29
+
30
+ // ─── Provider profile normalisation ─────────────────────────────────────────
31
+
32
+ /**
33
+ * Normalise the profile returned by Grant into { email, name, providerId }.
34
+ * Different providers return profile data in different shapes.
35
+ *
36
+ * @param {string} provider Lowercase provider key (e.g. "google").
37
+ * @param {object} profile Raw profile object from the OAuth provider.
38
+ * @returns {{ email: string|null, name: string|null, providerId: string|null }}
39
+ */
40
+ function normalizeProfile(provider, profile) {
41
+ if (!profile) return { email: null, name: null, providerId: null };
42
+
43
+ const email =
44
+ profile.email ||
45
+ profile.mail ||
46
+ (profile.emails && profile.emails[0] && (profile.emails[0].value || profile.emails[0])) ||
47
+ null;
48
+ const name =
49
+ profile.name ||
50
+ profile.displayName ||
51
+ profile.login ||
52
+ profile.username ||
53
+ (profile.first_name ? `${profile.first_name} ${profile.last_name || ''}`.trim() : null) ||
54
+ null;
55
+ const providerId =
56
+ String(profile.sub || profile.id || profile.user_id || profile.account_id || '');
57
+
58
+ return { email: email || null, name: name || null, providerId: providerId || null };
59
+ }
60
+
61
+ // ─── Grant config builder ───────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Build the Grant configuration object from the parsed YAML `oauth` section.
65
+ *
66
+ * Environment variable overrides (per-provider):
67
+ * OAUTH_<PROVIDER>_KEY – client/app ID
68
+ * OAUTH_<PROVIDER>_SECRET – client/app secret
69
+ *
70
+ * @param {object} oauthConfig The `oauth` block from the YAML config.
71
+ * @param {string} baseUrl The application base URL (e.g. http://localhost:3000).
72
+ * @returns {object} Configuration object accepted by Grant.
73
+ */
74
+ function buildGrantConfig(oauthConfig, baseUrl) {
75
+ const defaults = oauthConfig.defaults || {};
76
+ const origin = baseUrl.replace(/\/$/, '');
77
+
78
+ const grantConfig = {
79
+ defaults: {
80
+ origin,
81
+ transport: 'querystring',
82
+ prefix: '/connect',
83
+ ...defaults,
84
+ },
85
+ };
86
+
87
+ const providers = oauthConfig.providers || {};
88
+ for (const [name, cfg] of Object.entries(providers)) {
89
+ const envPrefix = `OAUTH_${name.toUpperCase()}_`;
90
+ const key = process.env[`${envPrefix}KEY`] || cfg.key || '';
91
+ const secret = process.env[`${envPrefix}SECRET`] || cfg.secret || '';
92
+
93
+ grantConfig[name] = {
94
+ ...cfg,
95
+ key,
96
+ secret,
97
+ // callback is where Grant redirects after the OAuth dance;
98
+ // we point it at our own API endpoint which issues a JWT.
99
+ callback: cfg.callback || `/api/auth/oauth/callback`,
100
+ };
101
+ }
102
+
103
+ return grantConfig;
104
+ }
105
+
106
+ // ─── Route registration ─────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Mount the Grant middleware and register the OAuth callback route.
110
+ *
111
+ * @param {import('express').Application} app
112
+ * @param {object} core Parsed core configuration.
113
+ * @param {Function} emit EventBus emit function.
114
+ */
115
+ function registerOAuthRoutes(app, core, emit) {
116
+ const oauthConfig = core.oauth;
117
+ if (!oauthConfig || !oauthConfig.providers || Object.keys(oauthConfig.providers).length === 0) {
118
+ return; // OAuth not configured — skip
119
+ }
120
+
121
+ const baseUrl = (
122
+ process.env.BASE_URL ||
123
+ `http://localhost:${core.port}`
124
+ ).replace(/\/$/, '');
125
+
126
+ const grantConfig = buildGrantConfig(oauthConfig, baseUrl);
127
+
128
+ // Mount grant middleware — handles /connect/:provider and /connect/:provider/callback
129
+ app.use(Grant(grantConfig));
130
+ logger.info(' Mounted OAuth middleware at /connect');
131
+
132
+ // Determine the target authenticable entity for OAuth users.
133
+ // The `oauth.entity` field specifies which entity to use (default: first authenticable entity).
134
+ const targetEntityName = oauthConfig.entity || null;
135
+ const entity =
136
+ (targetEntityName && core.authenticableEntities[targetEntityName]) ||
137
+ Object.values(core.authenticableEntities)[0];
138
+
139
+ if (!entity) {
140
+ logger.warn(' OAuth: no authenticable entity found — OAuth callback will not work.');
141
+ return;
142
+ }
143
+
144
+ const _emit = typeof emit === 'function' ? emit : () => {};
145
+
146
+ /**
147
+ * GET /api/auth/oauth/callback
148
+ *
149
+ * Grant redirects here with query-string parameters after the OAuth dance.
150
+ * We extract the access_token / profile, find-or-create the user, and
151
+ * return a JWT (or redirect if `oauth.successRedirect` is set).
152
+ */
153
+ app.get('/api/auth/oauth/callback', oauthLimiter, async (req, res) => {
154
+ try {
155
+ const { access_token, profile, provider, error } = req.query;
156
+
157
+ if (error) {
158
+ return _handleError(res, oauthConfig, `OAuth error: ${error}`);
159
+ }
160
+
161
+ if (!access_token && !profile) {
162
+ return _handleError(res, oauthConfig, 'OAuth callback missing token and profile');
163
+ }
164
+
165
+ // Grant may pass the profile as a JSON string in the querystring
166
+ let parsedProfile = {};
167
+ if (profile) {
168
+ try { parsedProfile = typeof profile === 'string' ? JSON.parse(profile) : profile; } catch { /* ignore */ }
169
+ }
170
+
171
+ const providerName = (provider || 'unknown').toLowerCase();
172
+ const { email, name, providerId } = normalizeProfile(providerName, parsedProfile);
173
+
174
+ if (!email && !providerId) {
175
+ return _handleError(res, oauthConfig, 'Could not obtain email or provider ID from OAuth profile');
176
+ }
177
+
178
+ // Find or create the user
179
+ const user = await _findOrCreateOAuthUser(entity, {
180
+ email,
181
+ name,
182
+ provider: providerName,
183
+ providerId,
184
+ accessToken: access_token || null,
185
+ });
186
+
187
+ _emit(`${entity.name}.oauthLogin`, { provider: providerName, userId: user.id });
188
+
189
+ const token = signToken({ id: user.id, entity: entity.name });
190
+
191
+ // Redirect or return JSON based on config
192
+ const successRedirect = oauthConfig.successRedirect;
193
+ if (successRedirect) {
194
+ const sep = successRedirect.includes('?') ? '&' : '?';
195
+ return res.redirect(`${successRedirect}${sep}token=${encodeURIComponent(token)}`);
196
+ }
197
+
198
+ res.json({ token, user: _omitSensitive(user) });
199
+ } catch (e) {
200
+ logger.error('OAuth callback error:', e.message);
201
+ return _handleError(res, oauthConfig, e.message);
202
+ }
203
+ });
204
+
205
+ // List configured providers
206
+ app.get('/api/auth/oauth/providers', oauthLimiter, (_req, res) => {
207
+ const providers = Object.keys(oauthConfig.providers || {});
208
+ res.json({ providers });
209
+ });
210
+
211
+ const providerNames = Object.keys(oauthConfig.providers);
212
+ logger.info(` OAuth providers: ${providerNames.join(', ')}`);
213
+ logger.info(` OAuth callback: /api/auth/oauth/callback`);
214
+ logger.info(` OAuth entity: ${entity.name}`);
215
+ }
216
+
217
+ // ─── Helpers ────────────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Find an existing user by email or create a new one.
221
+ */
222
+ async function _findOrCreateOAuthUser(entity, { email, name, provider, providerId, accessToken }) {
223
+ const table = entity.tableName;
224
+
225
+ // 1. Try to find by email
226
+ if (email) {
227
+ const existing = await db.findAllSimple(table, { email });
228
+ if (existing.length > 0) return existing[0];
229
+ }
230
+
231
+ // 2. Create a new user with a random password (they authenticate via OAuth)
232
+ const newUser = {
233
+ email: email || `${provider}_${providerId}@oauth.local`,
234
+ password: crypto.randomBytes(32).toString('hex'), // random placeholder
235
+ };
236
+
237
+ // Add optional fields if the entity supports them
238
+ const propNames = new Set(entity.properties.map((p) => p.name));
239
+ if (name && propNames.has('name')) newUser.name = name;
240
+
241
+ const bcrypt = require('bcryptjs');
242
+ newUser.password = await bcrypt.hash(newUser.password, 10);
243
+
244
+ const created = await db.create(table, newUser);
245
+ return created;
246
+ }
247
+
248
+ function _omitSensitive(user) {
249
+ if (!user) return null;
250
+ const { password: _, ...rest } = user;
251
+ return rest;
252
+ }
253
+
254
+ function _handleError(res, oauthConfig, message) {
255
+ const errorRedirect = oauthConfig.errorRedirect;
256
+ if (errorRedirect) {
257
+ const sep = errorRedirect.includes('?') ? '&' : '?';
258
+ return res.redirect(`${errorRedirect}${sep}error=${encodeURIComponent(message)}`);
259
+ }
260
+ return res.status(400).json({ error: message });
261
+ }
262
+
263
+ module.exports = { registerOAuthRoutes, buildGrantConfig, normalizeProfile };
package/docs/auth.md CHANGED
@@ -42,6 +42,9 @@ entities:
42
42
 
43
43
  Authenticable entities have 2 extra properties that are used as credentials to log in: `email` and `password`. You do not need to specify them.The `email` property expects a unique valid emails and the `password` property is automatically hashed using _bcryt_ with 10 salt rounds.
44
44
 
45
+ !!! tip "Social Login"
46
+ Want users to log in with Google, GitHub, Discord, or other OAuth providers? See the [OAuth / Social Login](./oauth.md) guide.
47
+
45
48
  ## Actions
46
49
 
47
50
  ### Login