2026-10-01 23:15:57 +02:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/danyel/go-loose/internal/key"
|
|
|
|
|
"github.com/danyel/go-loose/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-04 12:24:13 +02:00
|
|
|
// clientUserResponse is the identity payload handed to client applications. It
|
|
|
|
|
// carries the tenant role and its capabilities so that an application such as
|
|
|
|
|
// go-tell can gate features, plus an absolute avatar_url it can render directly.
|
|
|
|
|
type clientUserResponse struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
DisplayName string `json:"display_name"`
|
|
|
|
|
AvatarURL string `json:"avatar_url"`
|
|
|
|
|
TenantID string `json:"tenant_id"`
|
|
|
|
|
TenantSlug string `json:"tenant_slug"`
|
|
|
|
|
ApplicationID string `json:"application_id"`
|
|
|
|
|
Application string `json:"application"`
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
Permissions []string `json:"permissions"`
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
func (s *Server) clientUser(r *http.Request, user store.ClientUser) clientUserResponse {
|
2026-10-04 12:24:13 +02:00
|
|
|
return clientUserResponse{
|
|
|
|
|
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
|
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
|
|
|
AvatarURL: s.avatarURL(r, user.AvatarKey), TenantID: user.TenantID,
|
2026-10-04 12:24:13 +02:00
|
|
|
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
|
2026-10-04 16:57:30 +02:00
|
|
|
Role: user.Role, Permissions: user.Permissions,
|
2026-10-04 12:24:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 23:15:57 +02:00
|
|
|
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
query := r.URL.Query()
|
|
|
|
|
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
|
|
|
|
|
if query.Get("response_type") != "code" || clientID == "" || redirectURI == "" || state == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "response_type=code, client_id, redirect_uri, and state are required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
app, err := s.store.ValidateClientRedirect(r.Context(), clientID, redirectURI)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "unknown client or redirect URI")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.internalError(w, "validate client redirect", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !strings.EqualFold(r.Host, s.tenantAuthHost(app.TenantSlug)) {
|
|
|
|
|
http.Redirect(w, r, s.tenantAuthURL(app.TenantSlug, r.URL.RequestURI()), http.StatusTemporaryRedirect)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
claims, err := s.sessions.Get(r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
returnURL := s.tenantAuthURL(app.TenantSlug, r.URL.RequestURI())
|
|
|
|
|
loginQuery := url.Values{"client_id": {clientID}, "return": {returnURL}}
|
|
|
|
|
http.Redirect(w, r, "/client/login?"+loginQuery.Encode(), http.StatusSeeOther)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
app, err = s.store.ClientForAuthorization(r.Context(), clientID, redirectURI, claims.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeError(w, http.StatusForbidden, "your account does not have access to this application")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.internalError(w, "authorize client user", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
code, _, codeHash, err := key.GenerateToken("glc_code_")
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internalError(w, "generate authorization code", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.CreateAuthorizationCode(r.Context(), app.ID, claims.UserID, redirectURI, codeHash); err != nil {
|
|
|
|
|
s.internalError(w, "store authorization code", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
target, _ := url.Parse(redirectURI)
|
|
|
|
|
values := target.Query()
|
|
|
|
|
values.Set("code", code)
|
|
|
|
|
values.Set("state", state)
|
|
|
|
|
target.RawQuery = values.Encode()
|
|
|
|
|
http.Redirect(w, r, target.String(), http.StatusFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid token request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
clientID, clientSecret, ok := r.BasicAuth()
|
|
|
|
|
if !ok {
|
|
|
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Go Loose token endpoint"`)
|
|
|
|
|
writeError(w, http.StatusUnauthorized, "HTTP Basic client authentication required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
code, redirectURI := r.Form.Get("code"), r.Form.Get("redirect_uri")
|
|
|
|
|
if r.Form.Get("grant_type") != "authorization_code" || clientID == "" || clientSecret == "" || code == "" || redirectURI == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "authorization_code grant parameters are required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
token, _, tokenHash, err := key.GenerateToken("glc_session_")
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internalError(w, "generate client session", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
user, expiresAt, err := s.store.ExchangeAuthorizationCode(
|
|
|
|
|
r.Context(), clientID, redirectURI, key.Hash(clientSecret), key.Hash(code), tokenHash,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired authorization code")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.internalError(w, "exchange authorization code", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"access_token": token, "token_type": "Bearer",
|
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
|
|
|
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(r, user),
|
2026-10-01 23:15:57 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
token := bearerToken(r)
|
|
|
|
|
if token == "" {
|
|
|
|
|
writeError(w, http.StatusUnauthorized, "Bearer token required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
user, err := s.store.ClientUserByToken(r.Context(), key.Hash(token))
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
writeError(w, http.StatusUnauthorized, "session is invalid or expired")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.internalError(w, "load client user", 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
|
|
|
writeJSON(w, http.StatusOK, s.clientUser(r, user))
|
2026-10-01 23:15:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
token := bearerToken(r)
|
|
|
|
|
if token == "" {
|
|
|
|
|
writeError(w, http.StatusUnauthorized, "Bearer token required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.RevokeClientSession(r.Context(), key.Hash(token)); err != nil && !errors.Is(err, store.ErrNotFound) {
|
|
|
|
|
s.internalError(w, "revoke client session", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
|
|
|
value := r.Header.Get("Authorization")
|
|
|
|
|
if !strings.HasPrefix(value, "Bearer ") {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(strings.TrimPrefix(value, "Bearer "))
|
|
|
|
|
}
|