initial commit

This commit is contained in:
Daniel Noulet 2026-10-01 23:15:57 +02:00
commit 54576d9d33
61 changed files with 5569 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
.env
bin
coverage.out

18
.env.example Normal file
View file

@ -0,0 +1,18 @@
GO_LOOSE_ADDR=:8080
GO_LOOSE_BASE_URL=http://localhost:8080
GO_LOOSE_AUTH_DOMAIN=auth.local
GO_LOOSE_DATABASE_URL=postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable
GO_LOOSE_SESSION_SECRET=replace-with-at-least-32-random-characters
GO_LOOSE_DEV_LOGIN=false
GO_LOOSE_DEV_USER=admin@local.test
GO_LOOSE_LOCAL_LOGIN=true
GO_LOOSE_LOCAL_ADMIN_EMAIL=admin@goloose.local
GO_LOOSE_LOCAL_ADMIN_PASSWORD=
GO_LOOSE_OIDC_ISSUER=
GO_LOOSE_OIDC_CLIENT_ID=
GO_LOOSE_OIDC_CLIENT_SECRET=
GO_LOOSE_OIDC_REDIRECT_URL=http://localhost:8080/auth/callback
GO_LOOSE_ALLOWED_EMAIL_DOMAINS=
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=false
GO_LOOSE_BOOTSTRAP_TENANT=nmbs
GO_LOOSE_BOOTSTRAP_APP=guess

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.env
.idea/
.vscode/
bin/
coverage.out
go-loose

12
Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM golang:1.27.1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/go-loose ./cmd/go-loose
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/go-loose /go-loose
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/go-loose"]

36
Makefile Normal file
View file

@ -0,0 +1,36 @@
SHELL := /bin/sh
GO_LOOSE_ADDR ?= :8081
GO_LOOSE_BASE_URL ?= http://localhost:$(GO_LOOSE_PORT)
GO_LOOSE_DATABASE_URL ?= postgres://goloose:goloose@localhost:5433/goloose?sslmode=disable
GO_LOOSE_SESSION_SECRET ?= local-development-secret-change-me-now
GO_LOOSE_DEV_LOGIN ?= true
GO_LOOSE_DEV_USER ?= admin@local.test
GO_LOOSE_BOOTSTRAP_TENANT ?= nmbs
GO_LOOSE_BOOTSTRAP_APP ?= guess
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS ?= true
export
.PHONY: build test run compose-up compose-down fmt vet
build:
go build ./...
test:
go test -race ./...
run:
go run ./cmd/go-loose
compose-up:
docker compose up --build
compose-down:
docker compose down
fmt:
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
vet:
go vet ./...

76
README.md Normal file
View file

@ -0,0 +1,76 @@
# Go Loose
Go Loose is a multi-tenant API-key control plane for Go HTTP services. It gives operators a web console to register tenant applications, issue and revoke keys, import OpenAPI contracts, and inspect API metadata. Applications use the public `client` package as fail-closed middleware: requests without a valid key never reach the protected handler.
## Start locally
```bash
docker compose up --build
```
Open <http://localhost:8080>. The Compose setup deliberately enables database login and private OpenAPI URL scanning for local development. Never use unrestricted private URL scanning in production.
On a fresh database, open <http://localhost:8080/install>, enter the Google OAuth client secret from Google Cloud Console, and optionally seed the NMBS and YPTO Guess demonstration data. The CMS Demo client ID and callback URL are prefilled. See the [installation guide](docs/installation.md) for Google setup, first-admin behavior, waiting-room approval, and all demo credentials.
Hosted user login for that tenant is served from `http://nmbs.auth.local:8080`; add `127.0.0.1 nmbs.auth.local` to `/etc/hosts` for local browser testing.
If port 8080 is occupied, run `GO_LOOSE_PORT=18080 docker compose up --build` and open <http://localhost:18080>.
If PostgreSQL port 5432 is occupied, also set `GO_LOOSE_POSTGRES_PORT=15432`.
The first Google SSO user becomes system administrator. Later SSO users remain in a waiting room until the system administrator assigns a tenant, role, and application access. Tenant administrators can manage only their own tenants.
## Request flow
```text
Caller
└─ X-API-Key: gl_...
└─ Your Go API + go-loose/client middleware
└─ POST /api/v1/authorize → Go Loose
├─ hash key, evaluate status/expiry/tenant/app/host
├─ write authorization audit event
└─ allow with tenant/key principal OR deny
```
Only a SHA-256 digest and a short display prefix are stored. A plaintext key is shown once at creation. The client strips the credential before calling the protected handler.
## Configuration
Copy `.env.example` for direct local execution. Important settings:
| Variable | Purpose |
|---|---|
| `GO_LOOSE_DATABASE_URL` | PostgreSQL connection string |
| `GO_LOOSE_SESSION_SECRET` | At least 32 random characters; rotate through a planned session invalidation |
| `GO_LOOSE_BASE_URL` | Public origin, used for secure cookies and same-origin checks |
| `GO_LOOSE_OIDC_*` | OIDC issuer, client ID, secret, and callback URL |
| `GO_LOOSE_ALLOWED_EMAIL_DOMAINS` | Optional comma-separated SSO email-domain allowlist |
| `GO_LOOSE_LOCAL_LOGIN` | Enables database-backed login for seeded and invited users |
| `GO_LOOSE_LOCAL_ADMIN_PASSWORD` | Optional compatibility bootstrap; omit for installer-first deployment |
| `GO_LOOSE_DEV_LOGIN` | Local-only login; keep `false` in production |
| `GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS` | Allows scanning internal/loopback OpenAPI URLs; local-only unless egress is isolated |
Production must place Go Loose behind TLS, use a secret manager, set `GO_LOOSE_BASE_URL` to the HTTPS origin, disable development login, and configure OIDC. Swagger UI is available at `/docs` only after login.
## Documentation
- [Architecture and decisions](docs/architecture.md)
- [First-run installation, waiting room, and demo accounts](docs/installation.md)
- [Go middleware integration](docs/integration.md)
- [Client application login](docs/client-login.md)
- [Database user authentication and local credentials](docs/password-authentication.md)
- [Operations, migrations, and OIDC](docs/operations.md)
- [Coding standards](docs/coding-standards.md)
## Development
```bash
go mod download
make fmt
make test
make vet
make build
```
Development requires Go 1.27.1 or newer.
Migrations run transactionally at startup and are tracked in `schema_migrations`. See the operations guide before creating one.

139
client/client.go Normal file
View file

@ -0,0 +1,139 @@
// Package client provides fail-closed API-key authorization middleware for Go services.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const DefaultHeader = "X-API-Key"
type Config struct {
BaseURL string
Tenant string
Application string
Header string
HTTPClient *http.Client
}
type Client struct {
baseURL string
tenant string
application string
header string
httpClient *http.Client
}
type Principal struct {
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
ApplicationSlug string `json:"application_slug"`
APIKeyID string `json:"api_key_id"`
APIKeyName string `json:"api_key_name"`
}
type authorizationResponse struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
Principal
}
type contextKey struct{}
func New(cfg Config) (*Client, error) {
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Application) == "" {
return nil, errors.New("go-loose client requires BaseURL and Application")
}
if cfg.Header == "" {
cfg.Header = DefaultHeader
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 3 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"), tenant: cfg.Tenant,
application: cfg.Application, header: cfg.Header, httpClient: cfg.HTTPClient,
}, nil
}
func (c *Client) Authorize(ctx context.Context, apiKey, method, requestPath, host string) (Principal, error) {
payload, err := json.Marshal(map[string]string{
"tenant": c.tenant, "application": c.application, "method": method,
"path": requestPath, "host": stripPort(host),
})
if err != nil {
return Principal{}, fmt.Errorf("encode authorization request: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/authorize", bytes.NewReader(payload))
if err != nil {
return Principal{}, fmt.Errorf("create authorization request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set(c.header, apiKey)
response, err := c.httpClient.Do(request)
if err != nil {
return Principal{}, fmt.Errorf("authorize with Go Loose: %w", err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return Principal{}, fmt.Errorf("read authorization response: %w", err)
}
var result authorizationResponse
if err := json.Unmarshal(body, &result); err != nil {
return Principal{}, fmt.Errorf("decode authorization response (HTTP %d): %w", response.StatusCode, err)
}
if response.StatusCode != http.StatusOK || !result.Allowed {
if result.Reason == "" {
result.Reason = http.StatusText(response.StatusCode)
}
return Principal{}, fmt.Errorf("request denied by Go Loose: %s", result.Reason)
}
return result.Principal, nil
}
// Middleware rejects missing, invalid, or unverifiable keys and places the
// authorized Principal in the request context.
func (c *Client) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := strings.TrimSpace(r.Header.Get(c.header))
if apiKey == "" {
writeMiddlewareError(w, http.StatusUnauthorized, "missing API key")
return
}
principal, err := c.Authorize(r.Context(), apiKey, r.Method, r.URL.Path, r.Host)
if err != nil {
writeMiddlewareError(w, http.StatusForbidden, "API key not authorized")
return
}
r.Header.Del(c.header)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, principal)))
})
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(contextKey{}).(Principal)
return principal, ok
}
func stripPort(host string) string {
if parsed, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(parsed)
}
return strings.ToLower(host)
}
func writeMiddlewareError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}

54
client/client_test.go Normal file
View file

@ -0,0 +1,54 @@
package client
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestMiddlewareAuthorizesAndAddsPrincipal(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(DefaultHeader) != "gl_test_key_that_is_long_enough_for_client" {
t.Errorf("unexpected API key")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"allowed":true,"tenant_id":"tenant-1","tenant_slug":"nmbs","application_id":"app-1","application_slug":"guess","api_key_id":"key-1","api_key_name":"test"}`))
}))
defer authorizationServer.Close()
client, err := New(Config{BaseURL: authorizationServer.URL, Tenant: "nmbs", Application: "guess"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
handler := client.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := PrincipalFromContext(r.Context())
if !ok || principal.TenantSlug != "nmbs" {
t.Fatalf("missing principal: %#v", principal)
}
if r.Header.Get(DefaultHeader) != "" {
t.Fatal("middleware must not forward the credential")
}
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "http://nmbs.guess.local/guesses", nil)
request.Header.Set(DefaultHeader, "gl_test_key_that_is_long_enough_for_client")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
}
func TestMiddlewareFailsClosedWithoutKey(t *testing.T) {
client, err := New(Config{BaseURL: "http://go-loose", Application: "guess"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
called := false
handler := client.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if recorder.Code != http.StatusUnauthorized || called {
t.Fatalf("status = %d, called = %v", recorder.Code, called)
}
}

219
client/webauth.go Normal file
View file

@ -0,0 +1,219 @@
package client
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultSessionCookie = "go_loose_user_session"
stateCookie = "go_loose_login_state"
)
type BrowserAuthConfig struct {
BaseURL string
ClientID string
ClientSecret string
RedirectURL string
AfterLoginURL string
AfterLogoutURL string
LoginPath string
CookieName string
CookieSecure bool
HTTPClient *http.Client
}
type BrowserAuth struct {
config BrowserAuthConfig
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
}
type userContextKey struct{}
func NewBrowserAuth(config BrowserAuthConfig) (*BrowserAuth, error) {
if config.BaseURL == "" || config.ClientID == "" || config.ClientSecret == "" || config.RedirectURL == "" {
return nil, errors.New("browser auth requires BaseURL, ClientID, ClientSecret, and RedirectURL")
}
redirect, err := url.Parse(config.RedirectURL)
if err != nil || !redirect.IsAbs() {
return nil, errors.New("RedirectURL must be absolute")
}
config.BaseURL = strings.TrimRight(config.BaseURL, "/")
if config.AfterLoginURL == "" {
config.AfterLoginURL = "/"
}
if config.AfterLogoutURL == "" {
config.AfterLogoutURL = "/"
}
if config.LoginPath == "" {
config.LoginPath = "/login"
}
if config.CookieName == "" {
config.CookieName = defaultSessionCookie
}
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
return &BrowserAuth{config: config}, nil
}
func (a *BrowserAuth) LoginHandler(w http.ResponseWriter, r *http.Request) {
state, err := randomValue(24)
if err != nil {
http.Error(w, "could not start login", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: callbackPath(a.config.RedirectURL),
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
})
query := url.Values{
"response_type": {"code"}, "client_id": {a.config.ClientID},
"redirect_uri": {a.config.RedirectURL}, "state": {state},
}
http.Redirect(w, r, a.config.BaseURL+"/connect/authorize?"+query.Encode(), http.StatusFound)
}
func (a *BrowserAuth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(stateCookie)
if err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
http.Error(w, "invalid login state", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "authorization code missing", http.StatusBadRequest)
return
}
form := url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"redirect_uri": {a.config.RedirectURL},
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/token", strings.NewReader(form.Encode()))
if err != nil {
http.Error(w, "could not complete login", http.StatusInternalServerError)
return
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetBasicAuth(a.config.ClientID, a.config.ClientSecret)
response, err := a.config.HTTPClient.Do(request)
if err != nil {
http.Error(w, "identity service unavailable", http.StatusBadGateway)
return
}
defer response.Body.Close()
var tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&tokenResponse); err != nil || response.StatusCode != http.StatusOK || tokenResponse.AccessToken == "" {
http.Error(w, "authorization code exchange failed", http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: a.config.CookieName, Value: tokenResponse.AccessToken, Path: "/",
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
MaxAge: tokenResponse.ExpiresIn,
})
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: callbackPath(a.config.RedirectURL), MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, a.config.AfterLoginURL, http.StatusSeeOther)
}
func (a *BrowserAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
var revokeErr error
if cookie, err := r.Cookie(a.config.CookieName); err == nil {
request, requestErr := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/logout", nil)
if requestErr != nil {
revokeErr = requestErr
} else {
request.Header.Set("Authorization", "Bearer "+cookie.Value)
response, responseErr := a.config.HTTPClient.Do(request)
if responseErr != nil {
revokeErr = responseErr
} else {
response.Body.Close()
if response.StatusCode != http.StatusNoContent {
revokeErr = fmt.Errorf("Go Loose logout returned HTTP %d", response.StatusCode)
}
}
}
}
http.SetCookie(w, &http.Cookie{
Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
})
if revokeErr != nil {
http.Error(w, "local session cleared, but central session revocation failed", http.StatusBadGateway)
return
}
http.Redirect(w, r, a.config.AfterLogoutURL, http.StatusSeeOther)
}
func (a *BrowserAuth) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(a.config.CookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
return
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.config.BaseURL+"/connect/userinfo", nil)
if err != nil {
http.Error(w, "could not validate session", http.StatusInternalServerError)
return
}
request.Header.Set("Authorization", "Bearer "+cookie.Value)
response, err := a.config.HTTPClient.Do(request)
if err != nil {
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
return
}
defer response.Body.Close()
var user User
if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
http.SetCookie(w, &http.Cookie{Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey{}, user)))
})
}
func UserFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey{}).(User)
return user, ok
}
func randomValue(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", fmt.Errorf("generate random value: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func callbackPath(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Path == "" {
return "/"
}
return parsed.Path
}

101
client/webauth_test.go Normal file
View file

@ -0,0 +1,101 @@
package client
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestBrowserLoginRedirect(t *testing.T) {
auth, err := NewBrowserAuth(BrowserAuthConfig{
BaseURL: "https://nmbs.goloose.local", ClientID: "client",
ClientSecret: "secret", RedirectURL: "https://nmbs.guess.local/auth/callback",
})
if err != nil {
t.Fatalf("NewBrowserAuth() error = %v", err)
}
recorder := httptest.NewRecorder()
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, "/login", nil))
if recorder.Code != http.StatusFound {
t.Fatalf("status = %d", recorder.Code)
}
target, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatalf("parse location: %v", err)
}
if target.Path != "/connect/authorize" || target.Query().Get("client_id") != "client" || target.Query().Get("state") == "" {
t.Fatalf("unexpected redirect: %s", target)
}
if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly {
t.Fatal("expected an HTTP-only state cookie")
}
}
func TestBrowserMiddlewareAddsUser(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer session-token" {
t.Fatal("missing bearer token")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"user-1","email":"user@example.com","tenant_slug":"nmbs","application":"guess"}`))
}))
defer identity.Close()
auth, err := NewBrowserAuth(BrowserAuthConfig{
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
RedirectURL: "http://localhost/auth/callback",
})
if err != nil {
t.Fatalf("NewBrowserAuth() error = %v", err)
}
handler := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok || user.Email != "user@example.com" {
t.Fatalf("unexpected user: %#v", user)
}
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "http://localhost/private", nil)
request.AddCookie(&http.Cookie{Name: defaultSessionCookie, Value: "session-token"})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d", recorder.Code)
}
}
func TestBrowserCallbackUsesBasicClientAuthentication(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "client-id" || secret != "client-secret" {
t.Fatalf("unexpected client authentication: %q, %q, %v", clientID, secret, ok)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"session-token","expires_in":3600}`))
}))
defer identity.Close()
auth, err := NewBrowserAuth(BrowserAuthConfig{
BaseURL: identity.URL, ClientID: "client-id", ClientSecret: "client-secret",
RedirectURL: "http://localhost/auth/callback",
})
if err != nil {
t.Fatalf("NewBrowserAuth() error = %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/auth/callback?code=one-time-code&state=login-state", nil)
request.AddCookie(&http.Cookie{Name: stateCookie, Value: "login-state"})
recorder := httptest.NewRecorder()
auth.CallbackHandler(recorder, request)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var session *http.Cookie
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == defaultSessionCookie {
session = cookie
}
}
if session == nil || session.Value != "session-token" || !session.HttpOnly {
t.Fatalf("unexpected session cookie: %#v", session)
}
}

41
compose.yaml Normal file
View file

@ -0,0 +1,41 @@
services:
postgres:
image: postgres:17-alpine
ports:
- "${GO_LOOSE_POSTGRES_PORT:-5433}:5432"
environment:
POSTGRES_DB: goloose
POSTGRES_USER: goloose
POSTGRES_PASSWORD: goloose
volumes:
- postgres-data2:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U goloose -d goloose"]
interval: 5s
timeout: 3s
retries: 10
go-loose:
build: .
depends_on:
postgres:
condition: service_healthy
environment:
GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "http://localhost:${GO_LOOSE_PORT:-8080}"
GO_LOOSE_AUTH_DOMAIN: "auth.local"
GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable"
GO_LOOSE_SESSION_SECRET: "local-development-secret-change-me-now"
GO_LOOSE_DEV_LOGIN: "false"
GO_LOOSE_LOCAL_LOGIN: "true"
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
ports:
- "${GO_LOOSE_PORT:-8080}:8080"
healthcheck:
test: ["CMD", "/go-loose", "-healthcheck"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
volumes:
postgres-data2:

80
docs/architecture.md Normal file
View file

@ -0,0 +1,80 @@
# Architecture and decisions
## Scope
Go Loose is an authorization control plane, not an API gateway. Protected applications keep serving their own traffic and use the client middleware to ask Go Loose whether an incoming key is authorized. This minimizes coupling and lets a service adopt Go Loose one route group at a time.
## Components
```text
cmd/go-loose
configuration → migrations → HTTP server
internal/server
OIDC/session auth, management API, authorization endpoint, embedded web UI
internal/store
tenant-isolated PostgreSQL queries and authorization audit writes
internal/key
cryptographically random key generation and one-way lookup hashing
internal/contract
OpenAPI 2/3 JSON/YAML normalization and endpoint extraction
client
public net/http middleware package used by other Go modules
```
PostgreSQL is the source of truth. The frontend is dependency-free HTML/CSS/JavaScript embedded in the Go binary. This creates one immutable artifact and avoids a second package ecosystem while the product is young.
## Tenant model
- A **tenant** is an organization such as `nmbs`.
- An **application** is a protected API such as `guess` and always belongs to one tenant.
- An **API key** belongs to exactly one application and tenant.
- A **membership** grants a user `owner`, `admin`, or `viewer` access to a tenant.
- An **application access grant** allows a user to sign in to one client application.
- `allowed_hosts` optionally binds keys for an application to hosts such as `nmbs.guess.local`.
- Tenant and application slugs are sent by middleware, so identical application names can safely exist in different tenants.
Hostnames are authorization context, not tenant identity. Do not trust a host alone: the key, expected tenant, expected application, and optional allowed host must all agree.
## Security decisions
1. **Fail closed.** Missing keys, Go Loose outages, malformed responses, inactive keys, expired keys, scope mismatches, and host mismatches deny the request.
2. **No plaintext key persistence.** Keys contain 256 random bits and are stored as SHA-256 digests. High-entropy secrets do not require a slow password hash for offline resistance.
3. **Session isolation.** Management uses signed, HTTP-only, SameSite cookies. Mutations additionally require the configured same origin.
4. **OIDC validation.** The server uses provider discovery and validates issuer, audience, signature, and expiry through `go-oidc`.
5. **SSRF resistance.** Contract import allows only HTTP(S), resolves hostnames, and blocks loopback/private/link-local destinations unless explicitly enabled.
6. **Auditing.** Every runtime decision records tenant, application, key, route context, outcome, and reason. Raw keys are never logged.
7. **Least privilege.** Management writes require tenant `owner` or `admin`. Read access requires membership.
8. **Separate login purpose.** Client login sessions cannot open the management console. Hosted login uses exact callback matching, one-time codes, confidential client secrets, and opaque revocable sessions.
For production, add rate limiting at the ingress, PostgreSQL backups, metrics/tracing, audit retention, membership invitations, key rotation overlap, and a tightly controlled egress proxy for contract scanning.
## OpenAPI contracts
Imported OpenAPI 2.x/3.x documents are normalized to JSON and stored unchanged in meaning. Methods, paths, and operation IDs are extracted into relational rows for discovery. Contracts are currently informational: they do not restrict runtime paths. Enforcing contract endpoints later should be opt-in per application to avoid breaking existing consumers.
## Decision log
| Decision | Choice | Reason |
|---|---|---|
| Deployment | One Go binary | Simple operations and atomic frontend/backend releases |
| Database | PostgreSQL | Strong tenant relations, transactions, JSONB contracts, and mature operations |
| Router | Go `http.ServeMux` | Modern method/path routing without another framework |
| UI | Embedded vanilla web assets | Fast startup, small supply chain, no Node runtime/build |
| Identity | OIDC Authorization Code flow | Works with Keycloak, Entra ID, Auth0, Okta, and other compliant IdPs |
| API keys | Random opaque secret + SHA-256 digest | Efficient indexed validation without recoverable secrets |
| Migrations | Embedded ordered SQL | Reviewable schema history and automatic transactional upgrades |
| Runtime integration | Remote authorization middleware | Immediate revocation and centralized audit; availability must be engineered |
## Planned evolution
1. Membership invitations and IdP group-to-role mapping.
2. Key rotation pairs and usage analytics.
3. Optional short-lived signed authorization assertions to reduce control-plane latency.
4. Per-application contract enforcement and route-level scopes.
5. Webhooks and audit export.
6. High-availability deployment, telemetry, and policy-based rate limits.

134
docs/client-login.md Normal file
View file

@ -0,0 +1,134 @@
# Client application login
Go Loose can host login for browser-facing Go applications. Your application never handles the user's IdP password. It redirects the browser to Go Loose, receives a short-lived one-time authorization code, exchanges that code from its backend using a client secret, and stores the returned opaque session in an HTTP-only cookie.
## 1. Configure the application in Go Loose
1. Open **Applications** and choose **Configure client login**.
2. Add the exact callback URL, for example `https://nmbs.guess.local/auth/callback`.
3. Save and copy the client secret. It is shown once.
4. Open **Users**, invite the user by the email returned by the IdP, and choose **Manage** to grant access to the application.
For database login, the invitation also requires an initial password. The same tenant login screen supports both database credentials and SSO.
Saving client configuration rotates the secret. Callback URLs require HTTPS, except `localhost` and `127.0.0.1` while Go Loose development login is enabled.
## 2. Add the Go client
```bash
go get github.com/danyel/go-loose/client
```
Configure the hosted login and register its handlers:
```go
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
goloose "github.com/danyel/go-loose/client"
)
func main() {
auth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: "https://nmbs.auth.example.com",
ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"),
ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"),
RedirectURL: "https://nmbs.guess.local/auth/callback",
AfterLoginURL: "/app",
AfterLogoutURL: "/",
LoginPath: "/login",
CookieSecure: true,
HTTPClient: &http.Client{Timeout: 3 * time.Second},
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /login", auth.LoginHandler)
mux.HandleFunc("GET /auth/callback", auth.CallbackHandler)
mux.HandleFunc("POST /logout", auth.LogoutHandler)
protected := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := goloose.UserFromContext(r.Context())
if !ok {
http.Error(w, "user missing", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Hello %s from tenant %s", user.DisplayName, user.TenantSlug)
}))
mux.Handle("GET /app", protected)
mux.Handle("GET /api/profile", protected)
log.Fatal(http.ListenAndServe(":8090", mux))
}
```
Keep `GO_LOOSE_CLIENT_SECRET` in a secret manager, never source control. In local development, configure an exact callback such as `http://localhost:8090/auth/callback` and set `CookieSecure: false`.
## Tenant login hostname
Each tenant has its own login origin:
```text
https://<tenant-slug>.<GO_LOOSE_AUTH_DOMAIN>
```
For tenant `nmbs` with `GO_LOOSE_AUTH_DOMAIN=auth.local`, configure the Go client with:
```go
BaseURL: "http://nmbs.auth.local:18080"
```
For local development, add the tenant hostname to `/etc/hosts`:
```text
127.0.0.1 nmbs.auth.local
```
Production should configure wildcard DNS and TLS for `*.auth.example.com`, route those hosts to Go Loose, and use:
```dotenv
GO_LOOSE_BASE_URL=https://auth.example.com
GO_LOOSE_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_REDIRECT_URL=https://auth.example.com/auth/callback
```
The client package initially redirects to `/connect/authorize`. Go Loose verifies the registered client and exact callback, canonicalizes the browser onto the tenant hostname, and renders `<Tenant name> login` for the requested application.
## 3. Browser flow
```text
Browser → GET /login on client application
→ GET /connect/authorize on Go Loose
→ separate Go Loose client login
→ organization OIDC provider
→ Go Loose validates user + application grant
→ client /auth/callback?code=...&state=...
Client → POST /connect/token with Authorization: Basic <client-id:secret>
← opaque 12-hour session token
Browser ← HTTP-only SameSite session cookie on client application
Request → client middleware → GET /connect/userinfo → protected handler
Authorization: Bearer <opaque-session>
```
The authorization code expires after two minutes and can be used once. Redirect URIs must match exactly. Removing a user's application grant immediately makes `/connect/userinfo` reject existing sessions. Logout revokes the central session and clears the local cookie.
## Security and availability
- Use TLS for both Go Loose and the client application.
- Keep client secrets server-side and rotate them after suspected disclosure.
- Do not replace the middleware's fail-closed behavior with an allow-on-error fallback.
- Apply CSRF protection to state-changing routes in the client application; the package handles login `state`, not your application forms.
- The middleware checks Go Loose on every request for immediate revocation. Deploy Go Loose highly available and use a bounded HTTP timeout.
- API-key authentication and browser-user authentication are independent. Use `client.Middleware` for service/API keys and `BrowserAuth.Middleware` for logged-in users.
## Troubleshooting `403`
`403 your account does not have access to this application` means authentication succeeded but the user lacks an application grant. In Go Loose, open **Users**, select the user, choose **Manage**, and enable the target application. A missing or incorrect token header returns `401`, not `403`.

46
docs/coding-standards.md Normal file
View file

@ -0,0 +1,46 @@
# Coding standards
## Go
- Use the standard library unless a dependency clearly reduces security or protocol risk.
- Keep public APIs small, documented, and backward compatible.
- Pass `context.Context` through I/O boundaries and always bound network calls with timeouts.
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
- Return explicit errors. Runtime authorization must fail closed.
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.
## HTTP API
- Routes are versioned under `/api/v1`; health and authentication routes are infrastructure exceptions.
- JSON handlers reject unknown fields, extra JSON values, and oversized bodies.
- Errors use `{"error":"human-readable message"}` without internal implementation details.
- Use `401` for absent/unusable authentication, `403` for authenticated credentials outside policy, `404` when existence must not disclose access, and `422` for semantically invalid contracts.
- Any management mutation requires a valid user session and same-origin browser request.
- Update `web/openapi.json` whenever endpoint behavior changes.
## Database
- Use UUID primary keys for tenant data and UTC `timestamptz` timestamps.
- Foreign keys declare deletion behavior; frequently queried authorization fields get explicit indexes.
- Applied migrations are immutable and forward-compatible.
- Multi-step writes use transactions. Do not add application-side checks that can race when a database constraint can enforce the invariant.
## Frontend
- Preserve keyboard access, semantic elements, visible focus, responsive layout, and sufficient contrast in every theme.
- Escape all server-provided strings before assigning HTML. Prefer `textContent` for new dynamic surfaces.
- Store only visual preferences in local storage; authentication remains in HTTP-only cookies.
- Keep the console dependency-free unless complexity justifies a reviewed build tool decision.
## Tests and reviews
- New domain behavior needs table-driven unit tests and negative cases.
- Security boundaries need explicit tests for missing, malformed, expired, cross-tenant, and unauthorized inputs.
- Integration tests that need PostgreSQL should use a disposable database and never shared developer data.
- Reviews prioritize tenant isolation, fail-closed behavior, migration compatibility, secret handling, and error visibility.
## Commit style
Use focused commits with imperative subjects, for example `Add scoped API key authorization`. Do not mix unrelated refactors with behavior changes. Explain migration and security implications in the commit body.

75
docs/installation.md Normal file
View file

@ -0,0 +1,75 @@
# First-run installation and demo data
## Clean installation
Start Go Loose with PostgreSQL and a stable `GO_LOOSE_SESSION_SECRET` of at least 32 characters. No OIDC environment variables or bootstrap account are required:
```bash
docker compose up --build
```
Open `http://localhost:8080/install`. The installer asks for a Google OAuth client ID and client secret. In Google Cloud Console, configure this authorized redirect URI before submitting:
```text
http://localhost:8080/auth/callback
```
The supplied **CMS Demo** client ID is prefilled:
```text
554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com
```
The Google client secret is not included in the repository and must be copied from Google Cloud Console. Go Loose validates Google OIDC discovery, encrypts the secret with AES-GCM using a key derived from `GO_LOOSE_SESSION_SECRET`, and stores only the ciphertext in PostgreSQL. Keep the session secret stable: losing it makes the installed OIDC secret undecryptable.
Installation is a one-time operation. Submitting the installer immediately starts Google login. Google redirects to `/auth/callback`, where Go Loose validates the ID token, creates a signed HTTP-only session cookie, and redirects to the management console. Browser redirects cannot carry a caller-defined `Authorization` header, so no bearer token is exposed in the URL or to frontend JavaScript.
Later requests to `/install` redirect to login, and duplicate submissions are rejected.
## First administrator and waiting room
The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant and application that exists at that moment.
Later first-time SSO users are created without tenant membership. Their management page shows only the waiting room. A system administrator approves a waiting user from **Users & access**, selecting:
- the tenant;
- tenant role (`viewer`, `admin`, or `owner`);
- the applications the user may log in to.
Tenant owners and administrators can manage users, applications, API keys, client login, and OpenAPI contracts only inside their assigned tenants. A user assigned to multiple tenants can switch the active tenant from the console header. Database membership checks—not the selector—enforce isolation.
## Optional Guess demonstration seed
The installer selects the demonstration seed by default. It creates the `nmbs` and `ypto` tenants, one `guess` application in each tenant, and these local database users:
| Tenant | Email | Password | Role |
|---|---|---|---|
| NMBS | `interview@nmbs.auth.local` | `admin123` | Tenant administrator |
| YPTO | `interview@ypto.auth.local` | `admin123` | Tenant administrator |
| NMBS | `reviewer@nmbs.auth.local` | `Demo-Nmbs-2026!` | Tenant administrator |
| YPTO | `reviewer@ypto.auth.local` | `Demo-Ypto-2026!` | Tenant administrator |
All four users receive access only to their tenant's Guess application. Passwords are stored as Argon2id hashes; plaintext credentials are never written to PostgreSQL.
These accounts and especially `admin123` are intentionally non-production credentials. Disable the seed for production, or delete/rotate every demo account before exposing the service.
For local hosted-login testing, add:
```text
127.0.0.1 nmbs.auth.local ypto.auth.local
```
The tenant login origins are then:
```text
http://nmbs.auth.local:8080
http://ypto.auth.local:8080
```
An application must first have exact redirect URIs and a generated client secret configured in the management console before `/connect/authorize` can start its login flow.
## Environment-configured compatibility mode
Existing deployments may continue to supply all three `GO_LOOSE_OIDC_ISSUER`, `GO_LOOSE_OIDC_CLIENT_ID`, and `GO_LOOSE_OIDC_CLIENT_SECRET` variables. Go Loose treats that as an already configured deployment and bypasses the installer. Supplying `GO_LOOSE_LOCAL_LOGIN=true` together with a non-empty `GO_LOOSE_LOCAL_ADMIN_PASSWORD` does the same for a database-only development deployment.
For new installations, prefer the web installer. `GO_LOOSE_LOCAL_LOGIN=true` without a bootstrap password enables seeded and invited database users but does not bypass installation.

64
docs/integration.md Normal file
View file

@ -0,0 +1,64 @@
# Integrating a Go API
Install the module from its eventual repository location:
```bash
go get github.com/danyel/go-loose/client
```
Wrap a router or selected route group:
```go
package main
import (
"log"
"net/http"
"time"
goloose "github.com/danyel/go-loose/client"
)
func main() {
access, err := goloose.New(goloose.Config{
BaseURL: "https://nmbs.goloose.local",
Tenant: "nmbs",
Application: "guess",
HTTPClient: &http.Client{Timeout: 2 * time.Second},
})
if err != nil {
log.Fatal(err)
}
protected := access.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := goloose.PrincipalFromContext(r.Context())
w.Write([]byte("authorized by key " + principal.APIKeyName))
}))
mux := http.NewServeMux()
mux.Handle("GET /api/guesses", protected)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":8090", mux))
}
```
Call the protected API:
```bash
curl -H 'X-API-Key: gl_REPLACE_WITH_ISSUED_SECRET' \
https://nmbs.guess.local/api/guesses
```
The middleware sends the original method, URL path, and request host to Go Loose. It denies missing/invalid keys and control-plane errors, then removes `X-API-Key` before invoking the application handler. Read the authorized tenant/application/key identity with `PrincipalFromContext`.
## Importing the service contract
Expose an OpenAPI JSON or YAML route from the service, then use **Contracts → Import contract → Scan URL**. Private/internal URLs are blocked by default; production deployments that need internal discovery should use a restricted egress network and explicitly set `GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=true`.
Contracts can also be pasted into the console, which avoids server-side egress entirely.
## Availability and latency
The current client validates remotely on every request so revocation is immediate. Run Go Loose highly available and set a strict client timeout. Do not implement an “allow on error” fallback. A future signed-assertion mode can reduce latency while retaining bounded revocation delay.

78
docs/operations.md Normal file
View file

@ -0,0 +1,78 @@
# Operations
## First-run installer
New deployments need only PostgreSQL, the public URL settings, and a stable session secret. Open `/install` to configure Google SSO and optional Guess demo data. See [First-run installation and demo data](installation.md).
The first Google SSO user is claimed as system administrator. All later SSO users remain in the waiting room until that administrator grants tenant membership and application access.
## OIDC / SSO
Register an OIDC confidential web client with this callback:
```text
https://<go-loose-host>/auth/callback
```
The web installer is preferred. Environment configuration remains available for existing or fully automated deployments:
```dotenv
GO_LOOSE_BASE_URL=https://nmbs.goloose.local
GO_LOOSE_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_ISSUER=https://id.example.com/realms/platform
GO_LOOSE_OIDC_CLIENT_ID=go-loose
GO_LOOSE_OIDC_CLIENT_SECRET=<from-secret-manager>
GO_LOOSE_OIDC_REDIRECT_URL=https://nmbs.goloose.local/auth/callback
GO_LOOSE_ALLOWED_EMAIL_DOMAINS=example.com
GO_LOOSE_DEV_LOGIN=false
```
Client applications are served on `https://<tenant>.auth.example.com`; configure wildcard DNS and TLS for `*.auth.example.com`. The OIDC callback host and `GO_LOOSE_BASE_URL` must be inside the configured auth domain so the callback can issue the shared tenant-login cookie.
Database authentication is controlled by `GO_LOOSE_LOCAL_LOGIN`. It may remain enabled for seeded or invited users without creating a bootstrap account. Set `GO_LOOSE_LOCAL_ADMIN_PASSWORD` only when compatibility bootstrap behavior is explicitly required, and provide it through a secret manager.
The provider must return `sub`, `email`, and preferably `name` claims.
## Migrations
Migration files live in `internal/database/migrations` and are embedded into the binary. Files run lexicographically once, in individual transactions.
Create upgrades with an immutable numbered file:
```text
0002_membership_invitations.sql
```
Use this structure:
```sql
-- +goose Up
CREATE TABLE membership_invitations (...);
-- +goose Down
DROP TABLE membership_invitations;
```
The built-in runner applies only the `Up` section. Down sections document rollback intent but are never run automatically. Never edit an applied migration; create a new one. Prefer backward-compatible expansion first, deploy code that understands both shapes, then remove old fields in a later release.
Back up the database before production upgrades. Restore and test backups regularly. Startup fails if a migration fails, preventing the application from running against a partially upgraded schema.
## Production checklist
- TLS terminates at a trusted ingress and forwards the original host.
- `GO_LOOSE_BASE_URL` is the exact HTTPS browser origin.
- Session and OIDC secrets come from a secret manager.
- Demo users are not seeded, or every demo credential is removed/rotated before exposure.
- Development login and unrestricted private URL scanning are disabled.
- PostgreSQL uses TLS, backups, point-in-time recovery, and least-privilege credentials.
- `/healthz` is used for readiness; logs are shipped without request headers.
- Authorization endpoint rate limits and network policies are configured at ingress.
- Multiple replicas share PostgreSQL; rolling deployment starts only after migration compatibility is verified.
- The external Swagger UI assets are mirrored locally if internet-free operation is required.
## Key incident response
1. Revoke the exposed key in the console.
2. Issue a replacement and update the consumer through its secret manager.
3. Review `authorization_events` for the key ID and affected time window.
4. Investigate the exposure source; never restore a revoked key.

View file

@ -0,0 +1,56 @@
# Database user authentication
Go Loose supports local email/password accounts stored in PostgreSQL. Passwords are never stored in plaintext: the server uses Argon2id with a random 16-byte salt, 64 MiB of memory, three iterations, and two lanes.
## Seeded Docker credentials
On a fresh Compose deployment, use `/install` and keep **Seed NMBS and YPTO Guess demo data** selected. The complete credential table is in the [installation guide](installation.md). The two primary interview accounts are:
```text
interview@nmbs.auth.local / admin123
interview@ypto.auth.local / admin123
```
For tenant application login, add `127.0.0.1 nmbs.auth.local ypto.auth.local` to `/etc/hosts`.
```text
http://nmbs.auth.local:8080
```
If alternate ports are needed:
```bash
GO_LOOSE_PORT=18080 \
GO_LOOSE_POSTGRES_PORT=15432 \
docker compose up --build
```
Then use `http://localhost:18080/login` and `http://nmbs.auth.local:18080`.
## Configuration
```dotenv
GO_LOOSE_LOCAL_LOGIN=true
GO_LOOSE_LOCAL_ADMIN_EMAIL=admin@example.com # optional bootstrap
GO_LOOSE_LOCAL_ADMIN_PASSWORD=<at-least-12-characters> # optional bootstrap
```
`GO_LOOSE_LOCAL_LOGIN=true` enables all database-backed users. A bootstrap account is created only when `GO_LOOSE_LOCAL_ADMIN_PASSWORD` is non-empty; its password is applied only when the account has no password hash. Changing the environment variable later does not silently overwrite an existing database password. If a bootstrap account is required, inject its password from a secret manager and never document a production credential.
## Creating client users
1. Sign in to the management console.
2. Open **Users** and choose **Invite user**.
3. Enter the user's email, display name, initial password, and tenant role.
4. Choose **Manage** for that user and grant the required applications.
5. Give the initial credential to the user through a secure channel.
The user can then sign in on `<tenant>.auth.local` or the production tenant auth hostname. Re-inviting an existing email explicitly replaces its password and display name.
## Brute-force and audit behavior
- Five failed attempts for the same IP/email pair within ten minutes cause a fifteen-minute lock.
- Invalid emails and invalid passwords return the same response.
- Unknown-user attempts still run Argon2id verification to reduce timing differences.
- Successful, failed, and rate-limited attempts are written to `user_login_events`.
- Authentication sessions remain signed HTTP-only cookies. Tenant client-login sessions cannot access the management console.

23
go.mod Normal file
View file

@ -0,0 +1,23 @@
module github.com/danyel/go-loose
go 1.27.1
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.40.0
golang.org/x/oauth2 v0.37.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.42.0 // indirect
)

45
go.sum Normal file
View file

@ -0,0 +1,45 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

102
internal/config/config.go Normal file
View file

@ -0,0 +1,102 @@
package config
import (
"errors"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Addr string
BaseURL string
AuthDomain string
DatabaseURL string
SessionSecret string
DevLogin bool
DevUser string
LocalLogin bool
LocalAdminEmail string
LocalAdminPassword string
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCRedirectURL string
AllowedEmailDomains []string
AllowPrivateContractURL bool
BootstrapTenant string
BootstrapApp string
HTTPTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Addr: env("GO_LOOSE_ADDR", ":8080"),
BaseURL: strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/"),
AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.local")), "."),
DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
DevUser: env("GO_LOOSE_DEV_USER", "admin@local.test"),
LocalLogin: envBool("GO_LOOSE_LOCAL_LOGIN", false),
LocalAdminEmail: strings.ToLower(env("GO_LOOSE_LOCAL_ADMIN_EMAIL", "admin@goloose.local")),
LocalAdminPassword: os.Getenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD"),
OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"),
OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"),
OIDCRedirectURL: env("GO_LOOSE_OIDC_REDIRECT_URL", "http://localhost:8080/auth/callback"),
AllowedEmailDomains: csv(os.Getenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS")),
AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false),
BootstrapTenant: env("GO_LOOSE_BOOTSTRAP_TENANT", "default"),
BootstrapApp: env("GO_LOOSE_BOOTSTRAP_APP", "default"),
HTTPTimeout: 10 * time.Second,
}
if len(cfg.SessionSecret) < 32 {
return Config{}, errors.New("GO_LOOSE_SESSION_SECRET must contain at least 32 characters")
}
if cfg.AuthDomain == "" || strings.ContainsAny(cfg.AuthDomain, "/:") {
return Config{}, errors.New("GO_LOOSE_AUTH_DOMAIN must be a hostname such as auth.local")
}
oidcConfigured := cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != ""
if cfg.LocalAdminPassword != "" && len(cfg.LocalAdminPassword) < 12 {
return Config{}, errors.New("GO_LOOSE_LOCAL_ADMIN_PASSWORD must contain at least 12 characters")
}
if oidcConfigured {
baseURL, err := url.Parse(cfg.BaseURL)
if err != nil || (baseURL.Hostname() != cfg.AuthDomain && !strings.HasSuffix(baseURL.Hostname(), "."+cfg.AuthDomain)) {
return Config{}, errors.New("GO_LOOSE_BASE_URL must be inside GO_LOOSE_AUTH_DOMAIN for shared OIDC login")
}
}
return cfg, nil
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func envBool(name string, fallback bool) bool {
value := os.Getenv(name)
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func csv(value string) []string {
var result []string
for _, item := range strings.Split(value, ",") {
if item = strings.TrimSpace(strings.ToLower(item)); item != "" {
result = append(result, item)
}
}
return result
}

View file

@ -0,0 +1,45 @@
package config
import "testing"
func TestLoadRequiresSessionSecret(t *testing.T) {
t.Setenv("GO_LOOSE_SESSION_SECRET", "short")
t.Setenv("GO_LOOSE_DEV_LOGIN", "true")
if _, err := Load(); err == nil {
t.Fatal("expected an error for a short session secret")
}
}
func TestLoadDevelopmentConfiguration(t *testing.T) {
t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901")
t.Setenv("GO_LOOSE_DEV_LOGIN", "true")
t.Setenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS", "Example.com, internal.test")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if len(cfg.AllowedEmailDomains) != 2 || cfg.AllowedEmailDomains[0] != "example.com" {
t.Fatalf("unexpected domains: %#v", cfg.AllowedEmailDomains)
}
}
func TestLoadAllowsFirstRunWithoutConfiguredLogin(t *testing.T) {
t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901")
t.Setenv("GO_LOOSE_DEV_LOGIN", "false")
t.Setenv("GO_LOOSE_LOCAL_LOGIN", "false")
t.Setenv("GO_LOOSE_OIDC_ISSUER", "")
t.Setenv("GO_LOOSE_OIDC_CLIENT_ID", "")
t.Setenv("GO_LOOSE_OIDC_CLIENT_SECRET", "")
if _, err := Load(); err != nil {
t.Fatalf("Load() error = %v", err)
}
}
func TestLoadAllowsDatabaseLoginWithoutBootstrapAccount(t *testing.T) {
t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901")
t.Setenv("GO_LOOSE_LOCAL_LOGIN", "true")
t.Setenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD", "")
if _, err := Load(); err != nil {
t.Fatalf("Load() error = %v", err)
}
}

View file

@ -0,0 +1,140 @@
package contract
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
type Endpoint struct {
Method string `json:"method"`
Path string `json:"path"`
OperationID string `json:"operation_id"`
}
type Document struct {
Version string
JSON []byte
Endpoints []Endpoint
}
func Parse(content []byte) (Document, error) {
var value map[string]any
if json.Unmarshal(content, &value) != nil {
var yamlValue any
if err := yaml.Unmarshal(content, &yamlValue); err != nil {
return Document{}, fmt.Errorf("parse OpenAPI document: %w", err)
}
normalized, err := normalizeYAML(yamlValue)
if err != nil {
return Document{}, err
}
value, _ = normalized.(map[string]any)
}
version, _ := value["openapi"].(string)
if version == "" {
if swagger, _ := value["swagger"].(string); swagger != "" {
version = swagger
}
}
if version == "" {
return Document{}, errors.New("document is not an OpenAPI or Swagger contract")
}
var endpoints []Endpoint
paths, _ := value["paths"].(map[string]any)
for path, rawOperations := range paths {
operations, _ := rawOperations.(map[string]any)
for method, rawOperation := range operations {
method = strings.ToUpper(method)
if !isHTTPMethod(method) {
continue
}
operation, _ := rawOperation.(map[string]any)
operationID, _ := operation["operationId"].(string)
endpoints = append(endpoints, Endpoint{Method: method, Path: path, OperationID: operationID})
}
}
sort.Slice(endpoints, func(i, j int) bool {
if endpoints[i].Path == endpoints[j].Path {
return endpoints[i].Method < endpoints[j].Method
}
return endpoints[i].Path < endpoints[j].Path
})
canonical, err := json.Marshal(value)
if err != nil {
return Document{}, fmt.Errorf("encode OpenAPI document: %w", err)
}
return Document{Version: version, JSON: canonical, Endpoints: endpoints}, nil
}
func ValidateSourceURL(raw string, allowPrivate bool) (*url.URL, error) {
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return nil, errors.New("source URL must be an absolute HTTP(S) URL")
}
if allowPrivate {
return parsed, nil
}
addresses, err := net.LookupIP(parsed.Hostname())
if err != nil {
return nil, fmt.Errorf("resolve source host: %w", err)
}
for _, address := range addresses {
if address.IsLoopback() || address.IsPrivate() || address.IsLinkLocalUnicast() || address.IsUnspecified() {
return nil, errors.New("private source URLs are disabled")
}
}
return parsed, nil
}
func normalizeYAML(value any) (any, error) {
switch typed := value.(type) {
case map[string]any:
result := make(map[string]any, len(typed))
for key, item := range typed {
normalized, err := normalizeYAML(item)
if err != nil {
return nil, err
}
result[key] = normalized
}
return result, nil
case map[any]any:
result := make(map[string]any, len(typed))
for key, item := range typed {
stringKey, ok := key.(string)
if !ok {
return nil, errors.New("OpenAPI object contains a non-string key")
}
normalized, err := normalizeYAML(item)
if err != nil {
return nil, err
}
result[stringKey] = normalized
}
return result, nil
case []any:
result := make([]any, len(typed))
for i, item := range typed {
normalized, err := normalizeYAML(item)
if err != nil {
return nil, err
}
result[i] = normalized
}
return result, nil
default:
return typed, nil
}
}
func isHTTPMethod(method string) bool {
return bytes.Contains([]byte(" GET PUT POST DELETE PATCH HEAD OPTIONS TRACE "), []byte(" "+method+" "))
}

View file

@ -0,0 +1,26 @@
package contract
import "testing"
func TestParseYAML(t *testing.T) {
document, err := Parse([]byte(`openapi: 3.1.0
paths:
/guesses:
get:
operationId: listGuesses
post:
operationId: createGuess
`))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if document.Version != "3.1.0" || len(document.Endpoints) != 2 {
t.Fatalf("unexpected document: %#v", document)
}
}
func TestValidateSourceURLRejectsLoopback(t *testing.T) {
if _, err := ValidateSourceURL("http://127.0.0.1/openapi.json", false); err == nil {
t.Fatal("expected loopback URL to be rejected")
}
}

View file

@ -0,0 +1,100 @@
package database
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"sort"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func Open(ctx context.Context, databaseURL string) (*sql.DB, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
func Migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create migration table: %w", err)
}
entries, err := fs.ReadDir(migrationFiles, "migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
var applied bool
err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, entry.Name()).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %s: %w", entry.Name(), err)
}
if applied {
continue
}
content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
up, err := upSection(string(content))
if err != nil {
return fmt.Errorf("parse migration %s: %w", entry.Name(), err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
}
if _, err = tx.ExecContext(ctx, up); err == nil {
_, err = tx.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, entry.Name())
}
if err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
}
}
return nil
}
func upSection(content string) (string, error) {
const upMarker = "-- +goose Up"
const downMarker = "-- +goose Down"
start := strings.Index(content, upMarker)
if start < 0 {
return "", errors.New("missing up marker")
}
content = content[start+len(upMarker):]
if end := strings.Index(content, downMarker); end >= 0 {
content = content[:end]
}
if strings.TrimSpace(content) == "" {
return "", errors.New("empty up migration")
}
return content, nil
}

View file

@ -0,0 +1,13 @@
package database
import "testing"
func TestUpSection(t *testing.T) {
got, err := upSection("-- +goose Up\nCREATE TABLE example(id int);\n-- +goose Down\nDROP TABLE example;")
if err != nil {
t.Fatalf("upSection() error = %v", err)
}
if got != "\nCREATE TABLE example(id int);\n" {
t.Fatalf("unexpected section: %q", got)
}
}

View file

@ -0,0 +1,101 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
subject text NOT NULL UNIQUE,
email text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
last_login_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE applications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
name text NOT NULL,
description text NOT NULL DEFAULT '',
allowed_hosts text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, slug)
);
CREATE TABLE api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
name text NOT NULL,
prefix text NOT NULL,
secret_hash bytea NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
expires_at timestamptz,
last_used_at timestamptz,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
);
CREATE INDEX api_keys_lookup_idx ON api_keys(secret_hash) WHERE status = 'active';
CREATE INDEX api_keys_application_idx ON api_keys(application_id);
CREATE TABLE openapi_contracts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
version text NOT NULL,
source_url text,
document jsonb NOT NULL,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE contract_endpoints (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id uuid NOT NULL REFERENCES openapi_contracts(id) ON DELETE CASCADE,
method text NOT NULL,
path text NOT NULL,
operation_id text NOT NULL DEFAULT '',
UNIQUE (contract_id, method, path)
);
CREATE TABLE authorization_events (
id bigserial PRIMARY KEY,
tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
application_id uuid REFERENCES applications(id) ON DELETE SET NULL,
api_key_id uuid REFERENCES api_keys(id) ON DELETE SET NULL,
method text NOT NULL,
path text NOT NULL,
host text NOT NULL,
allowed boolean NOT NULL,
reason text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX authorization_events_occurred_idx ON authorization_events(occurred_at DESC);
-- +goose Down
DROP TABLE IF EXISTS authorization_events;
DROP TABLE IF EXISTS contract_endpoints;
DROP TABLE IF EXISTS openapi_contracts;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS applications;
DROP TABLE IF EXISTS memberships;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants;

View file

@ -0,0 +1,54 @@
-- +goose Up
ALTER TABLE applications
ADD COLUMN client_id text NOT NULL UNIQUE DEFAULT ('glc_' || encode(gen_random_bytes(18), 'hex')),
ADD COLUMN client_secret_hash bytea,
ADD COLUMN redirect_uris text[] NOT NULL DEFAULT '{}';
CREATE UNIQUE INDEX users_email_unique_idx ON users (lower(email));
CREATE TABLE user_application_access (
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (application_id, user_id)
);
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT a.id, m.user_id, m.user_id
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
ON CONFLICT DO NOTHING;
CREATE TABLE client_authorization_codes (
code_hash bytea PRIMARY KEY,
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redirect_uri text NOT NULL,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE client_user_sessions (
token_hash bytea PRIMARY KEY,
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
last_used_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX client_user_sessions_user_idx ON client_user_sessions(user_id);
CREATE INDEX client_authorization_codes_expiry_idx ON client_authorization_codes(expires_at);
-- +goose Down
DROP TABLE IF EXISTS client_user_sessions;
DROP TABLE IF EXISTS client_authorization_codes;
DROP TABLE IF EXISTS user_application_access;
DROP INDEX IF EXISTS users_email_unique_idx;
ALTER TABLE applications
DROP COLUMN IF EXISTS redirect_uris,
DROP COLUMN IF EXISTS client_secret_hash,
DROP COLUMN IF EXISTS client_id;

View file

@ -0,0 +1,23 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN password_hash text,
ADD COLUMN password_changed_at timestamptz;
CREATE TABLE user_login_events (
id bigserial PRIMARY KEY,
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
email text NOT NULL,
remote_address text NOT NULL,
successful boolean NOT NULL,
reason text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX user_login_events_occurred_idx ON user_login_events(occurred_at DESC);
CREATE INDEX user_login_events_email_idx ON user_login_events(lower(email), occurred_at DESC);
-- +goose Down
DROP TABLE IF EXISTS user_login_events;
ALTER TABLE users
DROP COLUMN IF EXISTS password_changed_at,
DROP COLUMN IF EXISTS password_hash;

View file

@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE installation_settings (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
installed_at timestamptz NOT NULL DEFAULT now(),
oidc_issuer text NOT NULL,
oidc_client_id text NOT NULL,
oidc_client_secret_ciphertext bytea NOT NULL,
seeded_demo boolean NOT NULL DEFAULT false
);
CREATE TABLE system_administrators (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS system_administrators;
DROP TABLE IF EXISTS installation_settings;

38
internal/key/key.go Normal file
View file

@ -0,0 +1,38 @@
package key
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
)
const Prefix = "gl_"
func Generate() (plain, displayPrefix string, hash []byte, err error) {
return GenerateToken(Prefix)
}
func GenerateToken(prefix string) (plain, displayPrefix string, hash []byte, err error) {
random := make([]byte, 32)
if _, err = rand.Read(random); err != nil {
return "", "", nil, err
}
plain = prefix + base64.RawURLEncoding.EncodeToString(random)
displayPrefix = plain[:min(len(plain), 11)]
hash = Hash(plain)
return plain, displayPrefix, hash, nil
}
func Hash(plain string) []byte {
sum := sha256.Sum256([]byte(plain))
return sum[:]
}
func ValidateFormat(plain string) error {
if !strings.HasPrefix(plain, Prefix) || len(plain) < 40 {
return errors.New("invalid API key format")
}
return nil
}

22
internal/key/key_test.go Normal file
View file

@ -0,0 +1,22 @@
package key
import (
"bytes"
"testing"
)
func TestGenerate(t *testing.T) {
plain, prefix, hash, err := Generate()
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if err := ValidateFormat(plain); err != nil {
t.Fatalf("generated key is invalid: %v", err)
}
if prefix != plain[:11] {
t.Fatalf("prefix = %q", prefix)
}
if !bytes.Equal(hash, Hash(plain)) {
t.Fatal("hash mismatch")
}
}

View file

@ -0,0 +1,90 @@
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
const (
memory = 64 * 1024
iterations = 3
parallelism = 2
saltLength = 16
keyLength = 32
)
func Hash(value string) (string, error) {
if len(value) < 12 {
return "", errors.New("password must contain at least 12 characters")
}
return hash(value)
}
// HashDemo hashes intentionally weak, local-only seed credentials.
func HashDemo(value string) (string, error) {
if value == "" {
return "", errors.New("demo password is empty")
}
return hash(value)
}
func hash(value string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
hash := argon2.IDKey([]byte(value), salt, iterations, memory, parallelism, keyLength)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
memory, iterations, parallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
func Verify(encoded, value string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
return false
}
var parsedMemory uint64
var parsedIterations uint64
var parsedParallelism uint64
for _, setting := range strings.Split(parts[3], ",") {
keyValue := strings.SplitN(setting, "=", 2)
if len(keyValue) != 2 {
return false
}
number, err := strconv.ParseUint(keyValue[1], 10, 32)
if err != nil {
return false
}
switch keyValue[0] {
case "m":
parsedMemory = number
case "t":
parsedIterations = number
case "p":
parsedParallelism = number
}
}
if parsedMemory == 0 || parsedMemory > 256*1024 || parsedIterations == 0 || parsedIterations > 10 || parsedParallelism == 0 || parsedParallelism > 8 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(salt) < 8 {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(expected) < 16 {
return false
}
actual := argon2.IDKey([]byte(value), salt, uint32(parsedIterations), uint32(parsedMemory), uint8(parsedParallelism), uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}

View file

@ -0,0 +1,22 @@
package password
import "testing"
func TestHashAndVerify(t *testing.T) {
encoded, err := Hash("correct horse battery staple")
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
if !Verify(encoded, "correct horse battery staple") {
t.Fatal("expected password to verify")
}
if Verify(encoded, "incorrect password") {
t.Fatal("expected incorrect password to fail")
}
}
func TestHashRejectsShortPassword(t *testing.T) {
if _, err := Hash("too-short"); err == nil {
t.Fatal("expected short password to fail")
}
}

View file

@ -0,0 +1,51 @@
package secretbox
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
)
func Encrypt(master, plaintext string) ([]byte, error) {
block, err := aes.NewCipher(deriveKey(master))
if err != nil {
return nil, fmt.Errorf("create secret cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create secret AEAD: %w", err)
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generate secret nonce: %w", err)
}
return aead.Seal(nonce, nonce, []byte(plaintext), []byte("go-loose-oidc-v1")), nil
}
func Decrypt(master string, ciphertext []byte) (string, error) {
block, err := aes.NewCipher(deriveKey(master))
if err != nil {
return "", fmt.Errorf("create secret cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create secret AEAD: %w", err)
}
if len(ciphertext) < aead.NonceSize() {
return "", errors.New("encrypted secret is truncated")
}
nonce, sealed := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]
plaintext, err := aead.Open(nil, nonce, sealed, []byte("go-loose-oidc-v1"))
if err != nil {
return "", errors.New("encrypted secret cannot be opened")
}
return string(plaintext), nil
}
func deriveKey(master string) []byte {
sum := sha256.Sum256([]byte("go-loose/oidc/" + master))
return sum[:]
}

View file

@ -0,0 +1,20 @@
package secretbox
import "testing"
func TestRoundTrip(t *testing.T) {
ciphertext, err := Encrypt("01234567890123456789012345678901", "client-secret")
if err != nil {
t.Fatalf("Encrypt() error = %v", err)
}
plaintext, err := Decrypt("01234567890123456789012345678901", ciphertext)
if err != nil {
t.Fatalf("Decrypt() error = %v", err)
}
if plaintext != "client-secret" {
t.Fatalf("plaintext = %q", plaintext)
}
if _, err := Decrypt("different-master-secret-value-0000", ciphertext); err == nil {
t.Fatal("expected wrong key to fail")
}
}

View file

@ -0,0 +1,143 @@
package server
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/store"
)
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",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
})
}
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
}
writeJSON(w, http.StatusOK, user)
}
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 "))
}

View file

@ -0,0 +1,133 @@
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"
)
const googleDemoClientID = "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com"
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
}
csrfToken, err := s.installCSRFToken(w)
if err != nil {
http.Error(w, "could not prepare installer", http.StatusInternalServerError)
return
}
page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{GOOGLE_CLIENT_ID}}", googleDemoClientID,
"{{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
}
clientID := strings.TrimSpace(r.Form.Get("oidc_client_id"))
clientSecret := strings.TrimSpace(r.Form.Get("oidc_client_secret"))
if clientID == "" || clientSecret == "" {
writeError(w, http.StatusBadRequest, "Google client ID and client secret are required")
return
}
if err := s.configureOIDC(r.Context(), "https://accounts.google.com", clientID, clientSecret); err != nil {
writeError(w, http.StatusBadGateway, "Google OIDC discovery failed")
return
}
ciphertext, err := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if err != nil {
s.internalError(w, "encrypt OIDC secret", err)
return
}
seedDemo := r.Form.Get("seed_demo") == "on"
var demoUsers []store.DemoUser
if seedDemo {
demoUsers, err = buildDemoUsers()
if err != nil {
s.internalError(w, "hash demo credentials", err)
return
}
}
err = s.store.Install(r.Context(), store.Installation{
OIDCIssuer: "https://accounts.google.com", OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, SeededDemo: seedDemo,
}, demoUsers)
if err != nil {
s.internalError(w, "complete installation", err)
return
}
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
}{
{"interview@nmbs.auth.local", "NMBS Interview", "admin123", "nmbs"},
{"interview@ypto.auth.local", "YPTO Interview", "admin123", "ypto"},
{"reviewer@nmbs.auth.local", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"},
{"reviewer@ypto.auth.local", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"},
}
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
}
func (s *Server) installCSRFToken(w http.ResponseWriter) (string, error) {
state, err := s.sessions.NewState()
if err != nil {
return "", err
}
token := s.sessions.SignedState("install|" + state)
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,
})
return token, nil
}

63
internal/server/oidc.go Normal file
View file

@ -0,0 +1,63 @@
package server
import (
"context"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/danyel/go-loose/internal/password"
"golang.org/x/oauth2"
)
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret string) error {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return fmt.Errorf("discover OIDC provider: %w", err)
}
config := &oauth2.Config{
ClientID: clientID, ClientSecret: clientSecret,
Endpoint: provider.Endpoint(), RedirectURL: s.cfg.OIDCRedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
s.authMu.Lock()
s.oauth = config
s.verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
s.authMu.Unlock()
return nil
}
func (s *Server) oidcConfiguration() (*oauth2.Config, *oidc.IDTokenVerifier) {
s.authMu.RLock()
defer s.authMu.RUnlock()
return s.oauth, s.verifier
}
func (s *Server) hasOIDC() bool {
config, verifier := s.oidcConfiguration()
return config != nil && verifier != nil
}
func (s *Server) ensureLocalAdministrator(ctx context.Context) error {
if !s.cfg.LocalLogin {
return nil
}
dummyHash, err := password.Hash("not-a-real-account-password")
if err != nil {
return fmt.Errorf("hash password timing value: %w", err)
}
s.loginGuard.dummyHash = dummyHash
if s.cfg.LocalAdminPassword == "" {
return nil
}
passwordHash, err := password.Hash(s.cfg.LocalAdminPassword)
if err != nil {
return fmt.Errorf("hash local administrator password: %w", err)
}
if err := s.store.EnsurePasswordUser(
ctx, s.cfg.LocalAdminEmail, "Local Administrator", passwordHash,
s.cfg.BootstrapTenant, s.cfg.BootstrapApp,
); err != nil {
return fmt.Errorf("bootstrap local administrator: %w", err)
}
return nil
}

View file

@ -0,0 +1,22 @@
package server
import (
"net/http/httptest"
"testing"
"github.com/danyel/go-loose/internal/config"
)
func TestOAuthStateCookieDomain(t *testing.T) {
server := &Server{cfg: config.Config{AuthDomain: "auth.local"}}
tenantRequest := httptest.NewRequest("GET", "http://nmbs.auth.local/auth/start", nil)
if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.local" {
t.Fatalf("tenant cookie domain = %q, want auth.local", domain)
}
localRequest := httptest.NewRequest("GET", "http://localhost:8080/auth/start", nil)
if domain := server.oauthStateCookieDomain(localRequest); domain != "" {
t.Fatalf("local cookie domain = %q, want host-only cookie", domain)
}
}

View file

@ -0,0 +1,137 @@
package server
import (
"crypto/subtle"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/danyel/go-loose/internal/password"
)
type loginAttempt struct {
failures int
windowStart time.Time
lockedUntil time.Time
}
type loginGuard struct {
mu sync.Mutex
attempts map[string]loginAttempt
dummyHash string
}
func newLoginGuard() *loginGuard {
return &loginGuard{attempts: make(map[string]loginAttempt)}
}
func (g *loginGuard) allowed(key string, now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
attempt := g.attempts[key]
return !now.Before(attempt.lockedUntil)
}
func (g *loginGuard) fail(key string, now time.Time) {
g.mu.Lock()
defer g.mu.Unlock()
attempt := g.attempts[key]
if attempt.windowStart.IsZero() || now.Sub(attempt.windowStart) > 10*time.Minute {
attempt = loginAttempt{windowStart: now}
}
attempt.failures++
if attempt.failures >= 5 {
attempt.lockedUntil = now.Add(15 * time.Minute)
}
g.attempts[key] = attempt
if len(g.attempts) > 10_000 {
for itemKey, item := range g.attempts {
if now.Sub(item.windowStart) > 30*time.Minute && now.After(item.lockedUntil) {
delete(g.attempts, itemKey)
}
}
}
}
func (g *loginGuard) success(key string) {
g.mu.Lock()
delete(g.attempts, key)
g.mu.Unlock()
}
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
if !s.installed.Load() {
writeError(w, http.StatusServiceUnavailable, "Go Loose is not installed")
return
}
if !s.cfg.LocalLogin {
http.NotFound(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid login request")
return
}
csrfCookie, cookieErr := r.Cookie("go_loose_login_csrf")
csrfValue := r.FormValue("csrf_token")
csrfPayload, csrfValid := s.sessions.VerifySigned(csrfValue)
if cookieErr != nil || subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfValue)) != 1 ||
!csrfValid || !strings.HasPrefix(csrfPayload, "login|") {
writeError(w, http.StatusForbidden, "invalid login form; reload the login page")
return
}
email := strings.ToLower(strings.TrimSpace(r.Form.Get("email")))
passwordValue := r.Form.Get("password")
target := "/"
if requested := r.Form.Get("return"); requested != "" {
validated, err := s.validateLoginReturn(r.Context(), requested)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login return URL")
return
}
target = validated
}
remoteAddress := remoteIP(r.RemoteAddr)
guardKey := remoteAddress + "|" + email
now := time.Now()
if !s.loginGuard.allowed(guardKey, now) {
s.recordPasswordLogin(r, nil, email, remoteAddress, false, "rate_limited")
writeError(w, http.StatusTooManyRequests, "too many login attempts; try again later")
return
}
user, encoded, err := s.store.PasswordUser(r.Context(), email)
valid := err == nil && password.Verify(encoded, passwordValue)
if err != nil {
password.Verify(s.loginGuard.dummyHash, passwordValue)
}
if !valid {
s.loginGuard.fail(guardKey, now)
var userID *string
if user.ID != "" {
userID = &user.ID
}
s.recordPasswordLogin(r, userID, email, remoteAddress, false, "invalid_credentials")
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
s.loginGuard.success(guardKey)
s.recordPasswordLogin(r, &user.ID, user.Email, remoteAddress, true, "authenticated")
s.finishLogin(w, r, "local:"+user.Email, user.Email, user.DisplayName, target, false)
}
func (s *Server) recordPasswordLogin(r *http.Request, userID *string, email, remoteAddress string, successful bool, reason string) {
if err := s.store.RecordLogin(r.Context(), userID, email, remoteAddress, successful, reason); err != nil {
s.logger.Error("record password login", "error", err)
}
}
func remoteIP(address string) string {
host, _, err := net.SplitHostPort(address)
if err == nil {
return host
}
return address
}

View file

@ -0,0 +1,24 @@
package server
import (
"testing"
"time"
)
func TestLoginGuardLocksAfterFiveFailures(t *testing.T) {
guard := newLoginGuard()
now := time.Now()
for range 5 {
if !guard.allowed("address|user@example.com", now) {
t.Fatal("guard locked too early")
}
guard.fail("address|user@example.com", now)
}
if guard.allowed("address|user@example.com", now) {
t.Fatal("guard did not lock after five failures")
}
guard.success("address|user@example.com")
if !guard.allowed("address|user@example.com", now) {
t.Fatal("successful login did not clear failures")
}
}

731
internal/server/server.go Normal file
View file

@ -0,0 +1,731 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"path"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
"golang.org/x/oauth2"
)
type Server struct {
cfg config.Config
store *store.Store
db *sql.DB
sessions *session.Manager
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
authMu sync.RWMutex
installed atomic.Bool
httpClient *http.Client
logger *slog.Logger
loginGuard *loginGuard
}
type contextKey string
const claimsKey contextKey = "claims"
func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger) (*Server, error) {
server := &Server{
cfg: cfg, store: store.New(db), db: db, logger: logger,
sessions: session.New(cfg.SessionSecret, strings.HasPrefix(cfg.BaseURL, "https://")),
loginGuard: newLoginGuard(),
}
server.httpClient = &http.Client{
Timeout: cfg.HTTPTimeout,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many contract source redirects")
}
_, err := contract.ValidateSourceURL(request.URL.String(), cfg.AllowPrivateContractURL)
return err
},
}
installation, err := server.store.Installation(ctx)
if err == nil {
secret, decryptErr := secretbox.Decrypt(cfg.SessionSecret, installation.OIDCSecretCiphertext)
if decryptErr != nil {
return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr)
}
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret); err != nil {
return nil, err
}
server.installed.Store(true)
if err := server.ensureLocalAdministrator(ctx); err != nil {
return nil, err
}
} else if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("load installation: %w", err)
} else if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret); err != nil {
return nil, err
}
server.installed.Store(true)
if err := server.ensureLocalAdministrator(ctx); err != nil {
return nil, err
}
} else if cfg.LocalLogin && cfg.LocalAdminPassword != "" {
server.installed.Store(true)
if err := server.ensureLocalAdministrator(ctx); err != nil {
return nil, err
}
}
return server, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /install", s.installPage)
mux.HandleFunc("POST /install", s.install)
mux.HandleFunc("GET /login", s.loginPage)
mux.HandleFunc("GET /auth/start", s.authStart)
mux.HandleFunc("GET /auth/callback", s.authCallback)
mux.HandleFunc("GET /auth/dev", s.authDev)
mux.HandleFunc("POST /auth/logout", s.authLogout)
mux.HandleFunc("POST /auth/password", s.passwordLogin)
mux.HandleFunc("GET /assets/{name}", s.asset)
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
mux.HandleFunc("POST /connect/token", s.clientToken)
mux.HandleFunc("GET /connect/userinfo", s.clientUserInfo)
mux.HandleFunc("POST /connect/logout", s.clientLogout)
mux.HandleFunc("GET /client/login", s.clientLoginPage)
mux.Handle("GET /", s.requireSession(http.HandlerFunc(s.index)))
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
mux.Handle("POST /api/v1/tenants", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createTenant))))
mux.Handle("POST /api/v1/applications", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createApplication))))
mux.Handle("POST /api/v1/keys", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createKey))))
mux.Handle("POST /api/v1/keys/{id}/revoke", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.revokeKey))))
mux.Handle("POST /api/v1/contracts", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createContract))))
mux.Handle("POST /api/v1/applications/{id}/client-config", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.configureClient))))
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
return s.recover(s.logRequests(s.securityHeaders(mux)))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "database unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if !s.installed.Load() {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
s.renderManagementLogin(w, r)
}
func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
s.renderClientLogin(w, r)
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.internalError(w, "check management access", err)
return
}
if !allowed {
s.serveEmbedded(w, "waiting-room.html")
return
}
s.serveEmbedded(w, "index.html")
}
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "docs.html")
}
func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "openapi.json")
}
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" {
http.NotFound(w, r)
return
}
s.serveEmbedded(w, name)
}
func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
content, err := web.Files.ReadFile(name)
if err != nil {
http.Error(w, "asset unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name)))
w.Write(content)
}
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
target := "/"
if requested := r.URL.Query().Get("return"); requested != "" {
validated, err := s.validateLoginReturn(r.Context(), requested)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login return URL")
return
}
target = validated
}
state, err := s.newLoginState(target)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start login")
return
}
oauthConfig, verifier := s.oidcConfiguration()
if oauthConfig == nil || verifier == nil {
if s.cfg.DevLogin {
http.Redirect(w, r, "/auth/dev?state="+url.QueryEscape(state), http.StatusSeeOther)
return
}
writeError(w, http.StatusServiceUnavailable, "OIDC is not configured")
return
}
http.SetCookie(w, &http.Cookie{
Name: "go_loose_oauth_state", Value: state, Path: "/auth/callback", Domain: s.oauthStateCookieDomain(r),
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
SameSite: http.SameSiteLaxMode, MaxAge: 600,
})
http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound)
}
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
stateCookie, cookieErr := r.Cookie("go_loose_oauth_state")
if cookieErr != nil || stateCookie.Value != r.URL.Query().Get("state") {
writeError(w, http.StatusBadRequest, "invalid OIDC state")
return
}
http.SetCookie(w, &http.Cookie{
Name: "go_loose_oauth_state", Value: "", Path: "/auth/callback", Domain: s.oauthStateCookieDomain(r),
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"), MaxAge: -1,
})
target, err := s.loginTargetFromState(r.URL.Query().Get("state"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid OIDC state")
return
}
oauthConfig, verifier := s.oidcConfiguration()
if oauthConfig == nil || verifier == nil {
writeError(w, http.StatusServiceUnavailable, "OIDC is not configured")
return
}
oauthToken, err := oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
writeError(w, http.StatusUnauthorized, "OIDC code exchange failed")
return
}
rawIDToken, ok := oauthToken.Extra("id_token").(string)
if !ok {
writeError(w, http.StatusUnauthorized, "identity provider did not return an ID token")
return
}
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid ID token")
return
}
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil || claims.Subject == "" || claims.Email == "" {
writeError(w, http.StatusUnauthorized, "identity claims are incomplete")
return
}
if !s.emailAllowed(claims.Email) {
writeError(w, http.StatusForbidden, "email domain is not allowed")
return
}
if claims.Name == "" {
claims.Name = claims.Email
}
s.finishLogin(w, r, claims.Subject, claims.Email, claims.Name, target, true)
}
func (s *Server) authDev(w http.ResponseWriter, r *http.Request) {
if !s.cfg.DevLogin {
http.NotFound(w, r)
return
}
target, err := s.loginTargetFromState(r.URL.Query().Get("state"))
if r.URL.Query().Get("state") == "" {
target, err = "/", nil
}
if err != nil {
writeError(w, http.StatusBadRequest, "invalid development login state")
return
}
s.finishLogin(w, r, "dev:"+s.cfg.DevUser, s.cfg.DevUser, "Local Administrator", target, false)
}
func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request, subject, email, name, target string, claimSystemAdministrator bool) {
user, err := s.store.UpsertUser(r.Context(), subject, email, name)
if err != nil {
s.logger.Error("upsert user", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if claimSystemAdministrator {
if _, err := s.store.ClaimFirstSystemAdministrator(r.Context(), user.ID); err != nil {
s.logger.Error("claim first system administrator", "error", err)
writeError(w, http.StatusInternalServerError, "login setup failed")
return
}
}
claims := session.Claims{
UserID: user.ID, Email: user.Email, DisplayName: user.DisplayName, Management: target == "/",
}
var sessionErr error
if target == "/" {
sessionErr = s.sessions.Set(w, claims)
} else {
sessionErr = s.sessions.SetShared(w, claims, s.cfg.AuthDomain)
}
if sessionErr != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
s.sessions.Clear(w)
s.sessions.ClearShared(w, s.cfg.AuthDomain)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "waiting for administrator approval")
return
}
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list tenants", err)
return
}
applications, err := s.store.ListApplications(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list applications", err)
return
}
keys, err := s.store.ListAPIKeys(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list API keys", err)
return
}
contracts, err := s.store.ListContracts(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list contracts", err)
return
}
users, err := s.store.ListManagedUsers(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list users", err)
return
}
pendingUsers, err := s.store.ListPendingUsers(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list pending users", err)
return
}
isSystemAdministrator, err := s.store.IsSystemAdministrator(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check system administrator", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
})
}
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
isSystemAdministrator, err := s.store.IsSystemAdministrator(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.internalError(w, "check system administrator", err)
return
}
if !isSystemAdministrator {
writeError(w, http.StatusForbidden, "system administrator access required")
return
}
var request struct{ Slug, Name string }
if !decodeJSON(w, r, &request) {
return
}
request.Slug, request.Name = strings.TrimSpace(request.Slug), strings.TrimSpace(request.Name)
if !validSlug(request.Slug) || request.Name == "" {
writeError(w, http.StatusBadRequest, "name and a valid lowercase slug are required")
return
}
item, err := s.store.CreateTenant(r.Context(), claimsFrom(r).UserID, request.Slug, request.Name)
if err != nil {
s.internalError(w, "create tenant", err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) createApplication(w http.ResponseWriter, r *http.Request) {
var request store.Application
if !decodeJSON(w, r, &request) {
return
}
request.Name, request.Slug = strings.TrimSpace(request.Name), strings.TrimSpace(request.Slug)
if request.TenantID == "" || request.Name == "" || !validSlug(request.Slug) {
writeError(w, http.StatusBadRequest, "tenant, name, and a valid lowercase slug are required")
return
}
for i := range request.AllowedHosts {
request.AllowedHosts[i] = strings.ToLower(strings.TrimSpace(request.AllowedHosts[i]))
if strings.ContainsAny(request.AllowedHosts[i], "/:") {
writeError(w, http.StatusBadRequest, "allowed hosts must be hostnames without schemes or ports")
return
}
}
item, err := s.store.CreateApplication(r.Context(), claimsFrom(r).UserID, request)
if err != nil {
s.handleStoreError(w, "create application", err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) createKey(w http.ResponseWriter, r *http.Request) {
var request struct {
ApplicationID string `json:"application_id"`
Name string `json:"name"`
ExpiresAt *time.Time `json:"expires_at"`
}
if !decodeJSON(w, r, &request) {
return
}
request.Name = strings.TrimSpace(request.Name)
if request.ApplicationID == "" || request.Name == "" || (request.ExpiresAt != nil && !request.ExpiresAt.After(time.Now())) {
writeError(w, http.StatusBadRequest, "application, name, and a future expiration are required")
return
}
plain, prefix, hash, err := key.Generate()
if err != nil {
s.internalError(w, "generate API key", err)
return
}
item, err := s.store.CreateAPIKey(r.Context(), claimsFrom(r).UserID, request.ApplicationID, request.Name, prefix, hash, request.ExpiresAt)
if err != nil {
s.handleStoreError(w, "create API key", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"key": item, "secret": plain})
}
func (s *Server) revokeKey(w http.ResponseWriter, r *http.Request) {
if err := s.store.RevokeAPIKey(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil {
s.handleStoreError(w, "revoke API key", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
plain := strings.TrimSpace(r.Header.Get("X-API-Key"))
if plain == "" {
authorization := r.Header.Get("Authorization")
if strings.HasPrefix(authorization, "Bearer ") {
plain = strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
}
}
if key.ValidateFormat(plain) != nil {
writeError(w, http.StatusUnauthorized, "a valid X-API-Key header is required")
return
}
var request struct {
Tenant string `json:"tenant"`
Application string `json:"application"`
Method string `json:"method"`
Path string `json:"path"`
Host string `json:"host"`
}
if !decodeJSON(w, r, &request) {
return
}
request.Method = strings.ToUpper(strings.TrimSpace(request.Method))
request.Host = strings.ToLower(strings.TrimSpace(request.Host))
if request.Application == "" || request.Method == "" || !strings.HasPrefix(request.Path, "/") {
writeError(w, http.StatusBadRequest, "application, method, and an absolute path are required")
return
}
result, err := s.store.Authorize(r.Context(), key.Hash(plain), request.Tenant, request.Application, request.Method, request.Path, request.Host)
if err != nil {
s.internalError(w, "authorize request", err)
return
}
if !result.Allowed {
writeJSON(w, http.StatusForbidden, result)
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) createContract(w http.ResponseWriter, r *http.Request) {
var request struct {
ApplicationID string `json:"application_id"`
SourceType string `json:"source_type"`
SourceURL string `json:"source_url"`
Document string `json:"document"`
}
if !decodeJSON(w, r, &request) {
return
}
var content []byte
var sourceURL *string
if request.SourceType == "url" {
parsed, err := contract.ValidateSourceURL(request.SourceURL, s.cfg.AllowPrivateContractURL)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
content, err = s.fetchContract(r.Context(), parsed)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
value := parsed.String()
sourceURL = &value
} else {
content = []byte(request.Document)
if request.SourceURL != "" {
sourceURL = &request.SourceURL
}
}
if len(content) == 0 {
writeError(w, http.StatusBadRequest, "an OpenAPI document or source URL is required")
return
}
document, err := contract.Parse(content)
if err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
item, err := s.store.SaveContract(r.Context(), claimsFrom(r).UserID, request.ApplicationID, document, sourceURL)
if err != nil {
s.handleStoreError(w, "save contract", err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) fetchContract(ctx context.Context, source *url.URL) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.String(), nil)
if err != nil {
return nil, errors.New("could not create contract request")
}
request.Header.Set("Accept", "application/json, application/yaml, text/yaml")
response, err := s.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("fetch contract: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("contract source returned HTTP %d", response.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(response.Body, 5<<20+1))
if err != nil {
return nil, fmt.Errorf("read contract: %w", err)
}
if len(content) > 5<<20 {
return nil, errors.New("contract exceeds 5 MiB")
}
return content, nil
}
func (s *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.installed.Load() {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
claims, err := s.sessions.Get(r)
if err != nil || !claims.Management {
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/openapi.json" {
writeError(w, http.StatusUnauthorized, "authentication required")
} else {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
})
}
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
sourceURL, sourceErr := url.Parse(source)
baseURL, baseErr := url.Parse(s.cfg.BaseURL)
if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme || sourceURL.Host != baseURL.Host {
writeError(w, http.StatusForbidden, "same-origin request required")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' 'unsafe-inline' https://unpkg.com; connect-src 'self'; img-src 'self' data:; font-src 'self' data:")
next.ServeHTTP(w, r)
})
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
s.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
})
}
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
s.logger.Error("panic recovered", "value", recovered)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) emailAllowed(email string) bool {
if len(s.cfg.AllowedEmailDomains) == 0 {
return true
}
parts := strings.Split(strings.ToLower(email), "@")
if len(parts) != 2 {
return false
}
for _, domain := range s.cfg.AllowedEmailDomains {
if parts[1] == domain {
return true
}
}
return false
}
func (s *Server) oauthStateCookieDomain(r *http.Request) string {
host := strings.ToLower(r.Host)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
if host == s.cfg.AuthDomain || strings.HasSuffix(host, "."+s.cfg.AuthDomain) {
return s.cfg.AuthDomain
}
return ""
}
func (s *Server) internalError(w http.ResponseWriter, operation string, err error) {
s.logger.Error(operation, "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
func (s *Server) handleStoreError(w http.ResponseWriter, operation string, err error) {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found or permission denied")
return
}
s.internalError(w, operation, err)
}
func claimsFrom(r *http.Request) session.Claims {
return r.Context().Value(claimsKey).(session.Claims)
}
func validSlug(value string) bool {
if len(value) < 2 || len(value) > 63 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' {
return false
}
}
return true
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON request")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "request must contain one JSON object")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
slog.Error("encode response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}

View file

@ -0,0 +1,151 @@
package server
import (
"context"
"encoding/base64"
"errors"
"html"
"net"
"net/http"
"net/url"
"strings"
"github.com/danyel/go-loose/web"
)
func (s *Server) tenantAuthHost(tenantSlug string) string {
host := tenantSlug + "." + s.cfg.AuthDomain
if parsed, err := url.Parse(s.cfg.BaseURL); err == nil && parsed.Port() != "" {
host = net.JoinHostPort(host, parsed.Port())
}
return host
}
func (s *Server) tenantAuthURL(tenantSlug, requestURI string) string {
base, _ := url.Parse(s.cfg.BaseURL)
return base.Scheme + "://" + s.tenantAuthHost(tenantSlug) + requestURI
}
func (s *Server) validateLoginReturn(ctx context.Context, raw string) (string, error) {
target, err := url.Parse(raw)
if err != nil || !target.IsAbs() || target.Path != "/connect/authorize" || target.Fragment != "" || target.User != nil {
return "", errors.New("invalid return URL")
}
base, err := url.Parse(s.cfg.BaseURL)
if err != nil || target.Scheme != base.Scheme {
return "", errors.New("invalid return scheme")
}
clientID, redirectURI := target.Query().Get("client_id"), target.Query().Get("redirect_uri")
app, err := s.store.ValidateClientRedirect(ctx, clientID, redirectURI)
if err != nil || !strings.EqualFold(target.Host, s.tenantAuthHost(app.TenantSlug)) {
return "", errors.New("return URL does not match client tenant")
}
return target.String(), nil
}
func (s *Server) newLoginState(target string) (string, error) {
nonce, err := s.sessions.NewState()
if err != nil {
return "", err
}
payload := nonce + "|" + base64.RawURLEncoding.EncodeToString([]byte(target))
return s.sessions.SignedState(payload), nil
}
func (s *Server) loginTargetFromState(state string) (string, error) {
payload, ok := s.sessions.VerifySigned(state)
if !ok {
return "", errors.New("invalid signed state")
}
parts := strings.SplitN(payload, "|", 2)
if len(parts) != 2 || parts[0] == "" {
return "", errors.New("invalid state payload")
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", errors.New("invalid state target")
}
target := string(decoded)
if target == "/" {
return target, nil
}
return s.validateLoginReturn(context.Background(), target)
}
func (s *Server) renderClientLogin(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
returnURL := r.URL.Query().Get("return")
app, err := s.store.ClientByID(r.Context(), clientID)
if err != nil || !strings.EqualFold(r.Host, s.tenantAuthHost(app.TenantSlug)) {
http.NotFound(w, r)
return
}
validatedReturn, err := s.validateLoginReturn(r.Context(), returnURL)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login request")
return
}
content, err := web.Files.ReadFile("client-login.html")
if err != nil {
http.Error(w, "login page unavailable", http.StatusInternalServerError)
return
}
loginURL := "/auth/start?return=" + url.QueryEscape(validatedReturn)
csrfToken, err := s.loginCSRFToken(w)
if err != nil {
http.Error(w, "could not prepare login", http.StatusInternalServerError)
return
}
page := strings.NewReplacer(
"{{TENANT_NAME}}", html.EscapeString(app.TenantName),
"{{APPLICATION_NAME}}", html.EscapeString(app.Name),
"{{LOGIN_URL}}", html.EscapeString(loginURL),
"{{RETURN_URL}}", html.EscapeString(validatedReturn),
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))
}
func (s *Server) renderManagementLogin(w http.ResponseWriter, _ *http.Request) {
content, err := web.Files.ReadFile("login.html")
if err != nil {
http.Error(w, "login page unavailable", http.StatusInternalServerError)
return
}
csrfToken, err := s.loginCSRFToken(w)
if err != nil {
http.Error(w, "could not prepare login", http.StatusInternalServerError)
return
}
page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))
}
func (s *Server) loginCSRFToken(w http.ResponseWriter) (string, error) {
state, err := s.sessions.NewState()
if err != nil {
return "", err
}
token := s.sessions.SignedState("login|" + state)
http.SetCookie(w, &http.Cookie{
Name: "go_loose_login_csrf", Value: token, Path: "/auth/password",
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
SameSite: http.SameSiteStrictMode, MaxAge: 600,
})
return token, nil
}
func hiddenStyle(hidden bool) string {
if hidden {
return "display:none"
}
return ""
}

View file

@ -0,0 +1,136 @@
package server
import (
"net/http"
"net/mail"
"net/url"
"strings"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/password"
)
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
var request struct {
RedirectURIs []string `json:"redirect_uris"`
}
if !decodeJSON(w, r, &request) {
return
}
if len(request.RedirectURIs) == 0 {
writeError(w, http.StatusBadRequest, "at least one redirect URI is required")
return
}
seen := make(map[string]bool)
for index, raw := range request.RedirectURIs {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Fragment != "" || parsed.User != nil {
writeError(w, http.StatusBadRequest, "redirect URIs must be absolute URLs without fragments or credentials")
return
}
localHTTP := (s.cfg.DevLogin || s.cfg.LocalLogin) && parsed.Scheme == "http" &&
(parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
if parsed.Scheme != "https" && !localHTTP {
writeError(w, http.StatusBadRequest, "redirect URIs require HTTPS except localhost in development")
return
}
request.RedirectURIs[index] = parsed.String()
if seen[parsed.String()] {
writeError(w, http.StatusBadRequest, "redirect URIs must be unique")
return
}
seen[parsed.String()] = true
}
secret, _, hash, err := key.GenerateToken("glc_secret_")
if err != nil {
s.internalError(w, "generate client secret", err)
return
}
app, err := s.store.ConfigureClient(r.Context(), claimsFrom(r).UserID, r.PathValue("id"), request.RedirectURIs, hash)
if err != nil {
s.handleStoreError(w, "configure client login", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"application": app, "client_secret": secret})
}
func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &request) {
return
}
address, err := mail.ParseAddress(strings.TrimSpace(request.Email))
if err != nil || address.Address != request.Email {
writeError(w, http.StatusBadRequest, "a valid plain email address is required")
return
}
request.Email = strings.ToLower(address.Address)
request.DisplayName = strings.TrimSpace(request.DisplayName)
if request.DisplayName == "" {
request.DisplayName = request.Email
}
if request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
return
}
passwordHash, err := password.Hash(request.Password)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
if err != nil {
s.handleStoreError(w, "invite user", err)
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
s.handleStoreError(w, "update user access", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
request.Role, request.ApplicationIDs,
); err != nil {
s.handleStoreError(w, "approve pending user", err)
return
}
w.WriteHeader(http.StatusNoContent)
}

125
internal/session/session.go Normal file
View file

@ -0,0 +1,125 @@
package session
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
const cookieName = "go_loose_session"
type Claims struct {
UserID string `json:"uid"`
Email string `json:"email"`
DisplayName string `json:"name"`
Management bool `json:"management"`
ExpiresAt int64 `json:"exp"`
}
type Manager struct {
secret []byte
secure bool
}
func New(secret string, secure bool) *Manager {
return &Manager{secret: []byte(secret), secure: secure}
}
func (m *Manager) Set(w http.ResponseWriter, claims Claims) error {
return m.set(w, claims, "")
}
func (m *Manager) SetShared(w http.ResponseWriter, claims Claims, domain string) error {
return m.set(w, claims, domain)
}
func (m *Manager) set(w http.ResponseWriter, claims Claims, domain string) error {
claims.ExpiresAt = time.Now().Add(12 * time.Hour).Unix()
payload, err := json.Marshal(claims)
if err != nil {
return err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
value := encoded + "." + m.sign(encoded)
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: value, Path: "/", HttpOnly: true, Secure: m.secure,
SameSite: http.SameSiteLaxMode, MaxAge: int((12 * time.Hour).Seconds()), Domain: domain,
})
return nil
}
func (m *Manager) Get(r *http.Request) (Claims, error) {
cookie, err := r.Cookie(cookieName)
if err != nil {
return Claims{}, err
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 || !hmac.Equal([]byte(parts[1]), []byte(m.sign(parts[0]))) {
return Claims{}, errors.New("invalid session signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Claims{}, errors.New("invalid session encoding")
}
var claims Claims
if err := json.Unmarshal(payload, &claims); err != nil {
return Claims{}, errors.New("invalid session payload")
}
if claims.UserID == "" || time.Now().Unix() >= claims.ExpiresAt {
return Claims{}, errors.New("session expired")
}
return claims, nil
}
func (m *Manager) Clear(w http.ResponseWriter) {
m.clear(w, "")
}
func (m *Manager) ClearShared(w http.ResponseWriter, domain string) {
m.clear(w, domain)
}
func (m *Manager) clear(w http.ResponseWriter, domain string) {
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: m.secure,
SameSite: http.SameSiteLaxMode, MaxAge: -1, Domain: domain,
})
}
func (m *Manager) NewState() (string, error) {
value := make([]byte, 24)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (m *Manager) SignedState(state string) string {
return state + "." + m.sign(state)
}
func (m *Manager) VerifyState(value string) bool {
_, ok := m.VerifySigned(value)
return ok
}
func (m *Manager) VerifySigned(value string) (string, bool) {
index := strings.LastIndexByte(value, '.')
if index <= 0 || index == len(value)-1 {
return "", false
}
payload, signature := value[:index], value[index+1:]
return payload, hmac.Equal([]byte(signature), []byte(m.sign(payload)))
}
func (m *Manager) sign(value string) string {
mac := hmac.New(sha256.New, m.secret)
mac.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View file

@ -0,0 +1,43 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSessionRoundTrip(t *testing.T) {
manager := New("01234567890123456789012345678901", false)
recorder := httptest.NewRecorder()
if err := manager.Set(recorder, Claims{UserID: "user-1", Email: "a@example.com"}); err != nil {
t.Fatalf("Set() error = %v", err)
}
request := httptest.NewRequest("GET", "/", nil)
request.AddCookie(recorder.Result().Cookies()[0])
claims, err := manager.Get(request)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if claims.UserID != "user-1" {
t.Fatalf("UserID = %q", claims.UserID)
}
}
func TestRejectsTamperedSession(t *testing.T) {
manager := New("01234567890123456789012345678901", false)
request := httptest.NewRequest("GET", "/", nil)
request.AddCookie(&http.Cookie{Name: cookieName, Value: "tampered.value"})
if _, err := manager.Get(request); err == nil {
t.Fatal("expected tampered session to fail")
}
}
func TestSignedValueAllowsDots(t *testing.T) {
manager := New("01234567890123456789012345678901", false)
value := "/connect/authorize?redirect_uri=https://app.example.test/callback"
signed := manager.SignedState(value)
got, ok := manager.VerifySigned(signed)
if !ok || got != value {
t.Fatalf("VerifySigned() = %q, %v", got, ok)
}
}

View file

@ -0,0 +1,299 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type ManagedUser struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"`
}
type ClientApplication struct {
ID string
TenantID string
TenantSlug string
TenantName string
Application string
Name string
ClientID string
}
type ClientUser struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
}
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
COALESCE((
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
FROM user_application_access ua
JOIN applications a ON a.id = ua.application_id
WHERE ua.user_id = u.id AND a.tenant_id = m.tenant_id
), '[]'::json)
FROM users u
JOIN memberships m ON m.user_id = u.id
WHERE EXISTS (
SELECT 1 FROM memberships own
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
AND own.role IN ('owner', 'admin')
)
ORDER BY u.email, m.tenant_id`, administratorID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ManagedUser, 0)
for rows.Next() {
var user ManagedUser
var applicationIDs []byte
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil {
return nil, err
}
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
return nil, err
}
result = append(result, user)
}
return result, rows.Err()
}
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ManagedUser{}, err
}
defer tx.Rollback()
var permitted bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
)`, tenantID, administratorID).Scan(&permitted); err != nil {
return ManagedUser{}, err
}
if !permitted {
return ManagedUser{}, ErrNotFound
}
var user ManagedUser
err = tx.QueryRowContext(ctx, `
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
VALUES ('invited:' || gen_random_uuid()::text, lower($1), $2, $3, now())
ON CONFLICT ((lower(email))) DO UPDATE SET
display_name = EXCLUDED.display_name,
password_hash = EXCLUDED.password_hash,
password_changed_at = now()
RETURNING id, email, display_name,
CASE WHEN subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END`,
email, displayName, passwordHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Status)
if err != nil {
return ManagedUser{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, user.ID, role); err != nil {
return ManagedUser{}, err
}
if err := tx.Commit(); err != nil {
return ManagedUser{}, err
}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
return user, nil
}
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
UPDATE memberships target SET role = $4
FROM memberships administrator
WHERE target.tenant_id = $1 AND target.user_id = $2
AND administrator.tenant_id = target.tenant_id
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`,
tenantID, userID, administratorID, role)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM user_application_access ua
USING applications a
WHERE ua.application_id = a.id AND a.tenant_id = $1 AND ua.user_id = $2`,
tenantID, userID); err != nil {
return err
}
for _, applicationID := range applicationIDs {
result, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $2, $3 FROM applications WHERE id = $1 AND tenant_id = $4
ON CONFLICT DO NOTHING`, applicationID, userID, administratorID, tenantID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
}
return tx.Commit()
}
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
var app Application
var encoded []byte
err := s.db.QueryRowContext(ctx, `
UPDATE applications a
SET redirect_uris = $3, client_secret_hash = $4, updated_at = now()
FROM memberships m
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
AND m.role IN ('owner', 'admin')
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
applicationID, administratorID, redirectURIs, secretHash).
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound
}
if err == nil {
err = json.Unmarshal(encoded, &app.RedirectURIs)
}
return app, err
}
func (s *Store) ClientForAuthorization(ctx context.Context, clientID, redirectURI, userID string) (ClientApplication, error) {
var app ClientApplication
err := s.db.QueryRowContext(ctx, `
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN user_application_access ua ON ua.application_id = a.id AND ua.user_id = $3
WHERE a.client_id = $1 AND $2 = ANY(a.redirect_uris)
AND a.client_secret_hash IS NOT NULL`,
clientID, redirectURI, userID).
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
if errors.Is(err, sql.ErrNoRows) {
return ClientApplication{}, ErrNotFound
}
return app, err
}
func (s *Store) ValidateClientRedirect(ctx context.Context, clientID, redirectURI string) (ClientApplication, error) {
var app ClientApplication
err := s.db.QueryRowContext(ctx, `
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
WHERE a.client_id = $1 AND $2 = ANY(a.redirect_uris)
AND a.client_secret_hash IS NOT NULL`,
clientID, redirectURI).
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
if errors.Is(err, sql.ErrNoRows) {
return ClientApplication{}, ErrNotFound
}
return app, err
}
func (s *Store) ClientByID(ctx context.Context, clientID string) (ClientApplication, error) {
var app ClientApplication
err := s.db.QueryRowContext(ctx, `
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
WHERE a.client_id = $1 AND a.client_secret_hash IS NOT NULL`,
clientID).
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
if errors.Is(err, sql.ErrNoRows) {
return ClientApplication{}, ErrNotFound
}
return app, err
}
func (s *Store) CreateAuthorizationCode(ctx context.Context, applicationID, userID, redirectURI string, codeHash []byte) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO client_authorization_codes(code_hash, application_id, user_id, redirect_uri, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '2 minutes')`,
codeHash, applicationID, userID, redirectURI)
return err
}
func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirectURI string, secretHash, codeHash, tokenHash []byte) (ClientUser, time.Time, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ClientUser{}, time.Time{}, err
}
defer tx.Rollback()
var user ClientUser
err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now()
FROM applications a, tenants t, users u
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
AND c.redirect_uri = $2 AND a.id = c.application_id
AND a.client_id = $3 AND a.client_secret_hash = $4
AND t.id = a.tenant_id AND u.id = c.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
codeHash, redirectURI, clientID, secretHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound
}
if err != nil {
return ClientUser{}, time.Time{}, err
}
expiresAt := time.Now().UTC().Add(12 * time.Hour)
if _, err := tx.ExecContext(ctx, `
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
VALUES ($1, $2, $3, $4)`, tokenHash, user.ApplicationID, user.ID, expiresAt); err != nil {
return ClientUser{}, time.Time{}, err
}
return user, expiresAt, tx.Commit()
}
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
var user ClientUser
err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now()
FROM applications a, tenants t, users u, user_application_access ua
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id
AND ua.application_id = a.id AND ua.user_id = u.id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound
}
return user, err
}
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {
result, err := s.db.ExecContext(ctx, `
UPDATE client_user_sessions SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
return nil
}

View file

@ -0,0 +1,207 @@
package store
import (
"context"
"database/sql"
"errors"
)
type Installation struct {
OIDCIssuer string
OIDCClientID string
OIDCSecretCiphertext []byte
SeededDemo bool
}
type DemoUser struct {
Email string
DisplayName string
PasswordHash string
TenantSlug string
}
func (s *Store) Installation(ctx context.Context) (Installation, error) {
var installation Installation
err := s.db.QueryRowContext(ctx, `
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo
FROM installation_settings WHERE singleton = true`).
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.SeededDemo)
if errors.Is(err, sql.ErrNoRows) {
return Installation{}, ErrNotFound
}
return installation, err
}
func (s *Store) Install(ctx context.Context, installation Installation, demoUsers []DemoUser) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo)
VALUES (true, $1, $2, $3, $4)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.SeededDemo); err != nil {
return err
}
if len(demoUsers) == 0 {
return tx.Commit()
}
for _, tenantSlug := range []string{"nmbs", "ypto"} {
if _, err := tx.ExecContext(ctx, `
INSERT INTO tenants(slug, name) VALUES ($1, upper($1))
ON CONFLICT (slug) DO NOTHING`, tenantSlug); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO applications(tenant_id, slug, name, description)
SELECT id, 'guess', 'Guess', 'Seeded Guess interview application'
FROM tenants WHERE slug = $1
ON CONFLICT (tenant_id, slug) DO NOTHING`, tenantSlug); err != nil {
return err
}
}
for _, demo := range demoUsers {
var userID string
if err := tx.QueryRowContext(ctx, `
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
VALUES ('demo:' || lower($1), lower($1), $2, $3, now())
ON CONFLICT ((lower(email))) DO UPDATE SET
display_name = EXCLUDED.display_name,
password_hash = EXCLUDED.password_hash,
password_changed_at = now()
RETURNING id`, demo.Email, demo.DisplayName, demo.PasswordHash).Scan(&userID); err != nil {
return err
}
var tenantID string
if err := tx.QueryRowContext(ctx, `SELECT id FROM tenants WHERE slug = $1`, demo.TenantSlug).Scan(&tenantID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $2, $2 FROM applications WHERE tenant_id = $1 AND slug = 'guess'
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(554628171917)`); err != nil {
return false, err
}
var exists bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM system_administrators)`).Scan(&exists); err != nil {
return false, err
}
if exists {
return false, tx.Commit()
}
if _, err := tx.ExecContext(ctx, `INSERT INTO system_administrators(user_id) VALUES ($1)`, userID); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
return true, tx.Commit()
}
func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed)
return allowed, err
}
func (s *Store) IsSystemAdministrator(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)`, userID).Scan(&allowed)
return allowed, err
}
func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([]User, error) {
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
if err != nil {
return nil, err
}
if !isAdministrator {
return []User{}, nil
}
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.email, u.display_name
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM memberships m WHERE m.user_id = u.id)
AND NOT EXISTS (SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id)
ORDER BY u.created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]User, 0)
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName); err != nil {
return nil, err
}
result = append(result, user)
}
return result, rows.Err()
}
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
if err != nil {
return err
}
if !isAdministrator {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT $1, id, $3 FROM users WHERE id = $2
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
for _, applicationID := range applicationIDs {
result, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $2, $3 FROM applications WHERE id = $1 AND tenant_id = $4
ON CONFLICT DO NOTHING`, applicationID, userID, administratorID, tenantID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
}
return tx.Commit()
}

View file

@ -0,0 +1,43 @@
package store
import (
"context"
"database/sql"
"errors"
)
func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error {
user, err := s.UpsertUser(ctx, "local:"+email, email, displayName)
if err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, `
UPDATE users
SET password_hash = $2, password_changed_at = now()
WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil {
return err
}
return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug)
}
func (s *Store) PasswordUser(ctx context.Context, email string) (User, string, error) {
var user User
var passwordHash sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, password_hash
FROM users
WHERE lower(email) = lower($1)`, email).
Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !passwordHash.Valid) {
return User{}, "", ErrNotFound
}
return user, passwordHash.String, err
}
func (s *Store) RecordLogin(ctx context.Context, userID *string, email, remoteAddress string, successful bool, reason string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO user_login_events(user_id, email, remote_address, successful, reason)
VALUES ($1, lower($2), $3, $4, $5)`,
userID, email, remoteAddress, successful, reason)
return err
}

416
internal/store/store.go Normal file
View file

@ -0,0 +1,416 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/danyel/go-loose/internal/contract"
)
var ErrNotFound = errors.New("not found")
type Store struct {
db *sql.DB
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
type Tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Role string `json:"role"`
}
type Application struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug,omitempty"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
AllowedHosts []string `json:"allowed_hosts"`
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
ClientReady bool `json:"client_ready"`
}
type APIKey struct {
ID string `json:"id"`
ApplicationID string `json:"application_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Contract struct {
ID string `json:"id"`
ApplicationID string `json:"application_id"`
Version string `json:"version"`
SourceURL *string `json:"source_url,omitempty"`
EndpointCount int `json:"endpoint_count"`
CreatedAt time.Time `json:"created_at"`
}
type Authorization struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
TenantSlug string `json:"tenant_slug,omitempty"`
AppID string `json:"application_id,omitempty"`
AppSlug string `json:"application_slug,omitempty"`
APIKeyID string `json:"api_key_id,omitempty"`
APIKeyName string `json:"api_key_name,omitempty"`
}
func New(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName string) (User, error) {
var user User
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return User{}, err
}
defer tx.Rollback()
err = tx.QueryRowContext(ctx, `
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
WHERE lower(email) = lower($2)
RETURNING id, email, display_name`, subject, email, displayName).
Scan(&user.ID, &user.Email, &user.DisplayName)
if errors.Is(err, sql.ErrNoRows) {
err = tx.QueryRowContext(ctx, `
INSERT INTO users(subject, email, display_name)
VALUES ($1, lower($2), $3)
RETURNING id, email, display_name`, subject, email, displayName).
Scan(&user.ID, &user.Email, &user.DisplayName)
}
if err != nil {
return User{}, err
}
return user, tx.Commit()
}
func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var tenantID string
err = tx.QueryRowContext(ctx, `
INSERT INTO tenants(slug, name) VALUES ($1, $2)
ON CONFLICT (slug) DO UPDATE SET name = tenants.name
RETURNING id`, tenantSlug, title(tenantSlug)).Scan(&tenantID)
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT $1, $2, 'owner'
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO applications(tenant_id, slug, name, description)
VALUES ($1, $2, $3, 'Bootstrap application')
ON CONFLICT (tenant_id, slug) DO NOTHING`, tenantID, appSlug, title(appSlug)); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT a.id, $2, $2
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id AND m.user_id = $2
WHERE a.tenant_id = $1 AND a.slug = $3
ON CONFLICT DO NOTHING`, tenantID, userID, appSlug); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT t.id, t.slug, t.name, m.role
FROM tenants t
JOIN memberships m ON m.tenant_id = t.id
WHERE m.user_id = $1
ORDER BY t.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Tenant, 0)
for rows.Next() {
var item Tenant
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Tenant, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Tenant{}, err
}
defer tx.Rollback()
var tenant Tenant
err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name).
Scan(&tenant.ID, &tenant.Slug, &tenant.Name)
if err != nil {
return Tenant{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
return Tenant{}, err
}
tenant.Role = "owner"
if err := tx.Commit(); err != nil {
return Tenant{}, err
}
return tenant, nil
}
func (s *Store) ListApplications(ctx context.Context, userID string) ([]Application, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT a.id, a.tenant_id, t.slug, a.slug, a.name, a.description,
to_json(a.allowed_hosts), a.client_id, to_json(a.redirect_uris),
(a.client_secret_hash IS NOT NULL)
FROM applications a
JOIN tenants t ON t.id = a.tenant_id
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE m.user_id = $1
ORDER BY t.slug, a.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Application, 0)
for rows.Next() {
var item Application
var allowedHosts, redirectURIs []byte
if err := rows.Scan(&item.ID, &item.TenantID, &item.TenantSlug, &item.Slug, &item.Name, &item.Description, &allowedHosts, &item.ClientID, &redirectURIs, &item.ClientReady); err != nil {
return nil, err
}
if err := json.Unmarshal(allowedHosts, &item.AllowedHosts); err != nil {
return nil, err
}
if err := json.Unmarshal(redirectURIs, &item.RedirectURIs); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) CreateApplication(ctx context.Context, userID string, app Application) (Application, error) {
row := s.db.QueryRowContext(ctx, `
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (
SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin')
)
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
client_id, to_json(redirect_uris), false`,
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID)
var allowedHosts, redirectURIs []byte
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound
}
if err == nil {
err = json.Unmarshal(allowedHosts, &app.AllowedHosts)
}
if err == nil {
err = json.Unmarshal(redirectURIs, &app.RedirectURIs)
}
return app, err
}
func (s *Store) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT k.id, k.application_id, k.name, k.prefix, k.status, k.expires_at, k.last_used_at, k.created_at
FROM api_keys k
JOIN memberships m ON m.tenant_id = k.tenant_id
WHERE m.user_id = $1
ORDER BY k.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]APIKey, 0)
for rows.Next() {
var item APIKey
if err := rows.Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, prefix string, hash []byte, expiresAt *time.Time) (APIKey, error) {
var item APIKey
err := s.db.QueryRowContext(ctx, `
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin')
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
applicationID, name, prefix, hash, expiresAt, userID).
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound
}
return item, err
}
func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
FROM memberships m
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`,
keyID, userID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) Authorize(ctx context.Context, hash []byte, tenantSlug, appSlug, method, path, host string) (Authorization, error) {
var auth Authorization
err := s.db.QueryRowContext(ctx, `
SELECT t.id, t.slug, a.id, a.slug, k.id, k.name,
(k.status = 'active' AND (k.expires_at IS NULL OR k.expires_at > now())
AND ($2 = '' OR t.slug = $2) AND ($3 = '' OR a.slug = $3)
AND (cardinality(a.allowed_hosts) = 0 OR $4 = ANY(a.allowed_hosts))) AS allowed
FROM api_keys k
JOIN applications a ON a.id = k.application_id
JOIN tenants t ON t.id = k.tenant_id
WHERE k.secret_hash = $1`,
hash, tenantSlug, appSlug, host).
Scan(&auth.TenantID, &auth.TenantSlug, &auth.AppID, &auth.AppSlug, &auth.APIKeyID, &auth.APIKeyName, &auth.Allowed)
if errors.Is(err, sql.ErrNoRows) {
auth.Reason = "invalid_api_key"
s.recordAuthorization(ctx, auth, method, path, host)
return auth, nil
}
if err != nil {
return Authorization{}, err
}
if auth.Allowed {
auth.Reason = "authorized"
_, _ = s.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = now() WHERE id = $1`, auth.APIKeyID)
} else {
auth.Reason = "key_scope_mismatch_or_inactive"
}
s.recordAuthorization(ctx, auth, method, path, host)
return auth, nil
}
func (s *Store) recordAuthorization(ctx context.Context, auth Authorization, method, path, host string) {
_, _ = s.db.ExecContext(ctx, `
INSERT INTO authorization_events(tenant_id, application_id, api_key_id, method, path, host, allowed, reason)
VALUES (NULLIF($1, '')::uuid, NULLIF($2, '')::uuid, NULLIF($3, '')::uuid, $4, $5, $6, $7, $8)`,
auth.TenantID, auth.AppID, auth.APIKeyID, method, path, host, auth.Allowed, auth.Reason)
}
func (s *Store) SaveContract(ctx context.Context, userID, applicationID string, document contract.Document, sourceURL *string) (Contract, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Contract{}, err
}
defer tx.Rollback()
var result Contract
err = tx.QueryRowContext(ctx, `
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
SELECT a.id, $2, $3, $4, $5
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin')
RETURNING id, application_id, version, source_url, created_at`,
applicationID, document.Version, sourceURL, document.JSON, userID).
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Contract{}, ErrNotFound
}
if err != nil {
return Contract{}, err
}
for _, endpoint := range document.Endpoints {
if _, err := tx.ExecContext(ctx, `
INSERT INTO contract_endpoints(contract_id, method, path, operation_id) VALUES ($1, $2, $3, $4)`,
result.ID, endpoint.Method, endpoint.Path, endpoint.OperationID); err != nil {
return Contract{}, err
}
}
result.EndpointCount = len(document.Endpoints)
if err := tx.Commit(); err != nil {
return Contract{}, err
}
return result, nil
}
func (s *Store) ListContracts(ctx context.Context, userID string) ([]Contract, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT c.id, c.application_id, c.version, c.source_url, count(e.id), c.created_at
FROM openapi_contracts c
JOIN applications a ON a.id = c.application_id
JOIN memberships m ON m.tenant_id = a.tenant_id
LEFT JOIN contract_endpoints e ON e.contract_id = c.id
WHERE m.user_id = $1
GROUP BY c.id
ORDER BY c.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Contract, 0)
for rows.Next() {
var item Contract
if err := rows.Scan(&item.ID, &item.ApplicationID, &item.Version, &item.SourceURL, &item.EndpointCount, &item.CreatedAt); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func title(slug string) string {
parts := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
for index := range parts {
if parts[index] != "" {
parts[index] = strings.ToUpper(parts[index][:1]) + parts[index][1:]
}
}
return strings.Join(parts, " ")
}
func Wrap(operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", operation, err)
}

240
web/app.js Normal file
View file

@ -0,0 +1,240 @@
const state = {tenants: [], applications: [], keys: [], contracts: [], users: [], pending_users: [], selectedTenant: ''};
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
const date = value => value ? new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value)) : 'Never';
const notice = (message, error = false) => {
const el = $('#notice');
el.textContent = message;
el.hidden = false;
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
setTimeout(() => el.hidden = true, 5000)
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin', ...options,
headers: {'Content-Type': 'application/json', ...(options.headers || {})}
});
if (response.status === 401) {
location.href = '/login';
throw new Error('Session expired')
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function load() {
const data = await api('/api/v1/dashboard');
Object.assign(state, data);
if (!state.tenants.some(tenant => tenant.id === state.selectedTenant)) state.selectedTenant = state.tenants[0]?.id || '';
$('#identity').textContent = data.user.display_name || data.user.email;
render();
}
function render() {
const tenantID = state.selectedTenant;
const selectedTenant = state.tenants.find(item => item.id === tenantID);
const canManageTenant = selectedTenant && ['owner', 'admin'].includes(selectedTenant.role);
const tenantApps = state.applications.filter(item => item.tenant_id === tenantID);
const tenantAppIDs = new Set(tenantApps.map(item => item.id));
const tenantKeys = state.keys.filter(item => tenantAppIDs.has(item.application_id));
const tenantContracts = state.contracts.filter(item => tenantAppIDs.has(item.application_id));
const tenantUsers = state.users.filter(item => item.tenant_id === tenantID);
const apps = new Map(state.applications.map(item => [item.id, item]));
$('#tenant-count').textContent = state.tenants.length;
$('#app-count').textContent = tenantApps.length;
$('#key-count').textContent = tenantKeys.length;
$('#contract-count').textContent = tenantContracts.length;
$('#user-count').textContent = tenantUsers.length + state.pending_users.length;
$('#tenant-context').innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>${escapeHTML(item.name)}</option>`).join('');
$('#new-tenant').hidden = !state.is_system_administrator;
$('#tenant-list').innerHTML = state.tenants.map(item => `<article class="card"><span class="badge">${escapeHTML(item.role)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.slug)}</p><div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div></article>`).join('') || empty('No tenants yet');
$('#app-list').innerHTML = tenantApps.map(item => `<article class="card"><span class="badge">${escapeHTML(item.tenant_slug)}</span><h3>${escapeHTML(item.name)}</h3><p>${escapeHTML(item.description || 'No description')}</p><div class="meta"><span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span><code>${escapeHTML(item.client_id)}</code></div>${canManageTenant ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Configure client login</button>` : ''}</article>`).join('') || empty('No applications yet');
$('#key-list').innerHTML = tenantKeys.map(item => `<tr><td>${escapeHTML(item.name)}</td><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td><code>${escapeHTML(item.prefix)}…</code></td><td><span class="badge">${escapeHTML(item.status)}</span></td><td>${date(item.last_used_at)}</td><td>${canManageTenant && item.status === 'active' ? `<button class="danger" data-revoke="${escapeHTML(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || rowEmpty(6, 'No keys issued');
$('#contract-list').innerHTML = tenantContracts.map(item => `<tr><td>${escapeHTML(apps.get(item.application_id)?.name || '—')}</td><td>${escapeHTML(item.version)}</td><td>${item.endpoint_count}</td><td>${item.source_url ? `<a href="${escapeHTML(item.source_url)}" target="_blank" rel="noreferrer">source</a>` : 'Pasted'}</td><td>${date(item.created_at)}</td></tr>`).join('') || rowEmpty(5, 'No contracts imported');
const pending = state.is_system_administrator ? state.pending_users.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>Waiting room</td><td>—</td><td><span class="badge">pending</span></td><td>0</td><td><button class="card-action" data-pending="${escapeHTML(item.id)}">Approve</button></td></tr>`).join('') : '';
$('#user-list').innerHTML = tenantUsers.map(item => `<tr><td><strong>${escapeHTML(item.display_name)}</strong><br><small>${escapeHTML(item.email)}</small></td><td>${escapeHTML(state.tenants.find(t => t.id === item.tenant_id)?.name || '—')}</td><td><span class="badge">${escapeHTML(item.role)}</span></td><td>${escapeHTML(item.status)}</td><td>${item.application_ids.length}</td><td>${canManageTenant ? `<button class="card-action" data-access="${escapeHTML(item.id)}" data-tenant="${escapeHTML(item.tenant_id)}">Manage</button>` : ''}</td></tr>`).join('') + pending || rowEmpty(6, 'No users');
$$('[data-tenant-action]').forEach(element => element.hidden = !canManageTenant);
$$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
select.value = tenantID;
});
$$('.app-select').forEach(select => select.innerHTML = tenantApps.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join(''));
}
const empty = text => `<article class="card"><p>${text}</p></article>`;
const rowEmpty = (span, text) => `<tr><td colspan="${span}">${text}</td></tr>`;
function formJSON(form) {
const data = Object.fromEntries(new FormData(form));
if (data.allowed_hosts !== undefined) data.allowed_hosts = data.allowed_hosts.split('\n').map(v => v.trim()).filter(Boolean);
if (!data.expires_at) delete data.expires_at; else data.expires_at = new Date(data.expires_at).toISOString();
return data;
}
$$('[data-dialog]').forEach(button => button.addEventListener('click', () => $('#' + button.dataset.dialog).showModal()));
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
$$('.tab').forEach(button => button.addEventListener('click', () => {
$$('.tab,.panel').forEach(el => el.classList.remove('active'));
button.classList.add('active');
$('#' + button.dataset.panel).classList.add('active')
}));
$('#tenant-form').addEventListener('submit', async event => submit(event, '/api/v1/tenants', 'Tenant created'));
$('#app-form').addEventListener('submit', async event => submit(event, '/api/v1/applications', 'Application registered'));
$('#key-form').addEventListener('submit', async event => {
event.preventDefault();
try {
const result = await api('/api/v1/keys', {method: 'POST', body: JSON.stringify(formJSON(event.target))});
event.target.closest('dialog').close();
event.target.reset();
showSecret('API key issued', 'This API key is shown once. Copy it now.', result.secret);
await load()
} catch (error) {
notice(error.message, true)
}
});
$('#contract-form').addEventListener('submit', async event => submit(event, '/api/v1/contracts', 'Contract imported'));
$('#user-form').addEventListener('submit', async event => submit(event, '/api/v1/users', 'User invited'));
$('#client-form').addEventListener('submit', async event => {
event.preventDefault();
const data = formJSON(event.target), id = data.application_id;
data.redirect_uris = data.redirect_uris.split('\n').map(v => v.trim()).filter(Boolean);
delete data.application_id;
delete data.client_id;
try {
const result = await api(`/api/v1/applications/${id}/client-config`, {
method: 'POST',
body: JSON.stringify(data)
});
event.target.closest('dialog').close();
showSecret('Client secret rotated', 'Update the client application now. This secret is shown once.', result.client_secret);
await load()
} catch (error) {
notice(error.message, true)
}
});
$('#access-form').addEventListener('submit', async event => {
event.preventDefault();
const form = event.target, data = formJSON(form);
data.application_ids = [...form.querySelectorAll('input[name=application_ids]:checked')].map(input => input.value);
const userID = data.user_id, approval = data.approval_mode === 'true';
delete data.user_id;
delete data.approval_mode;
if (approval) data.tenant_id = data.approval_tenant_id;
delete data.approval_tenant_id;
try {
await api(`/api/v1/users/${userID}/${approval ? 'approve' : 'access'}`, {
method: approval ? 'POST' : 'PUT', body: JSON.stringify(data)
});
form.closest('dialog').close();
notice(approval ? 'User approved' : 'User access updated');
await load()
} catch (error) {
notice(error.message, true)
}
});
async function submit(event, path, message) {
event.preventDefault();
try {
await api(path, {method: 'POST', body: JSON.stringify(formJSON(event.target))});
event.target.closest('dialog').close();
event.target.reset();
notice(message);
await load()
} catch (error) {
notice(error.message, true)
}
}
document.addEventListener('click', async event => {
const revoke = event.target.dataset.revoke;
if (revoke) {
if (!confirm('Revoke this key? Existing integrations will stop working.')) return;
try {
await api(`/api/v1/keys/${revoke}/revoke`, {method: 'POST'});
notice('Key revoked');
await load()
} catch (error) {
notice(error.message, true)
}
return
}
const clientID = event.target.dataset.client;
if (clientID) {
const app = state.applications.find(item => item.id === clientID), form = $('#client-form');
form.application_id.value = app.id;
form.client_id.value = app.client_id;
form.redirect_uris.value = app.redirect_uris.join('\n');
$('#client-dialog').showModal();
return
}
const userID = event.target.dataset.access;
if (userID) {
const user = state.users.find(item => item.id === userID && item.tenant_id === event.target.dataset.tenant),
form = $('#access-form');
form.user_id.value = user.id;
form.tenant_id.value = user.tenant_id;
form.approval_mode.value = 'false';
$('#approval-tenant-label').hidden = true;
form.role.value = user.role;
renderAccessApps(user.tenant_id, user.application_ids);
$('#access-dialog').showModal();
return
}
const pendingID = event.target.dataset.pending;
if (pendingID) {
const form = $('#access-form');
form.user_id.value = pendingID;
form.approval_mode.value = 'true';
$('#approval-tenant-label').hidden = false;
form.approval_tenant_id.value = state.selectedTenant;
form.role.value = 'viewer';
renderAccessApps(state.selectedTenant, []);
$('#access-dialog').showModal()
}
});
$('#copy-secret').addEventListener('click', () => navigator.clipboard.writeText($('#new-secret').textContent).then(() => notice('Secret copied')));
function showSecret(title, description, secret) {
$('#secret-title').textContent = title;
$('#secret-description').textContent = description;
$('#new-secret').textContent = secret;
$('#secret-dialog').showModal()
}
const theme = $('#theme'), mode = $('#mode');
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
document.documentElement.dataset.theme = theme.value;
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
theme.addEventListener('change', () => {
document.documentElement.dataset.theme = theme.value;
localStorage.setItem('gl-theme', theme.value)
});
mode.addEventListener('click', () => {
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.mode = next;
localStorage.setItem('gl-mode', next)
});
$('#tenant-context').addEventListener('change', event => {
state.selectedTenant = event.target.value;
render()
});
$('#access-form').approval_tenant_id.addEventListener('change', event => renderAccessApps(event.target.value, []));
function renderAccessApps(tenantID, selected) {
$('#access-apps').innerHTML = state.applications.filter(app => app.tenant_id === tenantID).map(app => `<label><input type="checkbox" name="application_ids" value="${escapeHTML(app.id)}" ${selected.includes(app.id) ? 'checked' : ''}><span>${escapeHTML(app.name)}</span></label>`).join('') || '<p>No applications</p>'
}
load().catch(error => notice(error.message, true));

27
web/client-login.html Normal file
View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{TENANT_NAME}} login · Go Loose</title><link rel="stylesheet" href="/assets/style.css"></head>
<body class="login-page">
<div class="login-glow"></div>
<main class="terminal client-terminal">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@identity-broker:~</strong></div>
<div class="terminal-body">
<p class="eyebrow">{{APPLICATION_NAME}} · SECURE ACCESS</p>
<h1>{{TENANT_NAME}} login.<br><span>Continue securely.</span></h1>
<p><span class="prompt">identity@{{TENANT_NAME}}:~$</span> verify access with your organization</p>
<p class="muted">Go Loose will return you to {{APPLICATION_NAME}} after your identity and application access are verified.</p>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<input type="hidden" name="return" value="{{RETURN_URL}}">
<label>Email<input type="email" name="email" autocomplete="username" required></label>
<label>Password<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">LOGIN TO {{APPLICATION_NAME}}</button>
</form>
<div style="{{SSO_STYLE}}"><p class="login-divider">or use your organization</p>
<a class="terminal-login" href="{{LOGIN_URL}}"><span>›</span> CONTINUE WITH SSO</a></div>
<p class="cursor-line"><span class="prompt">identity@go-loose:~$</span> <span class="cursor">█</span></p>
</div>
</main>
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>
</body>
</html>

23
web/docs.html Normal file
View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Go Loose · API documentation</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="docs-page">
<header><a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav><a href="/">← Dashboard</a></nav>
</header>
<div id="swagger-ui"></div>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>SwaggerUIBundle({
url: '/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: false
})</script>
</body>
</html>

8
web/embed.go Normal file
View file

@ -0,0 +1,8 @@
package web
import "embed"
// Files contains the dependency-free management console and API documentation shell.
//
//go:embed *.html *.css *.js *.json
var Files embed.FS

95
web/index.html Normal file
View file

@ -0,0 +1,95 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Go Loose · API access control</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="noise"></div>
<header>
<a class="brand" href="/"><span class="brand-mark">GL</span><span>Go Loose</span></a>
<nav>
<a href="/docs">API docs</a>
<label class="compact">Tenant<select id="tenant-context"></select></label>
<label class="compact">Theme
<select id="theme"><option value="tokyo">Tokyo Night</option><option value="catppuccin">Catppuccin</option><option value="tomorrow">Tomorrow</option><option value="kanagawa">Kanagawa</option></select>
</label>
<button id="mode" class="icon-button" title="Toggle light/dark mode">◐</button>
<span id="identity" class="identity"></span>
<form action="/auth/logout" method="post"><button class="ghost" type="submit">Log out</button></form>
</nav>
</header>
<main>
<section class="hero">
<div>
<p class="eyebrow">MULTI-TENANT API ACCESS CONTROL</p>
<h1>One key plane.<br><span>Every Go API.</span></h1>
<p class="lede">Register applications, issue scoped keys, import OpenAPI contracts, and authorize requests without coupling identity logic to every service.</p>
</div>
<div class="status-card">
<div class="pulse"></div>
<div><strong>Authorization plane</strong><span>Operational</span></div>
<code>POST /api/v1/authorize</code>
</div>
</section>
<div id="notice" class="notice" hidden></div>
<section class="stats" id="stats">
<article><span>Tenants</span><strong id="tenant-count">—</strong></article>
<article><span>Applications</span><strong id="app-count">—</strong></article>
<article><span>API keys</span><strong id="key-count">—</strong></article>
<article><span>Contracts</span><strong id="contract-count">—</strong></article>
<article><span>Users</span><strong id="user-count">—</strong></article>
</section>
<section class="workspace">
<aside>
<button class="tab active" data-panel="applications">01 <span>Applications</span></button>
<button class="tab" data-panel="keys">02 <span>API keys</span></button>
<button class="tab" data-panel="contracts">03 <span>Contracts</span></button>
<button class="tab" data-panel="tenants">04 <span>Tenants</span></button>
<button class="tab" data-panel="users">05 <span>Users</span></button>
</aside>
<div class="panels">
<section class="panel active" id="applications">
<div class="panel-head"><div><p class="eyebrow">SERVICE CATALOG</p><h2>Applications</h2></div><button data-dialog="app-dialog" data-tenant-action>New application</button></div>
<div class="card-grid" id="app-list"></div>
</section>
<section class="panel" id="keys">
<div class="panel-head"><div><p class="eyebrow">CREDENTIALS</p><h2>API keys</h2></div><button data-dialog="key-dialog" data-tenant-action>Issue key</button></div>
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Application</th><th>Prefix</th><th>Status</th><th>Last used</th><th></th></tr></thead><tbody id="key-list"></tbody></table></div>
</section>
<section class="panel" id="contracts">
<div class="panel-head"><div><p class="eyebrow">DISCOVERY</p><h2>OpenAPI contracts</h2></div><button data-dialog="contract-dialog" data-tenant-action>Import contract</button></div>
<div class="table-wrap"><table><thead><tr><th>Application</th><th>OpenAPI</th><th>Endpoints</th><th>Source</th><th>Imported</th></tr></thead><tbody id="contract-list"></tbody></table></div>
</section>
<section class="panel" id="tenants">
<div class="panel-head"><div><p class="eyebrow">ORGANIZATIONS</p><h2>Tenants</h2></div><button id="new-tenant" data-dialog="tenant-dialog">New tenant</button></div>
<div class="card-grid" id="tenant-list"></div>
</section>
<section class="panel" id="users">
<div class="panel-head"><div><p class="eyebrow">CLIENT IDENTITIES</p><h2>Users & access</h2></div><button data-dialog="user-dialog" data-tenant-action>Invite user</button></div>
<div class="table-wrap"><table><thead><tr><th>User</th><th>Tenant</th><th>Role</th><th>Status</th><th>Applications</th><th></th></tr></thead><tbody id="user-list"></tbody></table></div>
</section>
</div>
</section>
</main>
<dialog id="tenant-dialog"><form id="tenant-form"><div class="dialog-head"><h3>Create tenant</h3><button type="button" class="close">×</button></div><label>Name<input name="name" required></label><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="nmbs"></label><button type="submit">Create tenant</button></form></dialog>
<dialog id="app-dialog"><form id="app-form"><div class="dialog-head"><h3>Register application</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Name<input name="name" required></label><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}" placeholder="guess"></label><label>Description<textarea name="description"></textarea></label><label>Allowed hosts <small>(one per line, empty allows all)</small><textarea name="allowed_hosts" placeholder="nmbs.guess.local"></textarea></label><button type="submit">Register application</button></form></dialog>
<dialog id="key-dialog"><form id="key-form"><div class="dialog-head"><h3>Issue API key</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><label>Key name<input name="name" required placeholder="production-gateway"></label><label>Expires at <small>(optional)</small><input type="datetime-local" name="expires_at"></label><button type="submit">Issue key</button></form></dialog>
<dialog id="contract-dialog"><form id="contract-form"><div class="dialog-head"><h3>Import OpenAPI contract</h3><button type="button" class="close">×</button></div><label>Application<select name="application_id" class="app-select" required></select></label><div class="segmented"><label><input type="radio" name="source_type" value="document" checked> Paste</label><label><input type="radio" name="source_type" value="url"> Scan URL</label></div><label>Source URL <small>(optional when pasting)</small><input type="url" name="source_url" placeholder="https://service/openapi.json"></label><label>OpenAPI JSON or YAML<textarea name="document" rows="12" placeholder="openapi: 3.1.0&#10;paths: ..."></textarea></label><button type="submit">Import contract</button></form></dialog>
<dialog id="client-dialog"><form id="client-form"><div class="dialog-head"><h3>Configure client login</h3><button type="button" class="close">×</button></div><input type="hidden" name="application_id"><label>Client ID<input name="client_id" readonly></label><label>Redirect URIs <small>(one exact callback URL per line)</small><textarea name="redirect_uris" rows="5" placeholder="https://nmbs.guess.local/auth/callback" required></textarea></label><p class="muted">Saving rotates the client secret. Existing deployments must be updated immediately.</p><button type="submit">Save and rotate secret</button></form></dialog>
<dialog id="user-dialog"><form id="user-form"><div class="dialog-head"><h3>Invite tenant user</h3><button type="button" class="close">×</button></div><label>Tenant<select name="tenant_id" class="tenant-select" required></select></label><label>Email<input type="email" name="email" required></label><label>Display name<input name="display_name"></label><label>Initial password <small>(minimum 12 characters)</small><input type="password" name="password" minlength="12" autocomplete="new-password" required></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option></select></label><button type="submit">Invite user</button></form></dialog>
<dialog id="access-dialog"><form id="access-form"><div class="dialog-head"><h3>Manage user access</h3><button type="button" class="close">×</button></div><input type="hidden" name="user_id"><input type="hidden" name="tenant_id"><input type="hidden" name="approval_mode"><label id="approval-tenant-label" hidden>Tenant<select name="approval_tenant_id" class="tenant-select"></select></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="admin">Administrator</option><option value="owner">Owner</option></select></label><fieldset><legend>Applications</legend><div id="access-apps" class="check-list"></div></fieldset><button type="submit">Update access</button></form></dialog>
<dialog id="secret-dialog"><div class="dialog-head"><h3 id="secret-title">Secret issued</h3><button type="button" class="close">×</button></div><p id="secret-description">This secret is shown once. Copy it now.</p><div class="secret"><code id="new-secret"></code><button id="copy-secret" type="button">Copy</button></div></dialog>
<script src="/assets/app.js" defer></script>
</body>
</html>

25
web/install.html Normal file
View file

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Install Go Loose</title><link rel="stylesheet" href="/assets/style.css"></head>
<body class="login-page">
<div class="login-glow"></div>
<main class="terminal installer">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@installer:~</strong></div>
<div class="terminal-body">
<p class="eyebrow">FIRST-RUN INSTALLER</p>
<h1>Initialize<br><span>Go Loose.</span></h1>
<p class="muted">Configure Google identity and optionally create the Guess demonstration tenants. The client secret is encrypted before it reaches PostgreSQL.</p>
<form action="/install" method="post" class="installer-form">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Google OAuth client ID<input name="oidc_client_id" value="{{GOOGLE_CLIENT_ID}}" required></label>
<label>Google OAuth client secret<input type="password" name="oidc_client_secret" autocomplete="new-password" required></label>
<label>Authorized callback URL<input value="{{CALLBACK_URL}}" readonly></label>
<label class="seed-option"><input type="checkbox" name="seed_demo" checked><span>Seed NMBS and YPTO Guess demo data</span></label>
<button type="submit">INSTALL GO LOOSE</button>
</form>
<p class="muted">In Google Cloud Console, add the callback URL above under Authorized redirect URIs. After installation you will continue to Google, then return to Go Loose with a secure signed session.</p>
</div>
</main>
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>
</body>
</html>

30
web/login.html Normal file
View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Go Loose · Login</title><link rel="stylesheet" href="/assets/style.css"></head>
<body class="login-page">
<div class="login-glow"></div>
<main class="terminal">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@access-plane:~</strong></div>
<div class="terminal-body">
<pre class="ascii"> ██████╗ ██████╗ ██╗ ██████╗ ██████╗ ███████╗███████╗
██╔════╝ ██╔═══██╗ ██║ ██╔═══██╗██╔═══██╗██╔════╝██╔════╝
██║ ███╗██║ ██║ ██║ ██║ ██║██║ ██║███████╗█████╗
██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p>
<p class="muted">Sign in with a database account or continue through your organization's identity provider.</p>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Email<input type="email" name="email" autocomplete="username" required></label>
<label>Password<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">LOGIN</button>
</form>
<div style="{{SSO_STYLE}}"><p class="login-divider">or use your organization</p>
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p>
</div>
</main>
<script>const t=localStorage.getItem('gl-theme')||'tokyo',m=localStorage.getItem('gl-mode')||'dark';document.documentElement.dataset.theme=t;document.documentElement.dataset.mode=m</script>
</body>
</html>

240
web/openapi.json Normal file
View file

@ -0,0 +1,240 @@
{
"openapi": "3.1.0",
"info": {
"title": "Go Loose API",
"version": "0.1.0",
"description": "Multi-tenant API-key management and runtime authorization."
},
"servers": [{"url": "/"}],
"components": {
"securitySchemes": {
"session": {"type": "apiKey", "in": "cookie", "name": "go_loose_session"},
"apiKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"},
"clientBasic": {"type": "http", "scheme": "basic"},
"userBearer": {"type": "http", "scheme": "bearer"}
},
"schemas": {
"Error": {"type": "object", "properties": {"error": {"type": "string"}}},
"AuthorizationRequest": {
"type": "object",
"required": ["application", "method", "path"],
"properties": {
"tenant": {"type": "string", "example": "nmbs"},
"application": {"type": "string", "example": "guess"},
"method": {"type": "string", "example": "GET"},
"path": {"type": "string", "example": "/api/guesses"},
"host": {"type": "string", "example": "nmbs.guess.local"}
}
},
"AuthorizationResponse": {
"type": "object",
"properties": {
"allowed": {"type": "boolean"},
"reason": {"type": "string"},
"tenant_id": {"type": "string", "format": "uuid"},
"tenant_slug": {"type": "string"},
"application_id": {"type": "string", "format": "uuid"},
"application_slug": {"type": "string"},
"api_key_id": {"type": "string", "format": "uuid"},
"api_key_name": {"type": "string"}
}
}
}
},
"paths": {
"/healthz": {
"get": {
"tags": [
"Monitoring"
],
"summary": "Liveness check",
"responses": {"200": {"description": "Healthy"}}
}
},
"/install": {
"get": {
"tags": ["Installation"],
"summary": "Show the first-run installer",
"responses": {"200": {"description": "Installer form"}, "303": {"description": "Already installed"}}
},
"post": {
"tags": ["Installation"],
"summary": "Persist Google OIDC configuration and optional demo data",
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object", "required": ["csrf_token", "oidc_client_id", "oidc_client_secret"], "properties": {"csrf_token": {"type": "string"}, "oidc_client_id": {"type": "string"}, "oidc_client_secret": {"type": "string", "format": "password"}, "seed_demo": {"type": "boolean"}}}}}},
"responses": {"303": {"description": "Installation completed; continue to Google OIDC login"}, "409": {"description": "Already installed"}, "502": {"description": "Google OIDC discovery failed"}}
}
},
"/auth/password": {
"post": {
"tags": ["User Action"],
"summary": "Authenticate a database-backed user",
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object", "required": ["email", "password"], "properties": {"email": {"type": "string", "format": "email"}, "password": {"type": "string", "format": "password"}, "return": {"type": "string", "format": "uri"}}}}}},
"responses": {"303": {"description": "Login succeeded"}, "401": {"description": "Invalid email or password"}, "429": {"description": "Temporarily rate limited"}}
}
},
"/api/v1/authorize": {
"post": {
"tags": [
"Authorization Management"
],
"summary": "Authorize an incoming API request",
"security": [{"apiKey": []}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthorizationRequest"}}}},
"responses": {
"200": {"description": "Authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthorizationResponse"}}}},
"401": {"description": "Missing or invalid key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}},
"403": {"description": "Key not allowed for this request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthorizationResponse"}}}}
}
}
},
"/api/v1/dashboard": {
"get": {
"tags": [
"Dashboard Management"
],
"summary": "Get management console data",
"security": [{"session": []}],
"responses": {"200": {"description": "Dashboard data"}}
}
},
"/api/v1/tenants": {
"post": {
"tags": [
"Tenant Management"
],
"summary": "Create a tenant",
"security": [{"session": []}],
"responses": {"201": {"description": "Tenant created"}}
}
},
"/api/v1/applications": {
"post": {
"tags": [
"Application Management"
],
"summary": "Register an application",
"security": [{"session": []}],
"responses": {"201": {"description": "Application registered"}}
}
},
"/api/v1/keys": {
"post": {
"tags": [
"Key Management"
],
"summary": "Issue an API key",
"security": [{"session": []}],
"responses": {"201": {"description": "Key created; plaintext secret is returned once"}}
}
},
"/api/v1/keys/{id}/revoke": {
"post": {
"tags": [
"Key Management"
],
"summary": "Revoke an API key",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "Key revoked"}}
}
},
"/api/v1/contracts": {
"post": {
"tags": [
"Contract Management"
],
"summary": "Import a pasted contract or scan an OpenAPI URL",
"security": [{"session": []}],
"responses": {"201": {"description": "Contract imported"}}
}
},
"/connect/authorize": {
"get": {
"tags": [
"User Action"
],
"summary": "Start hosted client application login",
"parameters": [
{"in": "query", "name": "response_type", "required": true, "schema": {"type": "string", "const": "code"}},
{"in": "query", "name": "client_id", "required": true, "schema": {"type": "string"}},
{"in": "query", "name": "redirect_uri", "required": true, "schema": {"type": "string", "format": "uri"}},
{"in": "query", "name": "state", "required": true, "schema": {"type": "string"}}
],
"responses": {"302": {"description": "Redirect to login or exact client callback"}}
}
},
"/connect/token": {
"post": {
"tags": [
"User Action"
],
"summary": "Exchange a one-time authorization code",
"security": [{"clientBasic": []}],
"requestBody": {"required": true, "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}},
"responses": {"200": {"description": "Opaque client user session"}, "401": {"description": "Invalid code or client credentials"}}
}
},
"/connect/userinfo": {
"get": {
"tags": [
"User Action"
],
"summary": "Validate a client user session",
"security": [{"userBearer": []}],
"responses": {"200": {"description": "Authenticated client user"}, "401": {"description": "Invalid, revoked, or expired session"}}
}
},
"/connect/logout": {
"post": {
"tags": [
"User Action"
],
"summary": "Revoke a client user session",
"security": [{"userBearer": []}],
"responses": {"204": {"description": "Session revoked"}}
}
},
"/api/v1/applications/{id}/client-config": {
"post": {
"tags": [
"Application Management"
],
"summary": "Configure exact redirect URIs and rotate the client secret",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"200": {"description": "Configuration saved; client secret returned once"}}
}
},
"/api/v1/users": {
"post": {
"tags": [
"User Management"
],
"summary": "Invite a tenant user",
"security": [{"session": []}],
"responses": {"201": {"description": "User invited"}}
}
},
"/api/v1/users/{id}/access": {
"put": {
"tags": [
"User Management"
],
"summary": "Set tenant role and application access",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "Access updated"}}
}
},
"/api/v1/users/{id}/approve": {
"post": {
"tags": ["User Management"],
"summary": "Approve a waiting-room user into a tenant",
"description": "Requires a system administrator session.",
"security": [{"session": []}],
"parameters": [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {"204": {"description": "User approved"}, "404": {"description": "User or permission not found"}}
}
}
}
}

18
web/style.css Normal file
View file

@ -0,0 +1,18 @@
:root{--bg:#16161e;--surface:#1f2335;--surface2:#24283b;--text:#c0caf5;--muted:#7aa2f7;--accent:#7dcfff;--accent2:#bb9af7;--good:#9ece6a;--danger:#f7768e;--border:#3b4261;--shadow:rgba(0,0,0,.35)}
[data-theme=catppuccin]{--bg:#1e1e2e;--surface:#181825;--surface2:#313244;--text:#cdd6f4;--muted:#89b4fa;--accent:#94e2d5;--accent2:#cba6f7;--good:#a6e3a1;--danger:#f38ba8;--border:#45475a}
[data-theme=tomorrow]{--bg:#1d1f21;--surface:#282a2e;--surface2:#373b41;--text:#c5c8c6;--muted:#81a2be;--accent:#8abeb7;--accent2:#b294bb;--good:#b5bd68;--danger:#cc6666;--border:#4d5057}
[data-theme=kanagawa]{--bg:#1f1f28;--surface:#2a2a37;--surface2:#363646;--text:#dcd7ba;--muted:#7e9cd8;--accent:#7fb4ca;--accent2:#957fb8;--good:#98bb6c;--danger:#e46876;--border:#54546d}
[data-mode=light][data-theme=tokyo]{--bg:#e6e7ed;--surface:#fff;--surface2:#dfe2ed;--text:#343b58;--muted:#34548a;--accent:#0f4b6e;--accent2:#5a4a78;--good:#485e30;--danger:#8c4351;--border:#b4b5c3;--shadow:rgba(52,59,88,.15)}
[data-mode=light][data-theme=catppuccin]{--bg:#eff1f5;--surface:#fff;--surface2:#e6e9ef;--text:#4c4f69;--muted:#1e66f5;--accent:#179299;--accent2:#8839ef;--good:#40a02b;--danger:#d20f39;--border:#bcc0cc;--shadow:rgba(76,79,105,.15)}
[data-mode=light][data-theme=tomorrow]{--bg:#f5f5f5;--surface:#fff;--surface2:#e8e8e8;--text:#4d4d4c;--muted:#4271ae;--accent:#3e999f;--accent2:#8959a8;--good:#718c00;--danger:#c82829;--border:#c9c9c9;--shadow:rgba(77,77,76,.15)}
[data-mode=light][data-theme=kanagawa]{--bg:#f2ecde;--surface:#fffaf0;--surface2:#e7dba9;--text:#545464;--muted:#4d699b;--accent:#4d7f8c;--accent2:#624c83;--good:#6f894e;--danger:#c34043;--border:#c8c093;--shadow:rgba(84,84,100,.15)}
*{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
.docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)}
main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)}
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:34px 0;overflow:hidden;border-radius:12px}.stats article{background:var(--surface);padding:20px 24px}.stats span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.1em}.stats strong{display:block;font-size:30px;margin-top:6px}.workspace{display:grid;grid-template-columns:190px 1fr;gap:28px}.workspace aside{display:flex;flex-direction:column;gap:6px}.tab{border:0;border-left:2px solid transparent;background:transparent;color:var(--muted);padding:13px;text-align:left;font:600 12px ui-monospace,monospace}.tab span{font-family:Inter,system-ui;margin-left:9px}.tab.active{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}.panel{display:none}.panel.active{display:block}.panel-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:32px;letter-spacing:-.035em;margin:0}.panel-head button,dialog button[type=submit],.terminal-login,.card-action{background:var(--accent);color:var(--bg);font-weight:750;border:0;padding:11px 16px;border-radius:7px;cursor:pointer}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:20px}.card h3{margin:0 0 5px}.card p{color:color-mix(in srgb,var(--text) 62%,transparent);min-height:24px}.card-action{margin-top:16px;width:100%;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 45%,var(--border))}.badge{font:700 10px ui-monospace,monospace;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);padding:5px 7px;border-radius:5px}.meta{display:flex;justify-content:space-between;margin-top:18px;color:var(--muted);font-size:12px}.table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:15px;border-bottom:1px solid var(--border)}th{color:var(--muted);font:700 10px ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}tr:last-child td{border-bottom:0}td code{color:var(--accent)}.danger{color:var(--danger);background:transparent;border:0;cursor:pointer}.notice{margin:20px 0;padding:13px 16px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent);border-radius:8px}
dialog{color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 30px 100px rgba(0,0,0,.6);width:min(520px,92vw);padding:24px}dialog::backdrop{background:rgba(8,8,15,.74);backdrop-filter:blur(4px)}dialog form{display:grid;gap:16px}.dialog-head{display:flex;align-items:center;justify-content:space-between}.dialog-head h3{margin:0;font-size:22px}.close{border:0;background:transparent;color:var(--muted);font-size:28px;cursor:pointer}dialog label{display:grid;gap:7px;font-size:13px;color:var(--muted)}textarea{resize:vertical}.segmented{display:flex;gap:20px}.segmented label{display:flex;align-items:center;grid-template-columns:auto auto}.secret{display:flex;gap:8px;align-items:center;background:var(--bg);padding:12px;border-radius:8px}.secret code{overflow-wrap:anywhere;color:var(--good);flex:1}
.check-list{display:grid;gap:8px}.check-list label{display:flex;grid-template-columns:auto 1fr;align-items:center}.client-terminal h1{font-size:clamp(38px,6vw,64px);line-height:1;margin:10px 0 32px;letter-spacing:-.05em}.client-terminal h1 span{color:var(--accent2)}
.login-page{display:grid;place-items:center;min-height:100vh;overflow:hidden}.login-glow{position:fixed;width:600px;height:600px;border-radius:50%;background:var(--accent2);filter:blur(180px);opacity:.13}.terminal{width:min(920px,92vw);margin:40px;position:relative;background:color-mix(in srgb,var(--surface) 94%,black);border:1px solid var(--border);border-radius:10px;box-shadow:0 40px 120px rgba(0,0,0,.55);overflow:hidden;font-family:ui-monospace,SFMono-Regular,monospace}.terminal-bar{height:44px;background:var(--surface2);display:flex;align-items:center;gap:8px;padding:0 15px}.terminal-bar span{width:11px;height:11px;border-radius:50%;background:var(--danger)}.terminal-bar span:nth-child(2){background:#e0af68}.terminal-bar span:nth-child(3){background:var(--good)}.terminal-bar strong{position:absolute;left:50%;transform:translateX(-50%);color:var(--muted);font-size:12px}.terminal-body{padding:35px}.ascii{color:var(--accent);font-size:clamp(6px,1vw,12px);line-height:1.2;overflow:hidden}.prompt{color:var(--good)}.muted{color:var(--muted);max-width:650px;line-height:1.6}.terminal-login{display:inline-flex;gap:10px;text-decoration:none;margin:20px 0 10px}.dev-login{display:block;color:var(--muted);font-size:11px}.cursor{color:var(--accent);animation:blink 1s steps(1) infinite}@keyframes blink{50%{opacity:0}}.docs-page #swagger-ui{max-width:1200px;margin:30px auto}.docs-page .swagger-ui{filter:none}
.password-login{display:grid;grid-template-columns:1fr 1fr auto;gap:10px;align-items:end;margin:24px 0}.password-login label{display:grid;gap:6px;color:var(--muted);font-size:12px}.password-login button{height:42px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800;padding:0 18px}.login-divider{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}
.installer-form{display:grid;gap:14px;margin-top:24px}.installer-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.installer-form button{height:44px;border:0;border-radius:7px;background:var(--accent);color:var(--bg);font-weight:800}.installer-form .seed-option{display:flex;grid-template-columns:auto 1fr;align-items:center}.installer h1{font-size:clamp(40px,6vw,68px);line-height:1;margin:10px 0;letter-spacing:-.05em}.installer h1 span{color:var(--accent2)}
@media(max-width:800px){header{padding:0 16px}nav .compact,.identity{display:none}.hero{padding:55px 0;display:block}.status-card{margin-top:35px;min-width:0}.stats{grid-template-columns:1fr 1fr}.workspace{grid-template-columns:1fr}.workspace aside{flex-direction:row;overflow:auto}.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}.card-grid{grid-template-columns:1fr}.ascii{font-size:5.3px}.terminal-body{padding:22px}.password-login{grid-template-columns:1fr}.password-login button{width:100%}}

16
web/waiting-room.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" data-theme="tokyo" data-mode="dark">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Waiting for access · Go Loose</title><link rel="stylesheet" href="/assets/style.css"></head>
<body class="login-page">
<div class="login-glow"></div>
<main class="terminal client-terminal">
<div class="terminal-bar"><span></span><span></span><span></span><strong>goloose@waiting-room:~</strong></div>
<div class="terminal-body">
<p class="eyebrow">IDENTITY VERIFIED</p>
<h1>Access pending.<br><span>You are in the waiting room.</span></h1>
<p class="muted">A Go Loose administrator must assign your account to at least one tenant. No tenant or application data is visible while you wait.</p>
<form action="/auth/logout" method="post"><button class="terminal-login" type="submit">LOG OUT</button></form>
</div>
</main>
</body>
</html>