Add authenticated profiles and prefill Google account names
This commit is contained in:
@@ -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")),
|
||||
|
||||
+22
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user