Move Foundry Core to Go and add remote checks
Foundry Go checks / check (push) Has been cancelled

This commit is contained in:
2026-11-04 23:42:10 +08:00
parent e482f88600
commit 8cbd83f0e5
10 changed files with 463 additions and 375 deletions
+35
View File
@@ -0,0 +1,35 @@
name: Foundry Go checks
on: [push, pull_request]
jobs:
check:
runs-on: ces-control
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Check formatting, test and build
run: |
set -eu
test -z "$(gofmt -l cmd internal)"
go test -mod=readonly ./...
go build -mod=readonly -o bin/foundry ./cmd/foundry
- name: Smoke test HTTP routes
env:
PUBLIC_ORIGIN: https://api.example.com
DATABASE_URL: postgres://foundry:fixture@127.0.0.1:5432/foundry
GOOGLE_CLIENT_ID: fixture
GOOGLE_CLIENT_SECRET: fixture
FOUNDRY_PROJECTS: '{"notes":"http://localhost:3000/auth/callback"}'
PORT: '18080'
run: |
set -eu
./bin/foundry &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
curl --silent --show-error --fail --retry 5 --retry-connrefused http://127.0.0.1:18080/healthz
status=$(curl --silent --show-error -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/projects/notes/auth/me)
test "$status" = 401
+1 -1
View File
@@ -1,2 +1,2 @@
node_modules/
.env
/bin/
+2 -2
View File
@@ -5,9 +5,9 @@
## 服务端配置
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`),给运行账号授予这四张表所需的查询及增删改权限。
2. 在服务器私网准备 PostgreSQL 13 或以上版本。在认证库执行一次 `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` 检查认证库。
4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署时运行 `foundry` 二进制,通过 HTTPS 反向代理转发至服务监听地址。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。
5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。
## 前端接入
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"encoding/json"
"log"
"net"
"net/http"
"net/url"
"os"
"time"
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
)
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func main() {
origin, err := url.Parse(os.Getenv("PUBLIC_ORIGIN"))
if err != nil || origin.Scheme != "https" || origin.Hostname() == "" || origin.User != nil ||
(origin.Path != "" && origin.Path != "/") || origin.RawQuery != "" || origin.Fragment != "" {
log.Fatal("PUBLIC_ORIGIN must be an HTTPS origin")
}
var callbacks map[string]string
if err := json.Unmarshal([]byte(os.Getenv("FOUNDRY_PROJECTS")), &callbacks); err != nil || len(callbacks) == 0 {
log.Fatal("FOUNDRY_PROJECTS must configure at least one project")
}
id, secret, dsn := os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET"), os.Getenv("DATABASE_URL")
if id == "" || secret == "" || dsn == "" {
log.Fatal("Google credentials and DATABASE_URL are required")
}
config, err := pgx.ParseConfig(dsn)
if err != nil {
log.Fatal("Invalid DATABASE_URL")
}
db := stdlib.OpenDB(*config)
defer db.Close()
handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret)
if err != nil {
log.Fatal(err)
}
server := &http.Server{
Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")),
Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
+14
View File
@@ -0,0 +1,14 @@
module gitea.entroia.com/Zayden-Jung/foundry-core
go 1.25.0
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+329
View File
@@ -0,0 +1,329 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var projectName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
var tokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
type service struct {
db *sql.DB
projects map[string]*url.URL
redirectURI, clientID, secret string
google *http.Client
}
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) {
s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
for name, raw := range callbacks {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid project callback for %q", name)
}
host := u.Hostname()
local := u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1" || host == "::1")
if !projectName.MatchString(name) || host == "" || (u.Scheme != "https" && !local) || u.User != nil || u.Fragment != "" {
return nil, fmt.Errorf("invalid project callback for %q", name)
}
s.projects[name] = u
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { reply(w, 200, payload{"status": "ok"}) })
mux.HandleFunc("GET /readyz", s.wrap(func(w http.ResponseWriter, r *http.Request) error {
if _, err := s.db.ExecContext(r.Context(), "SELECT 1 FROM project_accounts LIMIT 1"); err != nil {
return err
}
reply(w, 200, payload{"status": "ready"})
return nil
}))
mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback))
mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
mux.ServeHTTP(w, r.WithContext(ctx))
}), nil
}
type payload map[string]any
func reply(w http.ResponseWriter, status int, body payload) {
if body != nil {
w.Header().Set("Content-Type", "application/json")
}
w.WriteHeader(status)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}
func reject(w http.ResponseWriter, status int, code string) error {
reply(w, status, payload{"error": code})
return nil
}
func (s *service) wrap(handle func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := handle(w, r); err != nil {
log.Printf("request failed (%T)", err)
_ = reject(w, 503, "service_unavailable")
}
}
}
func token() string {
var value [32]byte
rand.Read(value[:])
return base64.RawURLEncoding.EncodeToString(value[:])
}
func digest(value string) string {
sum := sha256.Sum256([]byte(value))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func (s *service) project(w http.ResponseWriter, r *http.Request) error {
project, action := r.PathValue("project"), r.PathValue("action")
frontend := s.projects[project]
if frontend == nil {
return reject(w, 404, "not_found")
}
if r.Method == http.MethodGet && action == "google" {
return s.start(w, r, project)
}
w.Header().Set("Vary", "Origin")
if origin := r.Header.Get("Origin"); origin != "" {
if origin != frontend.Scheme+"://"+frontend.Host {
return reject(w, 403, "origin_denied")
}
w.Header().Set("Access-Control-Allow-Origin", origin)
}
switch {
case r.Method == http.MethodOptions:
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier")
reply(w, 204, nil)
case r.Method == http.MethodPost && action == "exchange":
return s.exchange(w, r, project)
case r.Method == http.MethodGet && action == "me":
return s.account(w, r, project, false)
case r.Method == http.MethodPost && action == "logout":
return s.account(w, r, project, true)
default:
return reject(w, 405, "method_not_allowed")
}
return nil
}
func oauthCookie(state, value string, maxAge int) *http.Cookie {
return &http.Cookie{Name: "__Host-foundry-oauth-" + state, Value: value, Path: "/", MaxAge: maxAge,
Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}
}
func (s *service) start(w http.ResponseWriter, r *http.Request, project string) error {
challenge := r.URL.Query().Get("challenge")
if !tokenPattern.MatchString(challenge) {
return reject(w, 400, "invalid_challenge")
}
state, browser, verifier := token(), token(), token()
_, err := s.db.ExecContext(r.Context(), `
INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes')
`, digest(state), digest(browser), project, challenge, verifier)
if err != nil {
return err
}
query := url.Values{
"client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"},
"scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)},
"code_challenge_method": {"S256"}, "prompt": {"select_account"},
}
http.SetCookie(w, oauthCookie(state, browser, 600))
http.Redirect(w, r, "https://accounts.google.com/o/oauth2/v2/auth?"+query.Encode(), 302)
return nil
}
func (s *service) googleJSON(ctx context.Context, method, endpoint string, form url.Values, bearer string, result any) error {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
res, err := s.google.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return fmt.Errorf("Google returned HTTP %d", res.StatusCode)
}
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(result)
}
func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
query := r.URL.Query()
state := query.Get("state")
if !tokenPattern.MatchString(state) {
return reject(w, 400, "invalid_state")
}
browser, err := r.Cookie(oauthCookie(state, "", 0).Name)
if err != nil || !tokenPattern.MatchString(browser.Value) {
return reject(w, 400, "invalid_state")
}
var project, challenge, verifier string
err = s.db.QueryRowContext(ctx, `
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.Value)).Scan(&project, &challenge, &verifier)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 400, "invalid_state")
}
if err != nil {
return err
}
frontend := s.projects[project]
if frontend == nil {
return reject(w, 400, "invalid_state")
}
http.SetCookie(w, oauthCookie(state, "", -1))
destination := *frontend
code := query.Get("code")
if code == "" || query.Has("error") {
destination.Fragment = "error=google_login_cancelled"
http.Redirect(w, r, destination.String(), 302)
return nil
}
var tokens struct {
AccessToken string `json:"access_token"`
}
err = s.googleJSON(ctx, "POST", "https://oauth2.googleapis.com/token", url.Values{
"code": {code}, "client_id": {s.clientID}, "client_secret": {s.secret}, "redirect_uri": {s.redirectURI},
"grant_type": {"authorization_code"}, "code_verifier": {verifier},
}, "", &tokens)
if err != nil {
return err
}
if tokens.AccessToken == "" {
return errors.New("missing Google access token")
}
var user struct {
Subject string `json:"sub"`
Email *string `json:"email"`
Verified bool `json:"email_verified"`
}
if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil {
return err
}
if user.Subject == "" {
return errors.New("missing Google subject")
}
if !user.Verified {
user.Email = nil
}
grant := token()
_, err = s.db.ExecContext(ctx, `
WITH account AS (
INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, gen_random_uuid(), $2, $3)
ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email
RETURNING id
)
INSERT INTO login_grants
SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account
`, project, user.Subject, user.Email, digest(grant), challenge)
if err != nil {
return err
}
destination.Fragment = "code=" + grant
http.Redirect(w, r, destination.String(), 302)
return nil
}
func (s *service) exchange(w http.ResponseWriter, r *http.Request, project string) error {
code, verifier := r.Header.Get("X-Foundry-Code"), r.Header.Get("X-Foundry-Verifier")
if !tokenPattern.MatchString(code) || !tokenPattern.MatchString(verifier) {
return reject(w, 400, "invalid_exchange")
}
session := token()
result, err := s.db.ExecContext(r.Context(), `
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 err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 0 {
return reject(w, 401, "invalid_grant")
}
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
return nil
}
func (s *service) account(w http.ResponseWriter, r *http.Request, project string, logout bool) error {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || !tokenPattern.MatchString(token) {
return reject(w, 401, "invalid_session")
}
if logout {
_, err := s.db.ExecContext(r.Context(), "DELETE FROM project_sessions WHERE token_hash = $1 AND project = $2", digest(token), project)
if err != nil {
return err
}
reply(w, 204, nil)
return nil
}
var id string
var email *string
err := s.db.QueryRowContext(r.Context(), `
SELECT a.id::text, 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()
`, digest(token), project).Scan(&id, &email)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 401, "invalid_session")
}
if err != nil {
return err
}
reply(w, 200, payload{"project": project, "id": id, "email": email})
return nil
}
-164
View File
@@ -1,164 +0,0 @@
{
"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"
}
}
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"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
View File
@@ -1,199 +0,0 @@
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');