package auth import ( "database/sql" "errors" "net/http" "strings" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/httputil" ) // UserStore provisions and reads accounts. Petal has no signup flow: a row // appears the first time someone Authentik vouches for signs in, and that is // the only way one is ever created. type UserStore struct { db *sql.DB } // NewUserStore returns a store backed by the given database. func NewUserStore(sqlDB *sql.DB) *UserStore { return &UserStore{db: sqlDB} } // Upsert records the account behind an OIDC login, keyed by the issuer's // subject id. // // The subject is the id — not the email, which people change and which // Authentik does not promise is stable. Email and display name are refreshed on // every login so a rename upstream shows up here; pair_lang is deliberately not // touched, because it is Petal's own setting rather than the IdP's. func (u *UserStore) Upsert(sub, email, displayName string) error { if sub == "" { return errors.New("oidc: empty subject") } if displayName == "" { displayName = email } _, err := u.db.Exec( `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET email = excluded.email, display_name = excluded.display_name`, sub, email, displayName, ) return err } // Get loads one account. func (u *UserStore) Get(id string) (db.User, error) { var user db.User err := u.db.QueryRow( `SELECT id, email, COALESCE(display_name, ''), created_at, pair_lang FROM users WHERE id = ?`, id, ).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.PairLang) return user, err } // MeHandler reports who the caller is. The frontend uses it to namespace // per-account browser state and to show the signed-in writer; it sits behind // the auth middleware, so reaching it at all already proves a valid session. func (u *UserStore) MeHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, err := u.Get(UserID(r.Context())) if err != nil { httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in") return } httputil.WriteJSON(w, http.StatusOK, user) } } // Allowlist decides which of Authentik's users may write in this Petal. // Authentik fronts several applications; being a valid user there does not mean // being a user here. // // An entry matches a subject id or an email address, case-insensitively. Both // are accepted on purpose: a subject is an opaque uuid nobody can know before // that person's first login, so a subject-only list means the operator must let // someone in, read a log line, and edit config — whereas an email is knowable in // advance. An empty list allows everyone the IdP authenticates, which is the // right default for a single-household instance. type Allowlist map[string]bool // ParseAllowlist builds an Allowlist from a comma-separated env value. func ParseAllowlist(raw string) Allowlist { list := Allowlist{} for _, part := range strings.Split(raw, ",") { if p := strings.ToLower(strings.TrimSpace(part)); p != "" { list[p] = true } } return list } // Permits reports whether this login may proceed. func (a Allowlist) Permits(sub, email string) bool { if len(a) == 0 { return true } return a[strings.ToLower(sub)] || (email != "" && a[strings.ToLower(email)]) }