2026-10-01 23:15:57 +02:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/subtle"
|
|
|
|
|
"html"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/danyel/go-loose/internal/password"
|
|
|
|
|
"github.com/danyel/go-loose/internal/secretbox"
|
|
|
|
|
"github.com/danyel/go-loose/internal/store"
|
|
|
|
|
"github.com/danyel/go-loose/web"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.installed.Load() {
|
|
|
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
content, err := web.Files.ReadFile("install.html")
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "installer unavailable", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-02 18:56:52 +02:00
|
|
|
csrfToken, err := s.installCSRFToken(w, r)
|
2026-10-01 23:15:57 +02:00
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "could not prepare installer", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
page := strings.NewReplacer(
|
|
|
|
|
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
|
|
|
|
|
"{{CALLBACK_URL}}", html.EscapeString(s.cfg.OIDCRedirectURL),
|
|
|
|
|
).Replace(string(content))
|
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
w.Write([]byte(page))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if s.installed.Load() {
|
|
|
|
|
writeError(w, http.StatusConflict, "Go Loose is already installed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid installer request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
csrfCookie, cookieErr := r.Cookie("go_loose_install_csrf")
|
|
|
|
|
csrfValue := r.Form.Get("csrf_token")
|
|
|
|
|
payload, valid := s.sessions.VerifySigned(csrfValue)
|
|
|
|
|
if cookieErr != nil || subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfValue)) != 1 ||
|
|
|
|
|
!valid || !strings.HasPrefix(payload, "install|") {
|
|
|
|
|
writeError(w, http.StatusForbidden, "invalid installer form; reload the installer")
|
|
|
|
|
return
|
|
|
|
|
}
|
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
|
|
|
issuer := strings.TrimSpace(r.Form.Get("field1"))
|
|
|
|
|
clientID := strings.TrimSpace(r.Form.Get("field2"))
|
|
|
|
|
clientSecret := strings.TrimSpace(r.Form.Get("field3"))
|
|
|
|
|
redirectURL := strings.TrimSpace(r.Form.Get("field4"))
|
|
|
|
|
if issuer == "" || clientID == "" || clientSecret == "" || redirectURL == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "All four fields are required")
|
2026-10-01 23:15:57 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
seedDemo := r.Form.Get("seed_demo") == "on"
|
|
|
|
|
var demoUsers []store.DemoUser
|
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
|
|
|
var err error
|
2026-10-01 23:15:57 +02:00
|
|
|
if seedDemo {
|
|
|
|
|
demoUsers, err = buildDemoUsers()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internalError(w, "hash demo credentials", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
|
|
|
} else {
|
|
|
|
|
demoUsers = nil
|
|
|
|
|
}
|
|
|
|
|
ciphertext, encErr := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
|
|
|
|
|
if encErr != nil {
|
|
|
|
|
s.internalError(w, "encrypt OIDC secret", encErr)
|
|
|
|
|
return
|
2026-10-01 23:15:57 +02:00
|
|
|
}
|
|
|
|
|
err = s.store.Install(r.Context(), store.Installation{
|
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
|
|
|
OIDCIssuer: issuer, OIDCClientID: clientID,
|
|
|
|
|
OIDCSecretCiphertext: ciphertext, OIDCRedirectURL: redirectURL, SeededDemo: seedDemo,
|
2026-10-01 23:15:57 +02:00
|
|
|
}, demoUsers)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internalError(w, "complete installation", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
feat: account menu, database roles, and single logout
Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.
The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.
Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.
Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.
Two bugs found while verifying in a browser:
- A profile picture never rendered. avatarURL built an absolute URL
from the configured base URL, and the page's own img-src 'self'
blocked it. The console's own pages now take a root-relative path;
only /connect/userinfo, read from another origin, needs an absolute
one, and it follows the request.
- absoluteURL preferred the configuration over the request while its
comment claimed the opposite, which also misdirected the logout
redirect.
Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
2026-10-05 19:18:07 +02:00
|
|
|
// Configure OIDC in the running server
|
|
|
|
|
if err := s.configureOIDC(r.Context(), issuer, clientID, clientSecret, redirectURL); err != nil {
|
|
|
|
|
s.internalError(w, "configure OIDC", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-01 23:15:57 +02:00
|
|
|
s.installed.Store(true)
|
|
|
|
|
if err := s.ensureLocalAdministrator(r.Context()); err != nil {
|
|
|
|
|
s.internalError(w, "bootstrap local administrator", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, "/auth/start", http.StatusSeeOther)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildDemoUsers() ([]store.DemoUser, error) {
|
|
|
|
|
specs := []struct {
|
|
|
|
|
email, name, password, tenant string
|
|
|
|
|
}{
|
2026-10-02 21:45:42 +02:00
|
|
|
{"interview@nmbs.auth.dev", "NMBS Interview", "admin123", "nmbs"},
|
|
|
|
|
{"interview@ypto.auth.dev", "YPTO Interview", "admin123", "ypto"},
|
|
|
|
|
{"reviewer@nmbs.auth.dev", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"},
|
|
|
|
|
{"reviewer@ypto.auth.dev", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"},
|
2026-10-01 23:15:57 +02:00
|
|
|
}
|
|
|
|
|
result := make([]store.DemoUser, 0, len(specs))
|
|
|
|
|
for _, spec := range specs {
|
|
|
|
|
hash, err := password.HashDemo(spec.password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
result = append(result, store.DemoUser{
|
|
|
|
|
Email: spec.email, DisplayName: spec.name, PasswordHash: hash, TenantSlug: spec.tenant,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 18:56:52 +02:00
|
|
|
func (s *Server) installCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
|
|
|
|
if cookie, err := r.Cookie("go_loose_install_csrf"); err == nil {
|
|
|
|
|
payload, valid := s.sessions.VerifySigned(cookie.Value)
|
|
|
|
|
if valid && strings.HasPrefix(payload, "install|") {
|
|
|
|
|
s.setInstallCSRFCookie(w, cookie.Value)
|
|
|
|
|
return cookie.Value, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 23:15:57 +02:00
|
|
|
state, err := s.sessions.NewState()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
token := s.sessions.SignedState("install|" + state)
|
2026-10-02 18:56:52 +02:00
|
|
|
s.setInstallCSRFCookie(w, token)
|
|
|
|
|
return token, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) setInstallCSRFCookie(w http.ResponseWriter, token string) {
|
2026-10-01 23:15:57 +02:00
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
|
|
|
Name: "go_loose_install_csrf", Value: token, Path: "/install",
|
|
|
|
|
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
|
|
|
|
|
SameSite: http.SameSiteStrictMode, MaxAge: 900,
|
|
|
|
|
})
|
|
|
|
|
}
|