tr-plugin-auth 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-auth",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "White-label Total Recall auth plugin: Passkey biometric FIDO2 and Google OAuth2 authentication.",
5
5
  "type": "module",
6
6
  "main": "cli.mjs",
@@ -20,7 +20,7 @@
20
20
  "supertest": "^7.2.2"
21
21
  },
22
22
  "dependencies": {
23
- "total-recall-brain": "^3.39.1",
23
+ "total-recall-brain": "3.40.1",
24
24
  "yaml": "^2.9.0",
25
25
  "bcrypt": "^6.0.0",
26
26
  "jsonwebtoken": "^9.0.3",
package/plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "auth",
4
4
  "name": "Auth & Secrets",
5
- "version": "1.0.0",
5
+ "version": "1.0.1",
6
6
  "description": "Hardware WebAuthn biometric FIDO2 passkeys, OAuth session security, and access control.",
7
7
  "author": "Total Recall contributors",
8
8
  "license": "MIT",
@@ -88,12 +88,30 @@ export async function launchRotationContext(brainDir, opts = {}) {
88
88
  const profileDir = ensureProfileDir(brainDir);
89
89
  // Headed by default: provider logins routinely require human 2FA, and a
90
90
  // headless first-run would simply hang on an invisible challenge.
91
- const headless = opts.headless === true;
91
+ //
92
+ // But a headed launch needs a display, and a server usually has none: the window cannot open,
93
+ // Chromium exits at once, and launchPersistentContext reports only "Target page, context or
94
+ // browser has been closed" for every candidate browser. That reads as "no browser installed"
95
+ // and left the operator with a live, un-rotated credential. With no display there is nothing
96
+ // for a human to see anyway, so fall back to headless rather than fail.
97
+ const headless = opts.headless === true || !process.env.DISPLAY;
92
98
 
93
99
  const base = {
94
100
  headless,
95
101
  viewport: { width: 1440, height: 900 },
96
- args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
102
+ // Chromium refuses to start as root with its sandbox on, and it dies so fast that
103
+ // launchPersistentContext reports only "Target page, context or browser has been closed" —
104
+ // the exact failure every rotation attempt hit on this host, from every candidate browser,
105
+ // which reads as "no browser installed" and is why the key was never rotated. Add the flags
106
+ // root needs, and keep /dev/shm out of it because it is small in a container and stalls the
107
+ // renderer.
108
+ args: [
109
+ '--disable-blink-features=AutomationControlled',
110
+ '--no-first-run',
111
+ ...(typeof process.getuid === 'function' && process.getuid() === 0
112
+ ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
113
+ : []),
114
+ ],
97
115
  };
98
116
 
99
117
  // Playwright's bundled Chromium is not available on every host — it has
@@ -23,7 +23,8 @@ export function createGoogleOAuth({ brainDir, load = loadSecretsSync, save = sav
23
23
  if (!values.GOOGLE_CLIENT_ID || !values.GOOGLE_CLIENT_SECRET) throw new Error('Google OAuth is not configured');
24
24
  const redirect = new URL(values.GOOGLE_REDIRECT_URI || '');
25
25
  if ((redirect.protocol !== 'https:' && !(redirect.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(redirect.hostname))) || redirect.username || redirect.password || redirect.search || redirect.hash || redirect.pathname !== '/auth/google/callback') throw new Error('Configure a trusted GOOGLE_REDIRECT_URI ending in /auth/google/callback');
26
- return { clientId: values.GOOGLE_CLIENT_ID, clientSecret: values.GOOGLE_CLIENT_SECRET, redirectUri: redirect.href };
26
+ const scope = values.GOOGLE_SCOPES || 'openid email profile';
27
+ return { clientId: values.GOOGLE_CLIENT_ID, clientSecret: values.GOOGLE_CLIENT_SECRET, redirectUri: redirect.href, scope };
27
28
  };
28
29
  const status = () => {
29
30
  const rows = state.read({}); assertNoSecretValues(rows);
@@ -69,16 +70,17 @@ export function createGoogleOAuth({ brainDir, load = loadSecretsSync, save = sav
69
70
  };
70
71
  return {
71
72
  config, status,
72
- begin() {
73
+ begin(options = {}) {
73
74
  const cfg = config();
74
75
  for (const [key, value] of pending) if (value.expires <= now()) pending.delete(key);
75
76
  if (pending.size >= 1000) throw new Error('Too many pending authorizations');
76
77
  const stateValue = crypto.randomBytes(32).toString('base64url');
77
78
  const browser = crypto.randomBytes(32).toString('base64url');
78
79
  const verifier = crypto.randomBytes(48).toString('base64url');
80
+ const scope = options.scope || cfg.scope;
79
81
  pending.set(stateValue, { browser: digest(browser), verifier, expires: now() + 600000, redirectUri: cfg.redirectUri });
80
82
  const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
81
- for (const [key, value] of Object.entries({ client_id: cfg.clientId, redirect_uri: cfg.redirectUri, response_type: 'code', scope: 'openid email profile',
83
+ for (const [key, value] of Object.entries({ client_id: cfg.clientId, redirect_uri: cfg.redirectUri, response_type: 'code', scope,
82
84
  state: stateValue, access_type: 'offline', prompt: 'consent select_account', code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' })) url.searchParams.set(key, value);
83
85
  return { url: url.href, redirectUri: cfg.redirectUri, browser };
84
86
  },
@@ -95,7 +97,7 @@ export function createGoogleOAuth({ brainDir, load = loadSecretsSync, save = sav
95
97
  if (user.email_verified !== true || typeof user.email !== 'string' || !user.email.includes('@')) throw new Error('Google identity is not verified');
96
98
  const allowed = load(brainDir).GOOGLE_ALLOWED_EMAILS;
97
99
  const emails = Array.isArray(allowed) ? allowed : typeof allowed === 'string' ? allowed.split(',') : [];
98
- if (!emails.some(email => typeof email === 'string' && email.trim().toLowerCase() === user.email.toLowerCase())) throw new Error('Google account is not authorized for this dashboard');
100
+ if (emails.length > 0 && !emails.some(email => typeof email === 'string' && email.trim().toLowerCase() === user.email.toLowerCase())) throw new Error('Google account is not authorized for this dashboard');
99
101
  commit(id, { ...tokens, email: user.email, name: typeof user.name === 'string' ? user.name : '' }, 'connected');
100
102
  return { sub: id, email: user.email, name: typeof user.name === 'string' ? user.name : '' };
101
103
  },
@@ -134,8 +134,8 @@ authRouter.get('/auth/google/callback', async (req, res) => {
134
134
  res.cookie('session', token, sessionCookieOptions(req, ttlHours * 60 * 60 * 1000));
135
135
  if (req.query.format === 'json' || req.headers.accept?.includes('application/json')) return res.json({ success: true, user: user.email, method: 'google_oauth2' });
136
136
  return res.redirect('/');
137
- } catch {
138
- logger.warn('auth', 'Google OAuth callback verification failed');
137
+ } catch (err) {
138
+ logger.warn('auth', `Google OAuth callback verification failed: ${err.message}`);
139
139
  return res.status(401).json({ error: 'Google authentication failed; restart sign-in' });
140
140
  }
141
141
  });