diff --git a/backend/README.md b/backend/README.md index e89b0c3..5626d11 100644 --- a/backend/README.md +++ b/backend/README.md @@ -15,7 +15,7 @@ npm start --prefix backend The runtime uses Node.js 24+, Firebase Admin application-default credentials and the existing Google Cloud project. Production has no process-memory storage fallback. The test suite injects an isolated memory store and fake identity verifier, exercises the real HTTP/OAuth/MCP handlers and never contacts a third-party tool or sends email. -Production configuration: `PUBLIC_ORIGIN`, `GOOGLE_CLOUD_PROJECT`, `FIREBASE_API_KEY`, `ADMIN_EMAILS`, optional `PORT`. The Firebase web API key is a public project identifier, not a server credential. Do not commit local environment files, service-account keys, browser cookies, OAuth tokens or deployment receipts. Cloud Run uses its runtime service account instead of a downloaded key. +Production configuration: `PUBLIC_ORIGIN`, `GOOGLE_CLOUD_PROJECT`, `FIREBASE_API_KEY`, `ADMIN_EMAILS`, optional `PORT` and `SIGN_IN_PROVIDERS` (comma-separated `google.com` and/or `github.com` only after enabling the matching Firebase provider). The Firebase web API key is a public project identifier, not a server credential. Do not commit local environment files, service-account keys, browser cookies, OAuth tokens or deployment receipts. Cloud Run uses its runtime service account instead of a downloaded key. ## Accounts and profiles @@ -50,3 +50,5 @@ User-supplied strings are escaped in HTML and JSON-LD. Install text is displayed ## Deployment Use `python scripts/stage_cloudrun.py` to create a clean allowlisted context containing only runtime source, locked package manifests, layout and a fresh public build. Tests, local credentials, private receipts, plans and stale build files are excluded. The container runs as the unprivileged Node user. Preserve min instances 0, max instances 2 and request-based CPU billing when deploying. Firebase Hosting keeps custom-domain HTTPS and routes to Cloud Run. New public data is read from Firestore rather than requiring a source deployment for each approved listing. + +Social sign-in uses the official pinned Firebase browser SDK from Google's CDN, in-memory persistence and a popup. Only configured provider IDs appear in the public config; client secrets stay in Firebase. Provider ID tokens are exchanged for the existing secure server cookie, then the SDK signs out. No provider API access is requested beyond basic identity and, for GitHub, email. The safe return path preserves a pending MCP consent request. Provider collisions require the existing sign-in method and never silently merge profiles. diff --git a/backend/render.js b/backend/render.js index 8af7009..713462e 100644 --- a/backend/render.js +++ b/backend/render.js @@ -21,7 +21,7 @@ export function profileContent(p, tools) { export function accountContent(mode) { if (mode==='review') return `

Submission review

Only approved snapshots enter the public catalog. Review setup, source, licensing and any claimed evidence.

`; if (mode==='consent') return `

Connect an agent

Authorize ToolIndex

Only approve clients you recognize. Tools submitted by an agent still need review before publication. You can revoke access in your account.

`; - if (mode==='auth') return `

Your ToolIndex account

Sign in or create an account

Submit tools, manage your profile and connect an agent. Browsing is always public.

Your email address is private. New accounts verify their email before publishing a profile or requesting tool review.

`; + if (mode==='auth') return `

Your ToolIndex account

Sign in or create an account

Submit tools, manage your profile and connect an agent. Browsing is always public.

Your email address is private. New accounts verify their email before publishing a profile or requesting tool review.

`; if (mode==='email-action') return `

Account verification

Checking your link...

Return to sign in

`; return `

Creator workspace

Your ToolIndex

Customize your public profile and submit tools for review.

Your profile

Submit a tool

Save a draft, then request review. No GitHub issue or pull request needed.

Optional logo or custom image

Your submissions

Edits to published tools go through review again. The last approved version stays public until a reviewer approves an update or you withdraw it.

Connected agents

Connect your MCP client to https://toolindex.org/mcp. Your browser will open for account sign-in and permission.

Agent and API documentation

`; } diff --git a/backend/server.js b/backend/server.js index a70f638..b57dbaa 100644 --- a/backend/server.js +++ b/backend/server.js @@ -25,6 +25,7 @@ export async function createApp(options = {}) { const api = domain(store, baseCatalog, origin); const provider = oauthProvider(store, origin, uid => auth.getUser(uid)); const admins = new Set((options.admins || process.env.ADMIN_EMAILS || '').split(',').map(s=>s.trim().toLowerCase()).filter(Boolean)); + const signInProviders=(options.signInProviders || process.env.SIGN_IN_PROVIDERS || '').split(',').filter(p=>['google.com','github.com'].includes(p)); const allowedOrigins = new Set([origin, 'https://toolindex-org.web.app', 'https://toolindex-org.firebaseapp.com']); const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 1); app.use(rateLimit({windowMs:60000,limit:options.httpLimit || 600,standardHeaders:'draft-8',legacyHeaders:false,message:{error:'Request limit reached. Please retry shortly.'}})); @@ -32,7 +33,7 @@ export async function createApp(options = {}) { app.use((req, res, next) => { res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'strict-origin-when-cross-origin', 'Permissions-Policy':'camera=(), microphone=(), geolocation=()', - 'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"}); + 'Content-Security-Policy':"default-src 'self'; script-src 'self' https://www.gstatic.com/firebasejs/13.1.0/ https://apis.google.com; style-src 'self'; img-src 'self' https:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com; frame-src https://toolindex-org.firebaseapp.com https://apis.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"}); if (req.path.startsWith('/account') || req.path.startsWith('/authorize') || req.path.startsWith('/api/account') || req.path.startsWith('/api/me') || req.path.startsWith('/api/review')) res.set({'Cache-Control':'private, no-store','X-Robots-Tag':'noindex, nofollow'}); next(); }); @@ -59,7 +60,7 @@ export async function createApp(options = {}) { const html = (res, p, title, description, content, opts) => res.type('html').send(page(layout,origin,p,title,description,content,opts)); const json = (res, value) => res.set('Cache-Control','no-store').json(value); - app.get('/api/config', (req,res)=>res.json({apiKey:options.apiKey || process.env.FIREBASE_API_KEY || '', projectId:'toolindex-org', authDomain:'toolindex-org.firebaseapp.com', publicOrigin:origin})); + app.get('/api/config', (req,res)=>res.set('Cache-Control','no-store').json({apiKey:options.apiKey || process.env.FIREBASE_API_KEY || '', projectId:'toolindex-org', authDomain:'toolindex-org.firebaseapp.com', publicOrigin:origin,signInProviders})); app.post('/api/account/session', sameOrigin, async (req,res) => { const token=z.string().min(20).max(10000).parse(req.body.idToken); let decoded; diff --git a/backend/test/platform.test.js b/backend/test/platform.test.js index 3e79c32..260900d 100644 --- a/backend/test/platform.test.js +++ b/backend/test/platform.test.js @@ -140,3 +140,14 @@ test('HTTP: bounded request rates apply before account authorization or file acc const limited=await call('/api/me',{user:'none'});assert.equal(limited.status,429); assert.ok(limited.headers.get('ratelimit')); }); + +test('HTTP: only configured social providers are public and private auth returns stay off search engines',async t=>{ + const {call}=await httpFixture(t,{signInProviders:'google.com,unconfigured.example,github.com'}); + const config=await call('/api/config');assert.equal(config.headers.get('cache-control'),'no-store'); + const data=await config.json();assert.deepEqual(data.signInProviders,['google.com','github.com']);assert.ok(!data.clientSecret); + const page=await call('/account/sign-in/?next=%2Fauthorize%2Fconsent%2F%3Frequest%3Dexample'); + assert.equal(page.headers.get('cache-control'),'private, no-store'); + assert.equal(page.headers.get('x-robots-tag'),'noindex, nofollow'); + assert.ok((await page.text()).includes('id="social-sign-in"')); + assert.ok(page.headers.get('content-security-policy').includes('https://www.gstatic.com/firebasejs/13.1.0/')); +}); diff --git a/site/account.js b/site/account.js index 2519f5e..c84aa25 100644 --- a/site/account.js +++ b/site/account.js @@ -12,7 +12,28 @@ async function attempt(fn){try{await fn();}catch(e){if(e.status===401&&mode!=='auth'&&mode!=='email-action'){signIn();return;}say(e.message,true);}} if(mode==='auth'){ + let socialBusy=false; + api('/api/config').then(async config=>{ + if(!config.signInProviders?.length)return; + const social=await import('/social-auth.js'), connect=social.prepareSocialSignIn(config); + const box=document.getElementById('social-sign-in'); + for(const [providerId,label]of [['google.com','Google'],['github.com','GitHub']]){ + if(!config.signInProviders.includes(providerId))continue; + const button=el('button','Continue with '+label,'button');button.type='button'; + button.onclick=async()=>{ + if(socialBusy)return;socialBusy=true;say('Opening '+label+' sign-in...'); + try{ + await connect(providerId,async idToken=>{await api('/api/account/session','POST',{idToken});}); + location.assign(safeNext(new URLSearchParams(location.search).get('next'))); + }catch(error){say(error.status?error.message:social.socialError(error),true);} + finally{socialBusy=false;} + }; + box.append(button); + } + box.hidden=false; + }).catch(()=>say('Social sign-in could not load. You can still sign in with email.',true)); document.getElementById('auth-form').addEventListener('submit',e=>{e.preventDefault();attempt(async()=>{ + if(socialBusy)return; const form=e.currentTarget, data=fields(form), action=e.submitter?.value||'signin'; if(action==='reset'){if(!data.email||!form.elements.email.validity.valid)throw new Error('Enter your email address first.');await firebase('sendOobCode',{requestType:'PASSWORD_RESET',email:data.email});say('If an account uses this address, a password reset email will arrive.');return;} say(action==='signup'?'Creating your account...':'Signing in...'); diff --git a/site/social-auth.js b/site/social-auth.js new file mode 100644 index 0000000..1d3f737 --- /dev/null +++ b/site/social-auth.js @@ -0,0 +1,26 @@ +import { initializeApp } from 'https://www.gstatic.com/firebasejs/13.1.0/firebase-app.js'; +import { initializeAuth, inMemoryPersistence, browserPopupRedirectResolver, GoogleAuthProvider, GithubAuthProvider, signInWithPopup, signOut } from 'https://www.gstatic.com/firebasejs/13.1.0/firebase-auth.js'; + +// Provider tokens stay in memory. The server session remains the HttpOnly cookie. +export function prepareSocialSignIn(config) { + const auth = initializeAuth(initializeApp({apiKey:config.apiKey,authDomain:config.authDomain,projectId:config.projectId},'toolindex-social'), { + persistence:inMemoryPersistence,popupRedirectResolver:browserPopupRedirectResolver + }); + return async (providerId, consumeToken) => { + if (!config.signInProviders.includes(providerId)) throw new Error('This sign-in method is unavailable.'); + const provider = providerId==='google.com' ? new GoogleAuthProvider() : new GithubAuthProvider(); + if (providerId==='github.com') provider.addScope('user:email'); + const result = await signInWithPopup(auth,provider); + try { await consumeToken(await result.user.getIdToken()); } + finally { await signOut(auth); } + }; +} + +export function socialError(error) { + const code=error.code || ''; + if (code==='auth/popup-closed-by-user' || code==='auth/cancelled-popup-request') return 'Sign-in cancelled. You can try again.'; + if (code==='auth/popup-blocked') return 'Allow the sign-in popup, then choose your provider again.'; + if (code==='auth/account-exists-with-different-credential') return 'This email already uses another sign-in method. Use that method to keep your existing account.'; + if (code==='auth/unauthorized-domain' || code==='auth/operation-not-allowed' || code==='auth/invalid-oauth-client-id') return 'This sign-in provider is being configured. Please use email sign-in for now.'; + return 'Could not complete provider sign-in. Please try again or use email sign-in.'; +} diff --git a/site/style.css b/site/style.css index a5bbe95..e1682b6 100644 --- a/site/style.css +++ b/site/style.css @@ -355,3 +355,5 @@ button:disabled, input:disabled { cursor: default; } input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:3px solid #e88546; outline-offset:3px; } @media(max-width:800px) { .workspace-grid { grid-template-columns:1fr; gap:36px; } .site-header nav { flex-wrap:wrap; gap:12px; } .header { align-items:flex-start; } .account-top { align-items:flex-start; } } @media(max-width:440px) { .field-row { grid-template-columns:1fr; } .site-header .github { display:none; } } + +.social-sign-in:not([hidden]) { display: grid; gap: 10px; margin: 24px 0; }