diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index 0eac158..abf4427 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -11,6 +11,7 @@ import ( "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -53,6 +54,7 @@ func main() { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { log.Fatal(err) } + platform.Register(mux, db, callbacks) mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e85fd71..a1ef605 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -153,7 +153,7 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } query := url.Values{ "client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"}, - "scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)}, + "scope": {"openid email profile"}, "state": {state}, "code_challenge": {digest(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"}, } http.SetCookie(w, oauthCookie(state, browser, 600)) @@ -240,6 +240,8 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { Subject string `json:"sub"` Email *string `json:"email"` Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` } if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil { return err @@ -253,14 +255,29 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { 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 + WITH identity AS ( + SELECT gen_random_uuid() AS id + ), account AS ( + INSERT INTO project_accounts (project, id, google_subject, email, username, display_name, avatar_url) + SELECT $1, id, $2, $3, + left(COALESCE(NULLIF(regexp_replace(lower($6), '[^a-z0-9]', '', 'g'), ''), 'user'),7) + || '_' || replace(id::text,'-',''), + $6, NULLIF($7,'') FROM identity + ON CONFLICT (project, google_subject) DO UPDATE SET + email = EXCLUDED.email, + username = COALESCE(project_accounts.username, EXCLUDED.username), + display_name = CASE WHEN project_accounts.display_name='' THEN EXCLUDED.display_name ELSE project_accounts.display_name END, + avatar_url = COALESCE(project_accounts.avatar_url, EXCLUDED.avatar_url), + updated_at = now() RETURNING id + ), provider AS ( + INSERT INTO account_identities(project,provider,subject,account_id) + SELECT $1,'google',$2,id FROM account + ON CONFLICT (project,provider,subject) DO NOTHING ) INSERT INTO login_grants SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge) + `, project, user.Subject, user.Email, digest(grant), challenge, user.Name, user.Picture) if err != nil { return err } diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..da62d70 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +var ErrInvalidSession = errors.New("invalid session") + +// Authenticate resolves only an unexpired session belonging to this project. +func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { + value, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || !tokenPattern.MatchString(value) { + return "", ErrInvalidSession + } + var id string + err := db.QueryRowContext(ctx, ` +SELECT account_id::text FROM project_sessions +WHERE project=$1 AND token_hash=$2 AND expires_at>now() +`, project, digest(value)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrInvalidSession + } + return id, err +} diff --git a/internal/platform/http.go b/internal/platform/http.go new file mode 100644 index 0000000..3269da6 --- /dev/null +++ b/internal/platform/http.go @@ -0,0 +1,142 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "regexp" + "strings" + + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "github.com/jackc/pgx/v5/pgconn" +) + +type service struct { + db *sql.DB +} + +type response map[string]any + +func reply(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + if value != nil { + _ = json.NewEncoder(w).Encode(value) + } +} + +func reject(w http.ResponseWriter, status int, code string) { + reply(w, status, response{"error": code}) +} + +func decode(w http.ResponseWriter, r *http.Request, value any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + reject(w, 400, "invalid_request") + return false + } + if decoder.Decode(new(any)) != io.EOF { + reject(w, 400, "invalid_request") + return false + } + return true +} + +type endpoint func(http.ResponseWriter, *http.Request, string, string) error + +func (s *service) protected(callbacks map[string]string, handle endpoint) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + project := r.PathValue("project") + callback, exists := callbacks[project] + if !exists { + reject(w, 404, "not_found") + return + } + frontend, _ := url.Parse(callback) + w.Header().Set("Vary", "Origin") + if origin := r.Header.Get("Origin"); origin != "" { + if origin != frontend.Scheme+"://"+frontend.Host { + reject(w, 403, "origin_denied") + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + } + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match") + reply(w, 204, nil) + return + } + account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization")) + if errors.Is(err, auth.ErrInvalidSession) { + reject(w, 401, "invalid_session") + return + } + if err == nil { + err = handle(w, r, project, account) + } + if err != nil { + var pg *pgconn.PgError + switch { + case errors.Is(err, sql.ErrNoRows): + reject(w, 404, "not_found") + case errors.As(err, &pg) && pg.Code == "23505": + reject(w, 409, "already_exists") + default: + log.Printf("platform request failed (%T)", err) + reject(w, 503, "service_unavailable") + } + } + } +} + +func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) { + s := &service{db: db} + mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile)) +} + +var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,39}$`) + +func (s *service) profile(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodPatch { + var input struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + } + if !decode(w, r, &input) { + return nil + } + input.Username = strings.ToLower(strings.TrimSpace(input.Username)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + if !usernamePattern.MatchString(input.Username) || len(input.DisplayName) > 160 { + reject(w, 400, "invalid_profile") + return nil + } + _, err := s.db.ExecContext(r.Context(), ` +UPDATE project_accounts SET username=$3, display_name=$4, updated_at=now() +WHERE project=$1 AND id=$2 +`, project, account, input.Username, input.DisplayName) + if err != nil { + return err + } + } else if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + var username, email, avatar *string + var name string + err := s.db.QueryRowContext(r.Context(), ` +SELECT username, display_name, email, avatar_url FROM project_accounts WHERE project=$1 AND id=$2 +`, project, account).Scan(&username, &name, &email, &avatar) + if err != nil { + return err + } + reply(w, 200, response{"id": account, "project": project, "username": username, "display_name": name, "email": email, "avatar_url": avatar}) + return nil +}