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 +20 -0
- package/chadstart.example.yml +52 -0
- package/chadstart.schema.json +62 -0
- package/core/entity-engine.js +1 -0
- package/core/oauth.js +263 -0
- package/docs/auth.md +3 -0
- package/docs/oauth.md +869 -0
- package/mkdocs.yml +1 -0
- package/package.json +2 -1
- package/server/express-server.js +2 -0
- package/test/oauth.test.js +259 -0
package/mkdocs.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chadstart",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "YAML-first Backend as a Service — define your entire backend in one YAML file",
|
|
5
5
|
"main": "server/express-server.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"express": "^5.2.1",
|
|
49
49
|
"express-rate-limit": "^8.3.1",
|
|
50
|
+
"grant": "^5.4.24",
|
|
50
51
|
"htmx.org": "2.0.4",
|
|
51
52
|
"jsonwebtoken": "^9.0.3",
|
|
52
53
|
"mysql2": "^3.20.0",
|
package/server/express-server.js
CHANGED
|
@@ -23,6 +23,7 @@ const { loadPlugins } = require('../core/plugin-loader');
|
|
|
23
23
|
const { initErrorReporter, getRequestHandler, attachErrorHandler } = require('../core/error-reporter');
|
|
24
24
|
const { getTelemetryConfig, initTelemetry } = require('../core/telemetry');
|
|
25
25
|
const { setupFunctions, cleanup: cleanupFunctions } = require('../core/functions-engine');
|
|
26
|
+
const { registerOAuthRoutes } = require('../core/oauth');
|
|
26
27
|
const logger = require('../utils/logger');
|
|
27
28
|
|
|
28
29
|
function limiter(windowMs, max) {
|
|
@@ -133,6 +134,7 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
133
134
|
app.use('/api/auth', authLimiter);
|
|
134
135
|
registerAuthRoutes(app, core, emit);
|
|
135
136
|
registerApiKeyRoutes(app, core);
|
|
137
|
+
registerOAuthRoutes(app, core, emit);
|
|
136
138
|
|
|
137
139
|
const apiLimiters = buildApiLimiters(core);
|
|
138
140
|
app.use('/api', ...apiLimiters);
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { buildGrantConfig, normalizeProfile } = require('../core/oauth');
|
|
5
|
+
const { buildCore } = require('../core/entity-engine');
|
|
6
|
+
|
|
7
|
+
describe('OAuth – normalizeProfile', () => {
|
|
8
|
+
it('extracts email/name/providerId from standard profile', () => {
|
|
9
|
+
const result = normalizeProfile('google', {
|
|
10
|
+
email: 'user@example.com',
|
|
11
|
+
name: 'John Doe',
|
|
12
|
+
sub: '123456',
|
|
13
|
+
});
|
|
14
|
+
assert.strictEqual(result.email, 'user@example.com');
|
|
15
|
+
assert.strictEqual(result.name, 'John Doe');
|
|
16
|
+
assert.strictEqual(result.providerId, '123456');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('extracts email from emails array', () => {
|
|
20
|
+
const result = normalizeProfile('github', {
|
|
21
|
+
emails: [{ value: 'gh@example.com' }],
|
|
22
|
+
login: 'octocat',
|
|
23
|
+
id: 42,
|
|
24
|
+
});
|
|
25
|
+
assert.strictEqual(result.email, 'gh@example.com');
|
|
26
|
+
assert.strictEqual(result.name, 'octocat');
|
|
27
|
+
assert.strictEqual(result.providerId, '42');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles displayName fallback', () => {
|
|
31
|
+
const result = normalizeProfile('discord', {
|
|
32
|
+
email: 'dc@example.com',
|
|
33
|
+
displayName: 'DiscordUser',
|
|
34
|
+
id: '999',
|
|
35
|
+
});
|
|
36
|
+
assert.strictEqual(result.name, 'DiscordUser');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('handles first_name + last_name fallback', () => {
|
|
40
|
+
const result = normalizeProfile('facebook', {
|
|
41
|
+
email: 'fb@example.com',
|
|
42
|
+
first_name: 'Jane',
|
|
43
|
+
last_name: 'Smith',
|
|
44
|
+
id: '555',
|
|
45
|
+
});
|
|
46
|
+
assert.strictEqual(result.name, 'Jane Smith');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('handles missing profile', () => {
|
|
50
|
+
const result = normalizeProfile('google', null);
|
|
51
|
+
assert.strictEqual(result.email, null);
|
|
52
|
+
assert.strictEqual(result.name, null);
|
|
53
|
+
assert.strictEqual(result.providerId, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles empty profile', () => {
|
|
57
|
+
const result = normalizeProfile('google', {});
|
|
58
|
+
assert.strictEqual(result.email, null);
|
|
59
|
+
assert.strictEqual(result.name, null);
|
|
60
|
+
assert.strictEqual(result.providerId, null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles mail field (used by some providers)', () => {
|
|
64
|
+
const result = normalizeProfile('microsoft', {
|
|
65
|
+
mail: 'ms@example.com',
|
|
66
|
+
name: 'MS User',
|
|
67
|
+
user_id: 'abc123',
|
|
68
|
+
});
|
|
69
|
+
assert.strictEqual(result.email, 'ms@example.com');
|
|
70
|
+
assert.strictEqual(result.providerId, 'abc123');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('uses username as name fallback', () => {
|
|
74
|
+
const result = normalizeProfile('gitlab', {
|
|
75
|
+
email: 'gl@example.com',
|
|
76
|
+
username: 'gluser',
|
|
77
|
+
id: 789,
|
|
78
|
+
});
|
|
79
|
+
assert.strictEqual(result.name, 'gluser');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses account_id as providerId fallback', () => {
|
|
83
|
+
const result = normalizeProfile('dropbox', {
|
|
84
|
+
email: 'db@example.com',
|
|
85
|
+
account_id: 'dbid:AAA',
|
|
86
|
+
});
|
|
87
|
+
assert.strictEqual(result.providerId, 'dbid:AAA');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('handles emails array with plain strings', () => {
|
|
91
|
+
const result = normalizeProfile('other', {
|
|
92
|
+
emails: ['plain@example.com'],
|
|
93
|
+
id: '10',
|
|
94
|
+
});
|
|
95
|
+
assert.strictEqual(result.email, 'plain@example.com');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('OAuth – buildGrantConfig', () => {
|
|
100
|
+
const baseUrl = 'http://localhost:3000';
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
// Clean up env vars set during tests
|
|
104
|
+
delete process.env.OAUTH_GOOGLE_KEY;
|
|
105
|
+
delete process.env.OAUTH_GOOGLE_SECRET;
|
|
106
|
+
delete process.env.OAUTH_GITHUB_KEY;
|
|
107
|
+
delete process.env.OAUTH_GITHUB_SECRET;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('builds config with defaults', () => {
|
|
111
|
+
const oauthConfig = {
|
|
112
|
+
providers: {
|
|
113
|
+
google: {
|
|
114
|
+
scope: ['openid', 'email'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
119
|
+
|
|
120
|
+
assert.strictEqual(config.defaults.origin, 'http://localhost:3000');
|
|
121
|
+
assert.strictEqual(config.defaults.transport, 'querystring');
|
|
122
|
+
assert.ok(config.google);
|
|
123
|
+
assert.deepStrictEqual(config.google.scope, ['openid', 'email']);
|
|
124
|
+
assert.strictEqual(config.google.callback, '/api/auth/oauth/callback');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('uses env vars for key/secret', () => {
|
|
128
|
+
process.env.OAUTH_GOOGLE_KEY = 'env-key';
|
|
129
|
+
process.env.OAUTH_GOOGLE_SECRET = 'env-secret';
|
|
130
|
+
|
|
131
|
+
const oauthConfig = {
|
|
132
|
+
providers: {
|
|
133
|
+
google: {
|
|
134
|
+
scope: ['openid'],
|
|
135
|
+
key: 'yaml-key', // Should be overridden by env
|
|
136
|
+
secret: 'yaml-secret',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
141
|
+
|
|
142
|
+
assert.strictEqual(config.google.key, 'env-key');
|
|
143
|
+
assert.strictEqual(config.google.secret, 'env-secret');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('falls back to YAML key/secret when env not set', () => {
|
|
147
|
+
const oauthConfig = {
|
|
148
|
+
providers: {
|
|
149
|
+
github: {
|
|
150
|
+
key: 'yaml-key',
|
|
151
|
+
secret: 'yaml-secret',
|
|
152
|
+
scope: ['user:email'],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
157
|
+
|
|
158
|
+
assert.strictEqual(config.github.key, 'yaml-key');
|
|
159
|
+
assert.strictEqual(config.github.secret, 'yaml-secret');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('supports multiple providers', () => {
|
|
163
|
+
const oauthConfig = {
|
|
164
|
+
providers: {
|
|
165
|
+
google: { scope: ['email'] },
|
|
166
|
+
github: { scope: ['user:email'] },
|
|
167
|
+
discord: { scope: ['identify'] },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
171
|
+
|
|
172
|
+
assert.ok(config.google);
|
|
173
|
+
assert.ok(config.github);
|
|
174
|
+
assert.ok(config.discord);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('preserves custom callback', () => {
|
|
178
|
+
const oauthConfig = {
|
|
179
|
+
providers: {
|
|
180
|
+
google: {
|
|
181
|
+
callback: '/my-custom-callback',
|
|
182
|
+
scope: ['email'],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
187
|
+
|
|
188
|
+
assert.strictEqual(config.google.callback, '/my-custom-callback');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('strips trailing slash from origin', () => {
|
|
192
|
+
const config = buildGrantConfig({ providers: { google: {} } }, 'http://localhost:3000/');
|
|
193
|
+
assert.strictEqual(config.defaults.origin, 'http://localhost:3000');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('merges custom defaults', () => {
|
|
197
|
+
const oauthConfig = {
|
|
198
|
+
defaults: { transport: 'session' },
|
|
199
|
+
providers: { google: {} },
|
|
200
|
+
};
|
|
201
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
202
|
+
assert.strictEqual(config.defaults.transport, 'session');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('handles custom_params', () => {
|
|
206
|
+
const oauthConfig = {
|
|
207
|
+
providers: {
|
|
208
|
+
google: {
|
|
209
|
+
scope: ['openid'],
|
|
210
|
+
custom_params: { access_type: 'offline' },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
const config = buildGrantConfig(oauthConfig, baseUrl);
|
|
215
|
+
assert.deepStrictEqual(config.google.custom_params, { access_type: 'offline' });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('OAuth – buildCore integration', () => {
|
|
220
|
+
it('buildCore includes oauth config when present', () => {
|
|
221
|
+
const core = buildCore({
|
|
222
|
+
name: 'TestApp',
|
|
223
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
224
|
+
oauth: {
|
|
225
|
+
entity: 'User',
|
|
226
|
+
providers: { google: { scope: ['email'] } },
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
assert.ok(core.oauth);
|
|
231
|
+
assert.strictEqual(core.oauth.entity, 'User');
|
|
232
|
+
assert.ok(core.oauth.providers.google);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('buildCore sets oauth to null when not configured', () => {
|
|
236
|
+
const core = buildCore({
|
|
237
|
+
name: 'TestApp',
|
|
238
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
assert.strictEqual(core.oauth, null);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('buildCore preserves successRedirect and errorRedirect', () => {
|
|
245
|
+
const core = buildCore({
|
|
246
|
+
name: 'TestApp',
|
|
247
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
248
|
+
oauth: {
|
|
249
|
+
entity: 'User',
|
|
250
|
+
successRedirect: '/dashboard',
|
|
251
|
+
errorRedirect: '/login?error=true',
|
|
252
|
+
providers: { github: { scope: ['user:email'] } },
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
assert.strictEqual(core.oauth.successRedirect, '/dashboard');
|
|
257
|
+
assert.strictEqual(core.oauth.errorRedirect, '/login?error=true');
|
|
258
|
+
});
|
|
259
|
+
});
|