This commit is contained in:
@@ -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
@@ -1,2 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
/bin/
|
||||
|
||||
@@ -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()` 的记录。
|
||||
|
||||
## 前端接入
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
Generated
-164
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
Reference in New Issue
Block a user