@mirrormedia/lilith-google-auth 0.1.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/@types/google.d.ts +21 -0
- package/@types/index.d.ts +11 -0
- package/@types/log.d.ts +31 -0
- package/@types/mini-app.d.ts +8 -0
- package/@types/password-guard.d.ts +15 -0
- package/@types/password-plugin.d.ts +30 -0
- package/@types/redirect.d.ts +5 -0
- package/@types/session.d.ts +20 -0
- package/@types/signin-page.d.ts +8 -0
- package/@types/state-cookie.d.ts +12 -0
- package/@types/types.d.ts +69 -0
- package/@types/with-google-auth.d.ts +46 -0
- package/README.md +237 -0
- package/lib/google.js +59 -0
- package/lib/index.js +57 -0
- package/lib/log.js +68 -0
- package/lib/mini-app.js +267 -0
- package/lib/password-guard.js +66 -0
- package/lib/password-plugin.js +86 -0
- package/lib/redirect.js +21 -0
- package/lib/session.js +59 -0
- package/lib/signin-page.js +55 -0
- package/lib/state-cookie.js +64 -0
- package/lib/types.js +5 -0
- package/lib/with-google-auth.js +54 -0
- package/package.json +46 -0
package/lib/log.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.emitLogEntry = emitLogEntry;
|
|
7
|
+
exports.formatErrorEntry = formatErrorEntry;
|
|
8
|
+
exports.formatLogEntry = formatLogEntry;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Single-line JSON envelope for Cloud Logging. `severity` lets Cloud Logging
|
|
12
|
+
* parse the entry as `jsonPayload` instead of a multi-line `textPayload`, and
|
|
13
|
+
* an `ERROR` entry whose `message` is a stack trace is picked up by Error
|
|
14
|
+
* Reporting. Shape is shared with the upcoming lilith-core password-login
|
|
15
|
+
* change (separate PR); this file intentionally has no dependency on
|
|
16
|
+
* `@twreporter/errors`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maps a login event to a log entry. Fields stay top-level (no nesting) so a
|
|
21
|
+
* Logs Explorer query can filter on e.g. `jsonPayload.userId` directly.
|
|
22
|
+
*/
|
|
23
|
+
function formatLogEntry(event) {
|
|
24
|
+
if (event.outcome === 'success') {
|
|
25
|
+
return {
|
|
26
|
+
severity: 'INFO',
|
|
27
|
+
message: 'google-login success',
|
|
28
|
+
...event
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
severity: 'WARNING',
|
|
34
|
+
message: `google-login failure: ${event.reason}`,
|
|
35
|
+
...event
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Maps an unexpected throw (callback catch-all, or a caller-supplied logger
|
|
40
|
+
* throwing) to an ERROR entry. `message` is the stack trace when available so
|
|
41
|
+
* Error Reporting can group and display it; a non-Error thrown value falls
|
|
42
|
+
* back to its string form.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
function formatErrorEntry(err, context) {
|
|
47
|
+
const message = err instanceof Error && err.stack ? err.stack : String(err);
|
|
48
|
+
return {
|
|
49
|
+
severity: 'ERROR',
|
|
50
|
+
message,
|
|
51
|
+
type: context.type,
|
|
52
|
+
stage: context.stage,
|
|
53
|
+
email: context.email,
|
|
54
|
+
timestamp: new Date().toISOString()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Prints `entry` as a single JSON line: `console.error` for ERROR, `console.log` otherwise. */
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
function emitLogEntry(entry) {
|
|
61
|
+
const line = JSON.stringify(entry);
|
|
62
|
+
|
|
63
|
+
if (entry.severity === 'ERROR') {
|
|
64
|
+
console.error(line);
|
|
65
|
+
} else {
|
|
66
|
+
console.log(line);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/mini-app.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.createGoogleAuthMiniApp = createGoogleAuthMiniApp;
|
|
7
|
+
|
|
8
|
+
var _express = require("express");
|
|
9
|
+
|
|
10
|
+
var _cookie = require("cookie");
|
|
11
|
+
|
|
12
|
+
var _google = require("./google");
|
|
13
|
+
|
|
14
|
+
var _log = require("./log");
|
|
15
|
+
|
|
16
|
+
var _passwordGuard = require("./password-guard");
|
|
17
|
+
|
|
18
|
+
var _redirect = require("./redirect");
|
|
19
|
+
|
|
20
|
+
var _session = require("./session");
|
|
21
|
+
|
|
22
|
+
var _signinPage = require("./signin-page");
|
|
23
|
+
|
|
24
|
+
var _stateCookie = require("./state-cookie");
|
|
25
|
+
|
|
26
|
+
/** Express path syntax that would silently turn callbackPath into a pattern. */
|
|
27
|
+
const PATH_PATTERN_CHARS = /[:()*?+]/;
|
|
28
|
+
/**
|
|
29
|
+
* The mini-app is mounted before the host's own middleware (see
|
|
30
|
+
* withGoogleAuth), so it cannot inherit a host X-Robots-Tag middleware and
|
|
31
|
+
* stamps its own pages instead. Pass-through and guard responses are left
|
|
32
|
+
* alone: those belong to the host.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const ROBOTS_TAG = 'noindex, nofollow, noimageindex';
|
|
36
|
+
|
|
37
|
+
function fail(reason) {
|
|
38
|
+
throw new Error(`[google-auth] ${reason}`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fails fast on an option set that cannot produce a working flow. A CMS that
|
|
42
|
+
* boots with a broken sign-in is worse than one that refuses to boot.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
function validateOptions(options) {
|
|
47
|
+
var _options$clientId, _options$clientSecret, _options$stateSecret;
|
|
48
|
+
|
|
49
|
+
if (!((_options$clientId = options.clientId) !== null && _options$clientId !== void 0 && _options$clientId.trim())) fail('clientId is required');
|
|
50
|
+
if (!((_options$clientSecret = options.clientSecret) !== null && _options$clientSecret !== void 0 && _options$clientSecret.trim())) fail('clientSecret is required');
|
|
51
|
+
if (!((_options$stateSecret = options.stateSecret) !== null && _options$stateSecret !== void 0 && _options$stateSecret.trim())) fail('stateSecret is required');
|
|
52
|
+
|
|
53
|
+
if (options.stateSecret.length < 32) {
|
|
54
|
+
fail('stateSecret must be at least 32 characters');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allowedDomains = (options.allowedDomains ?? []).map(domain => domain.trim().toLowerCase()).filter(Boolean);
|
|
58
|
+
|
|
59
|
+
if (allowedDomains.length === 0) {
|
|
60
|
+
fail('allowedDomains must list at least one Workspace domain');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let callbackUrl;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
callbackUrl = new URL(options.callbackUrl);
|
|
67
|
+
} catch {
|
|
68
|
+
fail(`callbackUrl must be an absolute URL, got ${options.callbackUrl}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (callbackUrl.protocol !== 'http:' && callbackUrl.protocol !== 'https:') {
|
|
72
|
+
fail(`callbackUrl must be http(s), got ${callbackUrl.protocol}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const callbackPath = callbackUrl.pathname;
|
|
76
|
+
|
|
77
|
+
if (callbackPath === '/' || PATH_PATTERN_CHARS.test(callbackPath)) {
|
|
78
|
+
fail(`callbackUrl path is not usable as a route: ${callbackPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
allowedDomains,
|
|
83
|
+
callbackPath
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createGoogleAuthMiniApp(options, deps = {}) {
|
|
88
|
+
const {
|
|
89
|
+
allowedDomains,
|
|
90
|
+
callbackPath
|
|
91
|
+
} = validateOptions(options);
|
|
92
|
+
const passwordLoginEnabled = options.passwordLoginEnabled !== false;
|
|
93
|
+
const graphqlPath = options.graphqlPath ?? '/api/graphql';
|
|
94
|
+
const redirectDefault = options.signinRedirectDefault ?? '/';
|
|
95
|
+
const log = options.logger ?? defaultLogger;
|
|
96
|
+
const google = deps.google ?? (0, _google.createGoogleClient)({
|
|
97
|
+
clientId: options.clientId,
|
|
98
|
+
clientSecret: options.clientSecret,
|
|
99
|
+
callbackUrl: options.callbackUrl
|
|
100
|
+
});
|
|
101
|
+
const secureCookie = options.callbackUrl.startsWith('https://');
|
|
102
|
+
const router = (0, _express.Router)();
|
|
103
|
+
|
|
104
|
+
if (!passwordLoginEnabled) {
|
|
105
|
+
// First of two layers. This one never sees a multipart request, so the
|
|
106
|
+
// host must also register createPasswordLoginBlockPlugin() (see README).
|
|
107
|
+
router.use(graphqlPath, ...(0, _passwordGuard.createPasswordLoginGuard)());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
router.get('/signin', (req, res, next) => {
|
|
111
|
+
if (passwordLoginEnabled && req.query.password === '1') return next();
|
|
112
|
+
const from = typeof req.query.from === 'string' ? req.query.from : undefined;
|
|
113
|
+
const error = typeof req.query.error === 'string' ? req.query.error : undefined;
|
|
114
|
+
res.status(200).type('html').set('Cache-Control', 'no-store').set('X-Robots-Tag', ROBOTS_TAG).send((0, _signinPage.renderSigninPage)({
|
|
115
|
+
passwordLoginEnabled,
|
|
116
|
+
from,
|
|
117
|
+
error
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
router.get('/auth/google', (req, res) => {
|
|
121
|
+
const from = (0, _redirect.sanitizeRedirectPath)(req.query.from, redirectDefault);
|
|
122
|
+
const state = (0, _stateCookie.createAuthState)(from);
|
|
123
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
124
|
+
res.setHeader('X-Robots-Tag', ROBOTS_TAG);
|
|
125
|
+
res.setHeader('Set-Cookie', (0, _cookie.serialize)(_stateCookie.STATE_COOKIE_NAME, (0, _stateCookie.sealAuthState)(state, options.stateSecret), {
|
|
126
|
+
httpOnly: true,
|
|
127
|
+
secure: secureCookie,
|
|
128
|
+
sameSite: 'lax',
|
|
129
|
+
path: callbackPath,
|
|
130
|
+
maxAge: _stateCookie.STATE_TTL_SECONDS
|
|
131
|
+
}));
|
|
132
|
+
res.redirect(302, google.buildAuthUrl({
|
|
133
|
+
state: state.state,
|
|
134
|
+
nonce: state.nonce,
|
|
135
|
+
hdHint: allowedDomains.length === 1 ? allowedDomains[0] : undefined
|
|
136
|
+
}));
|
|
137
|
+
});
|
|
138
|
+
router.get(callbackPath, async (req, res) => {
|
|
139
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
140
|
+
res.setHeader('X-Robots-Tag', ROBOTS_TAG);
|
|
141
|
+
const clearState = (0, _cookie.serialize)(_stateCookie.STATE_COOKIE_NAME, '', {
|
|
142
|
+
httpOnly: true,
|
|
143
|
+
secure: secureCookie,
|
|
144
|
+
sameSite: 'lax',
|
|
145
|
+
path: callbackPath,
|
|
146
|
+
maxAge: 0
|
|
147
|
+
}); // A caller-supplied logger must never be able to block the redirect or
|
|
148
|
+
// the state-cookie clear that follow it.
|
|
149
|
+
|
|
150
|
+
const safeLog = event => {
|
|
151
|
+
try {
|
|
152
|
+
log(event);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
(0, _log.emitLogEntry)((0, _log.formatErrorEntry)(err, {
|
|
155
|
+
type: 'google-login',
|
|
156
|
+
stage: 'logger'
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const failWith = (reason, email) => {
|
|
162
|
+
safeLog(buildEvent(req, {
|
|
163
|
+
outcome: 'failure',
|
|
164
|
+
reason,
|
|
165
|
+
email
|
|
166
|
+
}));
|
|
167
|
+
res.setHeader('Set-Cookie', clearState);
|
|
168
|
+
res.redirect(302, `/signin?error=${reason}`);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const cookies = (0, _cookie.parse)(req.headers.cookie ?? '');
|
|
172
|
+
const state = (0, _stateCookie.unsealAuthState)(cookies[_stateCookie.STATE_COOKIE_NAME], options.stateSecret);
|
|
173
|
+
if (!state) return failWith('state', null); // Everything below can call out to Google, Keystone/Prisma, or a
|
|
174
|
+
// caller-supplied logger. Express 4 does not route a rejected async
|
|
175
|
+
// handler's promise to the error middleware, so any unexpected throw
|
|
176
|
+
// here must be caught and fail closed rather than hang the request or
|
|
177
|
+
// crash the process.
|
|
178
|
+
|
|
179
|
+
let email = null;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
if (typeof req.query.error === 'string') return failWith('token', null);
|
|
183
|
+
|
|
184
|
+
if (typeof req.query.state !== 'string' || req.query.state !== state.state) {
|
|
185
|
+
return failWith('state', null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (typeof req.query.code !== 'string' || req.query.code.length === 0) {
|
|
189
|
+
return failWith('token', null);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let identity;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
identity = await google.exchangeCode(req.query.code);
|
|
196
|
+
} catch {
|
|
197
|
+
return failWith('token', null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
email = identity.email;
|
|
201
|
+
if (!identity.email || identity.nonce !== state.nonce) return failWith('token', identity.email);
|
|
202
|
+
if (!identity.emailVerified) return failWith('unverified_email', identity.email);
|
|
203
|
+
|
|
204
|
+
if (!identity.hd || !allowedDomains.includes(identity.hd.toLowerCase())) {
|
|
205
|
+
return failWith('domain', identity.email);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = await (0, _session.signInByEmail)(options.keystoneContext, req, res, identity.email);
|
|
209
|
+
if (!result.ok) return failWith(result.reason, identity.email);
|
|
210
|
+
safeLog(buildEvent(req, {
|
|
211
|
+
outcome: 'success',
|
|
212
|
+
email: result.user.email ?? identity.email,
|
|
213
|
+
userId: result.user.id,
|
|
214
|
+
name: result.user.name,
|
|
215
|
+
role: result.user.role
|
|
216
|
+
})); // sessionStrategy.start() already set the session cookie; append the clear.
|
|
217
|
+
|
|
218
|
+
appendSetCookie(res, clearState);
|
|
219
|
+
res.redirect(302, (0, _redirect.sanitizeRedirectPath)(state.from, redirectDefault));
|
|
220
|
+
} catch (err) {
|
|
221
|
+
(0, _log.emitLogEntry)((0, _log.formatErrorEntry)(err, {
|
|
222
|
+
type: 'google-login',
|
|
223
|
+
stage: 'callback',
|
|
224
|
+
email
|
|
225
|
+
}));
|
|
226
|
+
return failWith('session', email);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return router;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function appendSetCookie(res, value) {
|
|
233
|
+
const existing = res.getHeader('Set-Cookie');
|
|
234
|
+
const list = Array.isArray(existing) ? existing.map(String) : existing ? [String(existing)] : [];
|
|
235
|
+
res.setHeader('Set-Cookie', [...list, value]);
|
|
236
|
+
}
|
|
237
|
+
/** Same precedence and IPv6-localhost normalisation as lilith-core. */
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
function clientIp(req) {
|
|
241
|
+
var _req$socket;
|
|
242
|
+
|
|
243
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
244
|
+
const realIp = req.headers['x-real-ip'];
|
|
245
|
+
const ip = (typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : undefined) || (typeof realIp === 'string' ? realIp.trim() : undefined) || ((_req$socket = req.socket) === null || _req$socket === void 0 ? void 0 : _req$socket.remoteAddress) || null;
|
|
246
|
+
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
|
|
247
|
+
return ip;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildEvent(req, fields) {
|
|
251
|
+
const userAgent = req.headers['user-agent'] ?? null;
|
|
252
|
+
return {
|
|
253
|
+
type: 'google-login',
|
|
254
|
+
timestamp: new Date().toISOString(),
|
|
255
|
+
userId: null,
|
|
256
|
+
name: null,
|
|
257
|
+
role: null,
|
|
258
|
+
ipAddress: clientIp(req),
|
|
259
|
+
userAgent,
|
|
260
|
+
...fields
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function defaultLogger(event) {
|
|
265
|
+
// Same field names as lilith-core's login-logging plugin so log queries match.
|
|
266
|
+
(0, _log.emitLogEntry)((0, _log.formatLogEntry)(event));
|
|
267
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.PASSWORD_MUTATION_PATTERN = void 0;
|
|
7
|
+
exports.createPasswordLoginGuard = createPasswordLoginGuard;
|
|
8
|
+
exports.requestUsesPasswordLogin = requestUsesPasswordLogin;
|
|
9
|
+
|
|
10
|
+
var _express = _interopRequireDefault(require("express"));
|
|
11
|
+
|
|
12
|
+
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
13
|
+
|
|
14
|
+
/** Field name of Keystone's password mutation for listKey 'User'. */
|
|
15
|
+
const PASSWORD_MUTATION_PATTERN = /\bauthenticateUserWithPassword\b/;
|
|
16
|
+
/**
|
|
17
|
+
* True when a GraphQL request body (single or batched) selects the password
|
|
18
|
+
* mutation. Aliases cannot hide it because the field name must still appear.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
exports.PASSWORD_MUTATION_PATTERN = PASSWORD_MUTATION_PATTERN;
|
|
22
|
+
|
|
23
|
+
function requestUsesPasswordLogin(body) {
|
|
24
|
+
const operations = Array.isArray(body) ? body : [body];
|
|
25
|
+
return operations.some(operation => {
|
|
26
|
+
if (!operation || typeof operation !== 'object') return false;
|
|
27
|
+
const query = operation.query;
|
|
28
|
+
return typeof query === 'string' && PASSWORD_MUTATION_PATTERN.test(query);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Express handlers that reject the password mutation with 403. Mount on the
|
|
33
|
+
* GraphQL path only when password login is disabled. The 500mb limit mirrors
|
|
34
|
+
* the host packages' own body parser so large DraftJS payloads are not
|
|
35
|
+
* rejected here with 413.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
function createPasswordLoginGuard() {
|
|
40
|
+
const parseJson = _express.default.json({
|
|
41
|
+
limit: '500mb'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const parseIfNeeded = (req, res, next) => {
|
|
45
|
+
if (req.method !== 'POST' || req.body !== undefined) return next();
|
|
46
|
+
return parseJson(req, res, next);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const guard = (req, res, next) => {
|
|
50
|
+
if (req.method === 'POST' && requestUsesPasswordLogin(req.body)) {
|
|
51
|
+
res.status(403).json({
|
|
52
|
+
errors: [{
|
|
53
|
+
message: 'Password login is disabled. Sign in with Google.',
|
|
54
|
+
extensions: {
|
|
55
|
+
code: 'PASSWORD_LOGIN_DISABLED'
|
|
56
|
+
}
|
|
57
|
+
}]
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
next();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return [parseIfNeeded, guard];
|
|
66
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.PASSWORD_MUTATION_FIELD = void 0;
|
|
7
|
+
exports.createPasswordLoginBlockPlugin = createPasswordLoginBlockPlugin;
|
|
8
|
+
exports.documentSelectsPasswordLogin = documentSelectsPasswordLogin;
|
|
9
|
+
|
|
10
|
+
var _graphql = require("graphql");
|
|
11
|
+
|
|
12
|
+
/** Field name of Keystone's password mutation for listKey 'User'. */
|
|
13
|
+
const PASSWORD_MUTATION_FIELD = 'authenticateUserWithPassword';
|
|
14
|
+
exports.PASSWORD_MUTATION_FIELD = PASSWORD_MUTATION_FIELD;
|
|
15
|
+
const BLOCKED_MESSAGE = 'Password login is disabled. Sign in with Google.';
|
|
16
|
+
/**
|
|
17
|
+
* True when any mutation operation in the document selects the password
|
|
18
|
+
* mutation at top level, directly or through a fragment spread. Working on the
|
|
19
|
+
* parsed document instead of the raw query string is what makes this immune to
|
|
20
|
+
* the multipart bypass: Keystone mounts `graphqlUploadExpress` after
|
|
21
|
+
* `extendExpressApp`, so an HTTP-layer guard never sees the operation carried
|
|
22
|
+
* in a multipart `operations` field.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function documentSelectsPasswordLogin(document) {
|
|
26
|
+
const fragments = new Map();
|
|
27
|
+
|
|
28
|
+
for (const definition of document.definitions) {
|
|
29
|
+
if (definition.kind === _graphql.Kind.FRAGMENT_DEFINITION) {
|
|
30
|
+
fragments.set(definition.name.value, definition);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const selectsPasswordLogin = (selections, visited) => selections.some(selection => {
|
|
35
|
+
if (selection.kind === _graphql.Kind.FIELD) {
|
|
36
|
+
return selection.name.value === PASSWORD_MUTATION_FIELD;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (selection.kind === _graphql.Kind.INLINE_FRAGMENT) {
|
|
40
|
+
return selectsPasswordLogin(selection.selectionSet.selections, visited);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const name = selection.name.value; // A self-referential fragment would otherwise recurse forever.
|
|
44
|
+
|
|
45
|
+
if (visited.has(name)) return false;
|
|
46
|
+
visited.add(name);
|
|
47
|
+
const fragment = fragments.get(name);
|
|
48
|
+
return fragment ? selectsPasswordLogin(fragment.selectionSet.selections, visited) : false;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return document.definitions.some(definition => definition.kind === _graphql.Kind.OPERATION_DEFINITION && definition.operation === 'mutation' && selectsPasswordLogin(definition.selectionSet.selections, new Set()));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Structural view of the slice of Apollo Server 4's plugin API this package
|
|
55
|
+
* uses. Typed by hand so the package does not depend on @apollo/server; the
|
|
56
|
+
* shape is assignable to `ApolloServerPlugin` where the host needs it.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apollo Server plugin that rejects the password mutation with 403. Add it to
|
|
62
|
+
* `config.graphql.apolloConfig.plugins` whenever the password kill switch is
|
|
63
|
+
* on; the mini-app's HTTP guard alone cannot see multipart requests.
|
|
64
|
+
*/
|
|
65
|
+
function createPasswordLoginBlockPlugin() {
|
|
66
|
+
return {
|
|
67
|
+
async requestDidStart() {
|
|
68
|
+
return {
|
|
69
|
+
async didResolveOperation(requestContext) {
|
|
70
|
+
if (documentSelectsPasswordLogin(requestContext.document)) {
|
|
71
|
+
throw new _graphql.GraphQLError(BLOCKED_MESSAGE, {
|
|
72
|
+
extensions: {
|
|
73
|
+
code: 'PASSWORD_LOGIN_DISABLED',
|
|
74
|
+
http: {
|
|
75
|
+
status: 403
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
};
|
|
86
|
+
}
|
package/lib/redirect.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.sanitizeRedirectPath = sanitizeRedirectPath;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Only same-origin relative paths are honoured as a post-login redirect.
|
|
10
|
+
* Anything else falls back so the callback can never become an open redirect.
|
|
11
|
+
*/
|
|
12
|
+
function sanitizeRedirectPath(from, fallback = '/') {
|
|
13
|
+
if (typeof from !== 'string' || from.length === 0) return fallback;
|
|
14
|
+
if (!from.startsWith('/')) return fallback;
|
|
15
|
+
if (from.startsWith('//') || from.startsWith('/\\')) return fallback; // Any C0 control character or DEL: header-splitting vectors beyond CR/LF,
|
|
16
|
+
// plus bytes a proxy or logger may re-interpret.
|
|
17
|
+
// eslint-disable-next-line no-control-regex
|
|
18
|
+
|
|
19
|
+
if (/[\u0000-\u001f\u007f]/.test(from)) return fallback;
|
|
20
|
+
return from;
|
|
21
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.signInByEmail = signInByEmail;
|
|
7
|
+
const SESSION_LIST_KEY = 'User';
|
|
8
|
+
/**
|
|
9
|
+
* Looks up the User by email and issues the same session cookie Keystone's
|
|
10
|
+
* password login would. No row is ever created here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
async function signInByEmail(keystoneContext, req, res, email) {
|
|
14
|
+
const row = await keystoneContext.sudo().query[SESSION_LIST_KEY].findOne({
|
|
15
|
+
where: {
|
|
16
|
+
email
|
|
17
|
+
},
|
|
18
|
+
query: 'id email name role'
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!row || row.id === undefined || row.id === null) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
reason: 'no_user'
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const user = {
|
|
29
|
+
id: String(row.id),
|
|
30
|
+
email: typeof row.email === 'string' ? row.email : null,
|
|
31
|
+
name: typeof row.name === 'string' ? row.name : null,
|
|
32
|
+
role: typeof row.role === 'string' ? row.role : null
|
|
33
|
+
}; // start() silently returns undefined without a response-bound context.
|
|
34
|
+
|
|
35
|
+
const context = await keystoneContext.withRequest(req, res);
|
|
36
|
+
if (!context.sessionStrategy) return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: 'session'
|
|
39
|
+
};
|
|
40
|
+
const token = await context.sessionStrategy.start({
|
|
41
|
+
data: {
|
|
42
|
+
listKey: SESSION_LIST_KEY,
|
|
43
|
+
itemId: user.id
|
|
44
|
+
},
|
|
45
|
+
context
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: 'session'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
user
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.ERROR_MESSAGES = void 0;
|
|
7
|
+
exports.renderSigninPage = renderSigninPage;
|
|
8
|
+
const ERROR_MESSAGES = {
|
|
9
|
+
state: '登入逾時或狀態不符,請重新登入。',
|
|
10
|
+
token: '無法驗證 Google 回傳的資料,請重新登入。',
|
|
11
|
+
domain: '此 Google 帳號不屬於允許的網域。',
|
|
12
|
+
unverified_email: 'Google 帳號的 email 尚未驗證。',
|
|
13
|
+
no_user: '此 email 尚未建立 CMS 帳號,請聯絡管理員。',
|
|
14
|
+
session: '建立登入工作階段失敗,請重試。'
|
|
15
|
+
};
|
|
16
|
+
exports.ERROR_MESSAGES = ERROR_MESSAGES;
|
|
17
|
+
const GENERIC_ERROR = '登入失敗,請重新登入。';
|
|
18
|
+
|
|
19
|
+
function renderSigninPage(options) {
|
|
20
|
+
const fromQuery = options.from ? `from=${encodeURIComponent(options.from)}` : '';
|
|
21
|
+
const googleHref = fromQuery ? `/auth/google?${fromQuery}` : '/auth/google';
|
|
22
|
+
const passwordHref = fromQuery ? `/signin?password=1&${fromQuery}` : '/signin?password=1';
|
|
23
|
+
const message = options.error ? ERROR_MESSAGES[options.error] ?? GENERIC_ERROR : '';
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html lang="zh-Hant">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
+
<meta name="robots" content="noindex, nofollow">
|
|
30
|
+
<title>登入</title>
|
|
31
|
+
<style>
|
|
32
|
+
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans TC", sans-serif; background: #f4f5f7; color: #172b4d; }
|
|
33
|
+
main { max-width: 360px; margin: 12vh auto; padding: 32px; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(9, 30, 66, 0.15); text-align: center; }
|
|
34
|
+
h1 { font-size: 20px; margin: 0 0 24px; }
|
|
35
|
+
.btn { display: block; padding: 12px 16px; border-radius: 6px; background: #1a73e8; color: #fff; text-decoration: none; font-weight: 600; }
|
|
36
|
+
.btn:hover { background: #1765cc; }
|
|
37
|
+
.alt { display: inline-block; margin-top: 20px; color: #5e6c84; font-size: 14px; }
|
|
38
|
+
.error { margin: 0 0 20px; padding: 10px 12px; border-radius: 6px; background: #ffebe6; color: #bf2600; font-size: 14px; }
|
|
39
|
+
</style>
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
<main>
|
|
43
|
+
<h1>登入 CMS</h1>
|
|
44
|
+
${message ? `<p class="error">${escapeHtml(message)}</p>` : ''}
|
|
45
|
+
<a class="btn" href="${escapeHtml(googleHref)}">使用 Google 帳號登入</a>
|
|
46
|
+
${options.passwordLoginEnabled ? `<a class="alt" href="${escapeHtml(passwordHref)}">使用密碼登入</a>` : ''}
|
|
47
|
+
</main>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeHtml(value) {
|
|
54
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
55
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.STATE_TTL_SECONDS = exports.STATE_COOKIE_NAME = void 0;
|
|
7
|
+
exports.createAuthState = createAuthState;
|
|
8
|
+
exports.sealAuthState = sealAuthState;
|
|
9
|
+
exports.unsealAuthState = unsealAuthState;
|
|
10
|
+
|
|
11
|
+
var _nodeCrypto = require("node:crypto");
|
|
12
|
+
|
|
13
|
+
const STATE_COOKIE_NAME = 'lilith-google-auth-state';
|
|
14
|
+
exports.STATE_COOKIE_NAME = STATE_COOKIE_NAME;
|
|
15
|
+
const STATE_TTL_SECONDS = 300;
|
|
16
|
+
exports.STATE_TTL_SECONDS = STATE_TTL_SECONDS;
|
|
17
|
+
|
|
18
|
+
function createAuthState(from, now = Date.now()) {
|
|
19
|
+
return {
|
|
20
|
+
state: (0, _nodeCrypto.randomBytes)(32).toString('base64url'),
|
|
21
|
+
nonce: (0, _nodeCrypto.randomBytes)(32).toString('base64url'),
|
|
22
|
+
from,
|
|
23
|
+
exp: now + STATE_TTL_SECONDS * 1000
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sealAuthState(state, secret) {
|
|
28
|
+
const payload = Buffer.from(JSON.stringify(state), 'utf8').toString('base64url');
|
|
29
|
+
return `${payload}.${sign(payload, secret)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unsealAuthState(sealed, secret, now = Date.now()) {
|
|
33
|
+
if (!sealed) return undefined;
|
|
34
|
+
const dot = sealed.lastIndexOf('.');
|
|
35
|
+
if (dot <= 0) return undefined;
|
|
36
|
+
const payload = sealed.slice(0, dot);
|
|
37
|
+
const signature = sealed.slice(dot + 1);
|
|
38
|
+
const expected = sign(payload, secret);
|
|
39
|
+
const a = Buffer.from(signature, 'utf8');
|
|
40
|
+
const b = Buffer.from(expected, 'utf8');
|
|
41
|
+
if (a.length !== b.length || !(0, _nodeCrypto.timingSafeEqual)(a, b)) return undefined;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
45
|
+
|
|
46
|
+
if (typeof parsed.state !== 'string' || typeof parsed.nonce !== 'string' || typeof parsed.from !== 'string' || typeof parsed.exp !== 'number') {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parsed.exp <= now) return undefined;
|
|
51
|
+
return {
|
|
52
|
+
state: parsed.state,
|
|
53
|
+
nonce: parsed.nonce,
|
|
54
|
+
from: parsed.from,
|
|
55
|
+
exp: parsed.exp
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sign(payload, secret) {
|
|
63
|
+
return (0, _nodeCrypto.createHmac)('sha256', secret).update(payload).digest('base64url');
|
|
64
|
+
}
|