Bootstrap Foundry Core with project-scoped Google sign-in
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
PUBLIC_ORIGIN=https://api.example.com
|
||||
DATABASE_URL=postgresql://foundry:replace-me@postgres.internal:5432/foundry_auth
|
||||
GOOGLE_CLIENT_ID=replace-me.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=replace-me
|
||||
FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}'
|
||||
HOST=127.0.0.1
|
||||
PORT=8080
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
@@ -0,0 +1,24 @@
|
||||
# 造物工坊 · Foundry Core
|
||||
|
||||
共享后端的第一版提供 Google 登录。各项目通过同一个 HTTPS API 登录,在私有 PostgreSQL 的认证库中维护独立账号和会话。项目业务库可分别配置独立的数据库和角色。
|
||||
|
||||
## 服务端配置
|
||||
|
||||
1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https://<API 域名>/auth/google/callback`。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。
|
||||
2. 在服务器私网准备 PostgreSQL。在认证库执行一次 `db/001-auth.sql`(使用 `psql -v ON_ERROR_STOP=1`),给运行账号授予这四张表所需的查询及增删改权限。
|
||||
3. 按 `.env.example` 注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。
|
||||
4. 在已确认的远程 runner 上安装生产依赖。运行 Node.js 22 或以上版本,通过 HTTPS 反向代理转发至服务监听地址。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。
|
||||
5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。
|
||||
|
||||
## 前端接入
|
||||
|
||||
1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。
|
||||
2. 顶层导航到 `GET /projects/<project>/auth/google?challenge=<challenge>`。Google 回到共享 API 后,API 将浏览器重定向至已配置的项目回调。
|
||||
3. 回调页从 URL fragment 读取 `code` 或 `error`,用 history.replaceState 清掉 fragment。收到 code 时,向 `POST /projects/<project>/auth/exchange` 发送 `X-Foundry-Code` 和 `X-Foundry-Verifier` 请求头,随后清理暂存的 verifier。code 有效期一分钟,仅可使用一次。
|
||||
4. 将返回的八小时会话凭证保存在内存中,使用 `Authorization: Bearer <access_token>` 调用 `GET /projects/<project>/auth/me`。通过 `POST /projects/<project>/auth/logout` 撤销会话并清理本地凭证。刷新页面后,内存凭证需要通过重新登录获取。
|
||||
|
||||
同一个 Google 账号首次进入不同项目时,会分别创建用户 ID。会话绑定其签发项目。Google 账号的标识 `sub` 用于认领同一项目的既有账号,邮箱只作为资料字段。当前注册模式为开放注册。
|
||||
|
||||
## 验收
|
||||
|
||||
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。
|
||||
@@ -0,0 +1,42 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE project_accounts (
|
||||
project text NOT NULL,
|
||||
id uuid NOT NULL,
|
||||
google_subject text NOT NULL,
|
||||
email text,
|
||||
PRIMARY KEY (project, id),
|
||||
UNIQUE (project, google_subject)
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_attempts (
|
||||
state_hash text PRIMARY KEY,
|
||||
browser_hash text NOT NULL,
|
||||
project text NOT NULL,
|
||||
frontend_challenge text NOT NULL,
|
||||
google_verifier text NOT NULL,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE login_grants (
|
||||
token_hash text PRIMARY KEY,
|
||||
project text NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
frontend_challenge text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
FOREIGN KEY (project, account_id) REFERENCES project_accounts (project, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE project_sessions (
|
||||
token_hash text PRIMARY KEY,
|
||||
project text NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
FOREIGN KEY (project, account_id) REFERENCES project_accounts (project, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX oauth_attempts_expiry ON oauth_attempts (expires_at);
|
||||
CREATE INDEX login_grants_expiry ON login_grants (expires_at);
|
||||
CREATE INDEX project_sessions_expiry ON project_sessions (expires_at);
|
||||
|
||||
COMMIT;
|
||||
Generated
+164
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"name": "foundry-core",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "foundry-core",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pg": "8.16.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "foundry-core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": { "start": "node src/server.js" },
|
||||
"dependencies": { "pg": "8.16.3" }
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import pg from 'pg';
|
||||
|
||||
function required(name) {
|
||||
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
||||
return process.env[name];
|
||||
}
|
||||
|
||||
const publicOrigin = new URL(required('PUBLIC_ORIGIN'));
|
||||
if (publicOrigin.protocol !== 'https:' || publicOrigin.href !== publicOrigin.origin + '/') {
|
||||
throw new Error('PUBLIC_ORIGIN must be an HTTPS origin');
|
||||
}
|
||||
const redirectUri = new URL('/auth/google/callback', publicOrigin).href;
|
||||
const clientId = required('GOOGLE_CLIENT_ID');
|
||||
const clientSecret = required('GOOGLE_CLIENT_SECRET');
|
||||
const db = new pg.Pool({ connectionString: required('DATABASE_URL') });
|
||||
db.on('error', error => console.error('Database pool error', error.code));
|
||||
|
||||
const projects = new Map();
|
||||
for (const [name, value] of Object.entries(JSON.parse(required('FOUNDRY_PROJECTS')))) {
|
||||
const callback = new URL(value);
|
||||
const local = callback.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(callback.hostname);
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(name) || (!local && callback.protocol !== 'https:') ||
|
||||
callback.username || callback.password || callback.hash) {
|
||||
throw new Error(`Invalid project configuration: ${name}`);
|
||||
}
|
||||
projects.set(name, callback);
|
||||
}
|
||||
|
||||
const randomToken = () => randomBytes(32).toString('base64url');
|
||||
const digest = value => createHash('sha256').update(value).digest('base64url');
|
||||
const isToken = value => typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value);
|
||||
const cookieName = state => `__Host-foundry-oauth-${state}`;
|
||||
|
||||
function reply(res, status, payload) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(payload === undefined ? undefined : JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function redirect(res, destination) {
|
||||
res.writeHead(302, { Location: destination });
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function googleJson(url, options) {
|
||||
const response = await fetch(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(10000) });
|
||||
if (!response.ok) throw new Error('Google request failed');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function start(res, url, project) {
|
||||
const challenge = url.searchParams.get('challenge');
|
||||
if (!isToken(challenge)) return reply(res, 400, { error: 'invalid_challenge' });
|
||||
|
||||
const state = randomToken();
|
||||
const browser = randomToken();
|
||||
const verifier = randomToken();
|
||||
await db.query(`
|
||||
INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes')
|
||||
`, [digest(state), digest(browser), project, challenge, verifier]);
|
||||
|
||||
const google = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
google.search = new URLSearchParams({
|
||||
client_id: clientId, redirect_uri: redirectUri, response_type: 'code',
|
||||
scope: 'openid email', state, code_challenge: digest(verifier), code_challenge_method: 'S256',
|
||||
prompt: 'select_account',
|
||||
});
|
||||
res.setHeader('Set-Cookie', `${cookieName(state)}=${browser}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600`);
|
||||
redirect(res, google.href);
|
||||
}
|
||||
|
||||
async function callback(req, res, url) {
|
||||
const state = url.searchParams.get('state');
|
||||
if (!isToken(state)) return reply(res, 400, { error: 'invalid_state' });
|
||||
const browserCookie = cookieName(state);
|
||||
const browser = req.headers.cookie?.split(';').map(part => part.trim())
|
||||
.find(part => part.startsWith(browserCookie + '='))?.slice(browserCookie.length + 1);
|
||||
if (!isToken(browser)) return reply(res, 400, { error: 'invalid_state' });
|
||||
|
||||
const { rows: [attempt] } = await db.query(`
|
||||
DELETE FROM oauth_attempts
|
||||
WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now()
|
||||
RETURNING project, frontend_challenge, google_verifier
|
||||
`, [digest(state), digest(browser)]);
|
||||
if (!attempt || !projects.has(attempt.project)) return reply(res, 400, { error: 'invalid_state' });
|
||||
res.setHeader('Set-Cookie', `${browserCookie}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
|
||||
const destination = new URL(projects.get(attempt.project));
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code || url.searchParams.has('error')) {
|
||||
destination.hash = 'error=google_login_cancelled';
|
||||
return redirect(res, destination.href);
|
||||
}
|
||||
|
||||
const tokens = await googleJson('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code', code_verifier: attempt.google_verifier,
|
||||
}),
|
||||
});
|
||||
if (typeof tokens.access_token !== 'string') throw new Error('Missing Google access token');
|
||||
const user = await googleJson('https://openidconnect.googleapis.com/v1/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
if (typeof user.sub !== 'string' || !user.sub) throw new Error('Missing Google subject');
|
||||
|
||||
const grant = randomToken();
|
||||
await db.query(`
|
||||
WITH account AS (
|
||||
INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO login_grants
|
||||
SELECT $5, $1, id, $6, now() + interval '1 minute' FROM account
|
||||
`, [attempt.project, randomUUID(), user.sub, user.email_verified === true ? user.email : null,
|
||||
digest(grant), attempt.frontend_challenge]);
|
||||
|
||||
destination.hash = new URLSearchParams({ code: grant }).toString();
|
||||
redirect(res, destination.href);
|
||||
}
|
||||
|
||||
async function exchange(req, res, project) {
|
||||
const code = req.headers['x-foundry-code'];
|
||||
const verifier = req.headers['x-foundry-verifier'];
|
||||
if (!isToken(code) || !isToken(verifier)) return reply(res, 400, { error: 'invalid_exchange' });
|
||||
|
||||
const session = randomToken();
|
||||
const { rowCount } = await db.query(`
|
||||
WITH grant AS (
|
||||
DELETE FROM login_grants
|
||||
WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now()
|
||||
RETURNING account_id
|
||||
)
|
||||
INSERT INTO project_sessions
|
||||
SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant
|
||||
`, [digest(code), project, digest(verifier), digest(session)]);
|
||||
if (!rowCount) return reply(res, 401, { error: 'invalid_grant' });
|
||||
reply(res, 200, { access_token: session, token_type: 'Bearer', expires_in: 28800 });
|
||||
}
|
||||
|
||||
async function account(req, res, project, action) {
|
||||
const token = req.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1];
|
||||
if (!token) return reply(res, 401, { error: 'invalid_session' });
|
||||
|
||||
const params = [digest(token), project];
|
||||
if (action === 'logout') {
|
||||
await db.query('DELETE FROM project_sessions WHERE token_hash = $1 AND project = $2', params);
|
||||
return reply(res, 204);
|
||||
}
|
||||
const { rows: [user] } = await db.query(`
|
||||
SELECT a.id, a.email FROM project_sessions s
|
||||
JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id
|
||||
WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now()
|
||||
`, params);
|
||||
if (!user) return reply(res, 401, { error: 'invalid_session' });
|
||||
reply(res, 200, { project, ...user });
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
const url = new URL(req.url, publicOrigin);
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') return reply(res, 200, { status: 'ok' });
|
||||
if (req.method === 'GET' && url.pathname === '/readyz') {
|
||||
await db.query('SELECT 1 FROM project_accounts LIMIT 1');
|
||||
return reply(res, 200, { status: 'ready' });
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/auth/google/callback') return callback(req, res, url);
|
||||
|
||||
const [, project, action] = url.pathname.match(/^\/projects\/([a-z][a-z0-9-]*)\/auth\/(google|exchange|me|logout)$/) ?? [];
|
||||
const frontend = projects.get(project);
|
||||
if (!frontend) return reply(res, 404, { error: 'not_found' });
|
||||
if (action === 'google' && req.method === 'GET') return start(res, url, project);
|
||||
|
||||
const origin = req.headers.origin;
|
||||
res.setHeader('Vary', 'Origin');
|
||||
if (origin && origin !== frontend.origin) return reply(res, 403, { error: 'origin_denied' });
|
||||
if (origin) res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Authorization, X-Foundry-Code, X-Foundry-Verifier');
|
||||
return reply(res, 204);
|
||||
}
|
||||
if (action === 'exchange' && req.method === 'POST') return exchange(req, res, project);
|
||||
if (action === 'me' && req.method === 'GET') return account(req, res, project, action);
|
||||
if (action === 'logout' && req.method === 'POST') return account(req, res, project, action);
|
||||
reply(res, 405, { error: 'method_not_allowed' });
|
||||
}
|
||||
|
||||
createServer((req, res) => {
|
||||
handle(req, res).catch(error => {
|
||||
console.error('Request failed', error.code ?? error.name);
|
||||
if (res.headersSent) return res.destroy();
|
||||
reply(res, 503, { error: 'service_unavailable' });
|
||||
});
|
||||
}).listen(Number(process.env.PORT ?? 8080), process.env.HOST ?? '127.0.0.1');
|
||||
Reference in New Issue
Block a user