diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..f278f2a --- /dev/null +++ b/.gitea/workflows/check.yml @@ -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 diff --git a/.gitignore b/.gitignore index 713d500..579c657 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -node_modules/ .env +/bin/ diff --git a/README.md b/README.md index 2a2a221..447473c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ## 服务端配置 1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https:///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()` 的记录。 ## 前端接入 diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go new file mode 100644 index 0000000..b354ce0 --- /dev/null +++ b/cmd/foundry/main.go @@ -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()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cdf6e3c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7600ae8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..e85fd71 --- /dev/null +++ b/internal/auth/auth.go @@ -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 +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e6c27fb..0000000 --- a/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 33983be..0000000 --- a/package.json +++ /dev/null @@ -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" } -} diff --git a/src/server.js b/src/server.js deleted file mode 100644 index 1ae7de4..0000000 --- a/src/server.js +++ /dev/null @@ -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');