@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/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
+ }
@@ -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
+ }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
+ }
package/lib/types.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });