diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2694dcb..6fc39f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,19 +44,15 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use domain types, and `src/styles/global.css` contains application-wide styles. Keep feature-specific components inside their feature directory. Vite proxies `/api` to the Go service in development. -- Local multi-tenant URLs use `.guess.local`. The frontend derives the - tenant from the hostname, calls same-origin `/api` through the HTTPS proxy, and - sends `X-Tenant-Id` on every request type. Other deployments use same-origin - `/api`. Generated participant links must use the validated matching tenant - origin. Keep this contract documented in `README.md` and `frontend/README.md`. -- Protected interviewer routes also require Go Loose `X-API-Key` authorization - for application `guess`. The tenant passed to Go Loose comes from - `X-Tenant-Id`. `GO_LOOSE_BASE_URL` defaults to `https://%s.auth.dev`; - `%s` is that tenant and must be one DNS label. Do not require the API key on - `/api/health`, `/api/openapi.json`, `/api/auth/login`, or opaque-token - participant routes. The frontend sends the key only when - `VITE_GO_LOOSE_API_KEY` is present at build time. Document both variables when - their contract changes. +- Multi-tenant URLs use `.`, where the app domain is + `guess-dev.urpi.be` in local development and the Kubernetes development + profile, and `guess.urpi.be` in production. The frontend derives the tenant from + the hostname, calls same-origin `/api` through the HTTPS proxy, and sends + `X-Tenant-Id` on every request type. Generated participant links must use the + validated matching tenant origin. Keep this contract documented in `README.md` + and `frontend/README.md`. +- Protected interviewer routes require a bearer JWT only. Go Loose is used for + browser login, not for API-key authorization. Goose files in `backend/migrations` are schema-only and run in every environment. `internal/database/seed/common.sql` contains shared reference data; @@ -67,13 +63,19 @@ test updates. Never put demo users or test records in schema migrations. Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`, `/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token -participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev` -start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects -to `https://.auth.dev/connect/authorize` and Go Loose returns to -`/api/auth/callback`. Go Loose client credentials come from uncommitted -environment variables. Missing tenant context uses the public schema; a -`*.guess.dev` host supplies the schema when `X-Tenant-Id` is absent. Never expose -password hashes, CV/photo bytes, or interviewer reference answers in public JSON. +participant interview routes. Any tenant host starts browser login with +`github.com/danyel/go-loose/client`: `/api/auth/login` redirects to +`https://./connect/authorize` and Go Loose returns to +`/api/auth/callback`. `GO_LOOSE_AUTH_DOMAIN` is `auth-dev.urpi.be` locally and in +the development profile, and `auth.urpi.be` in production; `GO_LOOSE_APP_DOMAIN` +is the matching application domain. Both default to the development domains. +`GET /api/auth/session` answers `404` when no Go Loose login is configured for the +request host, and the frontend uses that to fall back to the password form instead +of redirecting. Go Loose client credentials come from uncommitted environment +variables. The tenant comes from `X-Tenant-Id`, or from the request host and +`X-Forwarded-Host` when the header is absent; missing tenant context uses the +public schema. Never expose password hashes, CV/photo bytes, or interviewer +reference answers in public JSON. Questions are permanent reusable records linked to jobs through `job_posting_questions`; detach links rather than deleting questions. Deprecated diff --git a/Dockerfile b/Dockerfile index 76597c4..5bd3ff9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint USER app +USER 1000 EXPOSE 8080 ENV ADDRESS=:8081 \ APP_ENV=production \ diff --git a/Makefile b/Makefile index fb92e7d..cd2cd35 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/sh - +REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable JWT_SECRET ?= local-development-secret-change-me-12345 GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0 @@ -37,6 +37,9 @@ help: @echo "make test-env-down stops the test image" @echo "make lint runs lint on the frontend" @echo "make build builds the entire project" + @echo "make docker build the all-in-one image" + @echo "make docker-push build and push the image to the private registry" + @echo "make docker-run run the image on :8080" @echo "make rancher-storage installs the persistent local-path StorageClass" @echo "make ingress-nginx-install installs the ingress-nginx controller serving 80/443" @echo "make cert-manager-install installs cert-manager for chart-issued TLS certificates" @@ -109,6 +112,17 @@ build: cd backend && go build ./... cd frontend && npm run build +docker: + docker build -t $(IMAGE):$(TAG) . + +docker-push: docker + docker tag $(IMAGE):$(TAG) $(REGISTRY_IMAGE):$(TAG) + docker push $(REGISTRY_IMAGE):$(TAG) + +docker-run: + docker run --rm -p 8080:8080 --env-file .env $(IMAGE):$(TAG) + + rancher-storage: KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml diff --git a/README.md b/README.md index a17596d..120c459 100644 --- a/README.md +++ b/README.md @@ -21,33 +21,46 @@ make dev ``` `docker compose up -d --build` serves the UI through the local Traefik edge at - and . Add the hostnames once: + and . Browser +sign-in goes through the development Go Loose installation at +. Both domains must resolve to the local edge: ```text -127.0.0.1 nmbs.guess.dev ypto.guess.dev -127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev +127.0.0.1 nmbs.guess-dev.urpi.be ypto.guess-dev.urpi.be +127.0.0.1 nmbs.auth-dev.urpi.be ypto.auth-dev.urpi.be ``` +Production uses `guess.urpi.be` and `auth.urpi.be` instead. `GO_LOOSE_AUTH_DOMAIN` +and `GO_LOOSE_APP_DOMAIN` select the pair; both default to the development +domains so a local run never touches the production identity provider. + Start the shared TLS proxy described in -`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open - or . The hostname's first -label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the -`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream -requests. The backend uses that tenant ID as the isolated PostgreSQL schema and -builds participant invitation and meeting links from the matching tenant browser -origin instead of the static localhost fallback. +`/home/dnoulet/go/infrastructure/docs/APPLICATIONS.md`, then open + or . The +hostname's first label is the tenant ID: the frontend calls same-origin `/api` and +sends the same value in the `X-Tenant-Id` header on JSON, multipart, +file-download, and event-stream requests. The backend uses that tenant ID as the +isolated PostgreSQL schema and builds participant invitation and meeting links +from the matching tenant browser origin instead of the static localhost fallback. Compose mounts the infrastructure CA from -`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The -browser is redirected through the matching Go Loose tenant and returns to -`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and -`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them. +`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API at +`/certs/local-ca.crt`, which `GO_LOOSE_CA_FILE` points at so the API trusts the +local Traefik certificate. The browser is redirected through the matching Go Loose +tenant and returns to `/api/auth/callback`. Set the per-tenant +`GO_LOOSE_*_CLIENT_ID` and `GO_LOOSE_*_CLIENT_SECRET` variables in the +environment; never commit them. Sign in with: - Email: `interviewer@ypto.local` - Password: `admin123` +Those credentials are only for hosts without Go Loose login. When Go Loose +client credentials are configured, the frontend exchanges the Go Loose session +for a JWT silently and redirects to the identity provider instead of showing the +password form. + To run the applications outside containers: ```bash @@ -60,7 +73,8 @@ make frontend The API listens on port `8080` inside Docker and is exposed to browsers only through Traefik and the frontend's same-origin `/api` proxy. Vite permits the -documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and +documented local tenant hostnames; the API CORS policy permits the configured +tenant domain and `*.guess.local` development origins and the `Authorization` and `X-Tenant-Id` request headers. Non-local deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET` outside local development. @@ -68,7 +82,7 @@ outside local development. ## API contract The backend exposes its current OpenAPI 3.0 contract at -. The document is assembled in memory +. The document is assembled in memory from the same typed registrations that add routes to Chi, including request and response schemas, status codes, authentication, tenant headers, path/query parameters, multipart uploads, downloads, and event streams. It therefore @@ -78,16 +92,24 @@ requires no generation command and cannot miss a newly registered route. tenant header. Every other route declares required `X-Tenant-Id`. Protected operations also declare bearer authentication. -## Go Loose authorization +## Go Loose browser login -Tenant hosts start browser authentication at `GET /api/auth/login`. Go Loose +Tenant hosts start browser authentication at `GET /api/auth/login`. The API +redirects to `https://./connect/authorize`; Go Loose returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the -resulting secure browser session for the application's bearer JWT. The tenant -is derived from the request host, or from `X-Tenant-Id` for API calls. +resulting secure browser session for the application's bearer JWT. The tenant is +derived from the request host, or from `X-Forwarded-Host` when an ingress proxy +replaces it, and from `X-Tenant-Id` for API calls. -Compose configures `GO_LOOSE_AUTH_DOMAIN`, `GO_LOOSE_APP_DOMAIN`, -`GO_LOOSE_CA_FILE`, and per-tenant client ID and secret variables. These routes -do not require a JWT: +| Environment | `GO_LOOSE_AUTH_DOMAIN` | `GO_LOOSE_APP_DOMAIN` | +| --- | --- | --- | +| Local development | `auth-dev.urpi.be` | `guess-dev.urpi.be` | +| Kubernetes development | `auth-dev.urpi.be` | `guess-dev.urpi.be` | +| Production | `auth.urpi.be` | `guess.urpi.be` | + +Those two variables, `GO_LOOSE_CA_FILE`, and per-tenant client ID and secret +variables are configured by Compose and by the chart. These routes do not +require a JWT: - `GET /api/health` and `GET /api/openapi.json` - browser login, callback, logout, and session routes under `/api/auth` @@ -98,6 +120,13 @@ do not require a JWT: Login and participant routes still require `X-Tenant-Id`. Health and OpenAPI do not. Browser authentication routes derive the tenant from the configured host. +The frontend decides whether to attempt browser login from the host it is served +on, then asks the API whether Go Loose is configured there: `GET /api/auth/session` +answers `404` for any host without a Go Loose login, which is the signal to show +the password form instead of redirecting. A `401` means Go Loose is configured and +the browser has no valid session yet, so the frontend redirects to +`GET /api/auth/login`. + ## Switch the Git remote Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between @@ -175,7 +204,7 @@ docker run --rm -p 8080:8080 \ -e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \ -e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \ -e JWT_SECRET='replace-with-at-least-32-characters' \ - -e FRONTEND_URL='https://nmbs.guess.local' \ + -e FRONTEND_URL='https://nmbs.guess-dev.urpi.be' \ batty1039.startdedicated.net:5000/go-guess-api:0.0.1 ``` @@ -191,7 +220,8 @@ repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets. The chart under `deploy/helm/go-guess` deploys the API image, the web image, and their PostgreSQL and RabbitMQ. Every tenant gets its own HTTPS host at -`https://.guess.local`. The chart values are documented in +`https://.guess.urpi.be` in production and +`https://.guess-dev.urpi.be` in the development profile. The chart values are documented in [deploy/helm/go-guess/README.md](deploy/helm/go-guess/README.md). One web deployment serves every tenant host because the API resolves the tenant @@ -227,7 +257,7 @@ kubectl -n go-insane-production get secret go-guess-web-ca \ ``` Point each tenant host at the ingress controller address, then open -`https://nmbs.guess.local` and `https://ypto.guess.local`. Browser sign-in +`https://nmbs.guess.urpi.be` and `https://ypto.guess.urpi.be`. Browser sign-in through Go Loose needs the per-tenant client credentials; pass them from the environment to `make helm-deploy-production`: @@ -275,8 +305,9 @@ the registry in production when possible. ## Architecture Requests enter the Chi router and web handlers under `backend/internal/web`. -Tenant isolation runs first. The protected interviewer group then applies Go -Loose API-key authorization and JWT authentication. +Tenant isolation runs first. The protected interviewer group then requires a +bearer JWT, issued either by `POST /api/auth/login` or by exchanging a Go Loose +browser session. Web request/response models are translated to service models before business logic runs. Services own validation, CV trait extraction, and candidate scoring. The repository maps service models to GORM entities and persists them in PostgreSQL. diff --git a/backend/go.mod b/backend/go.mod index 25c353d..8ed566c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,7 @@ module github.com/danyel/go-guess/backend go 1.27.1 require ( - github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14 + github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 diff --git a/backend/go.sum b/backend/go.sum index ba99118..b8c9de0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,8 +22,8 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14 h1:kX0zybG4E3VLB4QpOsyJEBu4HoXx33CY2x8rtACHRRM= -github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk= +github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd h1:YCvHmqMNr0cfEqdAzRyVe4MOn+yYyMh8/FLSMydvYQM= +github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk= 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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6f408be..e694340 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -31,6 +31,20 @@ type GoLooseTenant struct { ClientSecret string } +const ( + // DefaultAuthDomain is the Go Loose identity domain used for local + // development. Production signs in through DefaultProdAuthDomain. + DefaultAuthDomain = "auth-dev.urpi.be" + // DefaultProdAuthDomain is the Go Loose identity domain used in production. + DefaultProdAuthDomain = "auth.urpi.be" + // DefaultAppDomain is the public application domain used for local + // development, matching deploy/helm/go-guess/values-dev.yaml. + DefaultAppDomain = "guess-dev.urpi.be" + // DefaultProdAppDomain is the public application domain used in production, + // matching deploy/helm/go-guess/values.yaml. + DefaultProdAppDomain = "guess.urpi.be" +) + // GoLooseCredentialEnv returns the environment variable names holding the Go // Loose client credentials of a tenant. func GoLooseCredentialEnv(slug string) (idEnv, secretEnv string) { @@ -63,10 +77,17 @@ func Load() (Config, error) { return cfg, nil } +// loadGoLoose reads the Go Loose browser login configuration. Both domains are +// single host suffixes: a tenant named "nmbs" signs in through +// https://nmbs. and is served from https://nmbs.. +// +// The defaults are the development domains, matching the rest of the local +// defaults in Load. Production deployments set GO_LOOSE_AUTH_DOMAIN and +// GO_LOOSE_APP_DOMAIN explicitly (see deploy/helm/go-guess/values.yaml). func loadGoLoose() GoLooseConfig { cfg := GoLooseConfig{ - AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", "auth.dev"), - AppDomain: env("GO_LOOSE_APP_DOMAIN", "guess.dev"), + AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", DefaultAuthDomain), + AppDomain: env("GO_LOOSE_APP_DOMAIN", DefaultAppDomain), CAFile: os.Getenv("GO_LOOSE_CA_FILE"), Tenants: map[string]GoLooseTenant{}, } diff --git a/backend/internal/golooseauth/registry.go b/backend/internal/golooseauth/registry.go index f0e5a7f..012aaad 100644 --- a/backend/internal/golooseauth/registry.go +++ b/backend/internal/golooseauth/registry.go @@ -24,16 +24,13 @@ type Registry struct { func New(cfg config.GoLooseConfig) (*Registry, error) { registry := &Registry{ - appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."), + appDomain: domain(cfg.AppDomain), bySlug: map[string]*goloose.BrowserAuth{}, } - if registry.appDomain == "" { - registry.appDomain = "guess.dev" - } if len(cfg.Tenants) == 0 { return registry, nil } - authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".") + authDomain := domain(cfg.AuthDomain) if authDomain == "" { return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured") } @@ -42,7 +39,7 @@ func New(cfg config.GoLooseConfig) (*Registry, error) { return nil, err } for slug, tenant := range cfg.Tenants { - slug = strings.ToLower(strings.TrimSpace(slug)) + slug = domain(slug) if slug == "" || strings.Contains(slug, ".") { return nil, fmt.Errorf("invalid Go Loose tenant slug %q", slug) } @@ -68,11 +65,13 @@ func New(cfg config.GoLooseConfig) (*Registry, error) { func (r *Registry) AppDomain() string { if r == nil || r.appDomain == "" { - return "guess.dev" + return config.DefaultAppDomain } return r.appDomain } +// ForHost resolves the Go Loose client of the tenant that owns host. Ingress +// proxies may replace the request Host, so X-Forwarded-Host wins when present. func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) { if r == nil { return nil, "", false @@ -82,16 +81,21 @@ func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) { return auth, slug, ok } +// Host returns the host that identifies the tenant of a request, preferring the +// proxy-supplied X-Forwarded-Host over the request Host. +func Host(r *http.Request) string { + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwarded != "" { + return forwarded + } + return r.Host +} + func TenantFromHost(host, appDomain string) string { - hostname := strings.ToLower(host) + hostname := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) if parsed, _, err := net.SplitHostPort(host); err == nil { - hostname = strings.ToLower(parsed) + hostname = strings.ToLower(strings.TrimSuffix(parsed, ".")) } - appDomain = strings.TrimPrefix(strings.ToLower(appDomain), ".") - if appDomain == "" { - appDomain = "guess.dev" - } - suffix := "." + appDomain + suffix := "." + domain(appDomain) if !strings.HasSuffix(hostname, suffix) { return "" } @@ -102,6 +106,11 @@ func TenantFromHost(host, appDomain string) string { return slug } +// domain normalizes a configured host suffix or tenant slug. +func domain(value string) string { + return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".") +} + func client(caFile string) (*http.Client, error) { transport := http.DefaultTransport.(*http.Transport).Clone() if caFile != "" { diff --git a/backend/internal/golooseauth/registry_test.go b/backend/internal/golooseauth/registry_test.go new file mode 100644 index 0000000..a57ae49 --- /dev/null +++ b/backend/internal/golooseauth/registry_test.go @@ -0,0 +1,181 @@ +package golooseauth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danyel/go-guess/backend/internal/config" +) + +func tenants() map[string]config.GoLooseTenant { + return map[string]config.GoLooseTenant{ + "nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}, + "ypto": {ClientID: "glc_ypto", ClientSecret: "secret"}, + } +} + +func TestNewUsesDevelopmentDomainsByDefault(t *testing.T) { + registry, err := New(config.GoLooseConfig{AuthDomain: config.DefaultAuthDomain, AppDomain: config.DefaultAppDomain, Tenants: tenants()}) + if err != nil { + t.Fatal(err) + } + if registry.AppDomain() != "guess-dev.urpi.be" { + t.Fatalf("app domain = %q", registry.AppDomain()) + } + location := loginRedirect(t, registry, "nmbs.guess-dev.urpi.be") + if !strings.HasPrefix(location, "https://nmbs.auth-dev.urpi.be/connect/authorize?") { + t.Fatalf("development login redirect = %q", location) + } + if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess-dev.urpi.be%2Fapi%2Fauth%2Fcallback") { + t.Fatalf("development callback missing from %q", location) + } +} + +func TestNewUsesProductionDomains(t *testing.T) { + registry, err := New(config.GoLooseConfig{ + AuthDomain: config.DefaultProdAuthDomain, + AppDomain: config.DefaultProdAppDomain, + Tenants: tenants(), + }) + if err != nil { + t.Fatal(err) + } + location := loginRedirect(t, registry, "nmbs.guess.urpi.be") + if !strings.HasPrefix(location, "https://nmbs.auth.urpi.be/connect/authorize?") { + t.Fatalf("production login redirect = %q", location) + } + if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess.urpi.be%2Fapi%2Fauth%2Fcallback") { + t.Fatalf("production callback missing from %q", location) + } +} + +func TestNewRequiresAnAuthDomainWithCredentials(t *testing.T) { + if _, err := New(config.GoLooseConfig{Tenants: tenants()}); err == nil { + t.Fatal("expected an error when GO_LOOSE_AUTH_DOMAIN is empty") + } +} + +func TestNewNormalizesConfiguredDomains(t *testing.T) { + registry, err := New(config.GoLooseConfig{ + AuthDomain: " .Auth-Dev.Urpi.Be. ", + AppDomain: ".Guess-Dev.Urpi.Be.", + Tenants: map[string]config.GoLooseTenant{"NMBS": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, + }) + if err != nil { + t.Fatal(err) + } + if registry.AppDomain() != "guess-dev.urpi.be" { + t.Fatalf("app domain = %q", registry.AppDomain()) + } + if location := loginRedirect(t, registry, "nmbs.guess-dev.urpi.be"); !strings.HasPrefix(location, "https://nmbs.auth-dev.urpi.be/connect/authorize?") { + t.Fatalf("normalized login redirect = %q", location) + } +} + +func TestNewRejectsMultiLabelTenantSlug(t *testing.T) { + _, err := New(config.GoLooseConfig{ + AuthDomain: config.DefaultAuthDomain, + AppDomain: config.DefaultAppDomain, + Tenants: map[string]config.GoLooseTenant{"nmbs.eu": {ClientID: "id", ClientSecret: "secret"}}, + }) + if err == nil { + t.Fatal("expected an error for a tenant slug containing a dot") + } +} + +func TestAppDomainFallsBackToTheDevelopmentDomain(t *testing.T) { + var missing *Registry + if missing.AppDomain() != config.DefaultAppDomain { + t.Fatalf("nil registry app domain = %q", missing.AppDomain()) + } + empty, err := New(config.GoLooseConfig{}) + if err != nil { + t.Fatal(err) + } + if empty.AppDomain() != config.DefaultAppDomain { + t.Fatalf("empty registry app domain = %q", empty.AppDomain()) + } +} + +func TestForHostResolvesTheTenantOfTheHost(t *testing.T) { + registry, err := New(config.GoLooseConfig{ + AuthDomain: config.DefaultAuthDomain, + AppDomain: config.DefaultAppDomain, + Tenants: tenants(), + }) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + host string + slug string + ok bool + }{ + {host: "nmbs.guess-dev.urpi.be", slug: "nmbs", ok: true}, + {host: "ypto.guess-dev.urpi.be", slug: "ypto", ok: true}, + {host: "NMBS.Guess-Dev.Urpi.Be:8443", slug: "nmbs", ok: true}, + // The apex host identifies no tenant. + {host: "guess-dev.urpi.be", slug: "", ok: false}, + // Another environment's domain has no configured Go Loose client here. + {host: "nmbs.guess.urpi.be", slug: "", ok: false}, + {host: "guess.urpi.be", slug: "", ok: false}, + {host: "localhost:5173", slug: "", ok: false}, + {host: "", slug: "", ok: false}, + } { + auth, slug, ok := registry.ForHost(tc.host) + if slug != tc.slug || ok != tc.ok { + t.Fatalf("ForHost(%q) = %q, %v, want %q, %v", tc.host, slug, ok, tc.slug, tc.ok) + } + if ok && auth == nil { + t.Fatalf("ForHost(%q) returned no client", tc.host) + } + } +} + +func TestHostPrefersTheForwardedHost(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil) + request.Host = "api:8080" + if Host(request) != "api:8080" { + t.Fatalf("host = %q", Host(request)) + } + request.Header.Set("X-Forwarded-Host", "nmbs.guess-dev.urpi.be") + if Host(request) != "nmbs.guess-dev.urpi.be" { + t.Fatalf("forwarded host = %q", Host(request)) + } +} + +func TestTenantFromHost(t *testing.T) { + for _, tc := range []struct { + host string + want string + }{ + {host: "nmbs.guess-dev.urpi.be", want: "nmbs"}, + {host: "ypto.guess-dev.urpi.be:443", want: "ypto"}, + {host: "nmbs.guess-dev.urpi.be.", want: "nmbs"}, + {host: "deep.nmbs.guess-dev.urpi.be", want: ""}, + {host: "guess-dev.urpi.be", want: ""}, + {host: "nmbs.evil.test", want: ""}, + } { + if got := TenantFromHost(tc.host, config.DefaultAppDomain); got != tc.want { + t.Fatalf("TenantFromHost(%q) = %q, want %q", tc.host, got, tc.want) + } + } +} + +func loginRedirect(t *testing.T, registry *Registry, host string) string { + t.Helper() + auth, _, ok := registry.ForHost(host) + if !ok { + t.Fatalf("no Go Loose client for %q", host) + } + request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) + request.Host = host + response := httptest.NewRecorder() + auth.LoginHandler(response, request) + if response.Code != http.StatusFound { + t.Fatalf("login for %q = %d, want 302", host, response.Code) + } + return response.Header().Get("Location") +} diff --git a/backend/internal/service/model/models.go b/backend/internal/service/model/models.go index 3cbc63b..91f7879 100644 --- a/backend/internal/service/model/models.go +++ b/backend/internal/service/model/models.go @@ -8,6 +8,10 @@ type User struct { PasswordHash string DisplayName string Role string + // AvatarURL is the picture Go Loose serves for this person. It is resolved + // from the identity provider on every sign-in rather than stored, because a + // stored key stops resolving once the picture is replaced or removed. + AvatarURL string } type Trait struct { diff --git a/backend/internal/service/services.go b/backend/internal/service/services.go index b8fb9d7..0992ac8 100644 --- a/backend/internal/service/services.go +++ b/backend/internal/service/services.go @@ -28,7 +28,7 @@ const CandidateThreshold = 60.0 type IAuthService interface { Login(context.Context, string, string) (string, model.User, error) - EstablishExternalSession(context.Context, string, string) (string, model.User, error) + EstablishExternalSession(context.Context, ExternalIdentity) (string, model.User, error) } type IJobService interface { @@ -89,9 +89,19 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string return token, user, nil } -func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) { - email = strings.ToLower(strings.TrimSpace(email)) - displayName = strings.TrimSpace(displayName) +// ExternalIdentity is what Go Loose reports about the person signing in. The +// avatar URL is deliberately not persisted: Go Loose retires a picture key when +// the picture changes, so a stored copy would eventually point at nothing. It is +// read from the live identity on every session instead. +type ExternalIdentity struct { + Email string + DisplayName string + AvatarURL string +} + +func (s *AuthService) EstablishExternalSession(ctx context.Context, identity ExternalIdentity) (string, model.User, error) { + email := strings.ToLower(strings.TrimSpace(identity.Email)) + displayName := strings.TrimSpace(identity.DisplayName) if email == "" { return "", model.User{}, ErrValidation } @@ -116,6 +126,7 @@ func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displ return "", model.User{}, err } user.PasswordHash = "" + user.AvatarURL = strings.TrimSpace(identity.AvatarURL) return token, user, nil } diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index ece7e22..846b653 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -653,8 +653,10 @@ func (h *Handler) frontendURLForRequest(r *http.Request) string { if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { return h.frontendURL } - if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") || - strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") { + // Only trust the Origin when it is the browser origin of the requesting + // tenant, so an invitation link cannot be pointed at another host. + if strings.EqualFold(parsed.Hostname(), tenant+"."+h.AppDomain()) || + strings.EqualFold(parsed.Hostname(), tenant+".guess.local") { return origin } return h.frontendURL diff --git a/backend/internal/web/handler/handler_test.go b/backend/internal/web/handler/handler_test.go index 714da23..54a8823 100644 --- a/backend/internal/web/handler/handler_test.go +++ b/backend/internal/web/handler/handler_test.go @@ -1,17 +1,22 @@ package handler import ( + "encoding/json" "net/http/httptest" "testing" - "github.com/danyel/go-loose/client" + "github.com/danyel/go-guess/backend/internal/config" + "github.com/danyel/go-guess/backend/internal/golooseauth" + servicemodel "github.com/danyel/go-guess/backend/internal/service/model" + webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" + goloose "github.com/danyel/go-loose/client" ) func TestFrontendURLForTenantRequest(t *testing.T) { handler := &Handler{frontendURL: "http://localhost:5173"} request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request.Header.Set("Origin", "http://ypto.guess.local:5173") - request.Header.Set(client.XTenantId, "ypto") + request.Header.Set(goloose.XTenantId, "ypto") if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" { t.Fatalf("unexpected frontend URL %q", value) @@ -22,7 +27,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) { handler := &Handler{frontendURL: "http://localhost:5173"} request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request.Header.Set("Origin", "http://nmbs.guess.local:5173") - request.Header.Set(client.XTenantId, "ypto") + request.Header.Set(goloose.XTenantId, "ypto") if value := handler.frontendURLForRequest(request); value != handler.frontendURL { t.Fatalf("expected configured frontend URL, got %q", value) @@ -32,10 +37,87 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) { func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) { handler := &Handler{frontendURL: "http://localhost:5173"} request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) - request.Header.Set("Origin", "https://ypto.guess.dev") - request.Header.Set(client.XTenantId, "ypto") + request.Header.Set("Origin", "https://ypto."+config.DefaultAppDomain) + request.Header.Set(goloose.XTenantId, "ypto") - if value := handler.frontendURLForRequest(request); value != "https://ypto.guess.dev" { + if value := handler.frontendURLForRequest(request); value != "https://ypto."+config.DefaultAppDomain { t.Fatalf("expected tenant dev origin, got %q", value) } } + +func TestFrontendURLAcceptsConfiguredProductionDomain(t *testing.T) { + browser, err := golooseauth.New(config.GoLooseConfig{ + AuthDomain: config.DefaultProdAuthDomain, + AppDomain: config.DefaultProdAppDomain, + Tenants: map[string]config.GoLooseTenant{ + "nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}, + }, + }) + if err != nil { + t.Fatal(err) + } + handler := &Handler{frontendURL: "https://nmbs.guess.urpi.be"} + handler.SetBrowserAuth(browser) + + request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) + request.Header.Set("Origin", "https://nmbs."+config.DefaultProdAppDomain) + request.Header.Set(goloose.XTenantId, "nmbs") + if value := handler.frontendURLForRequest(request); value != "https://nmbs."+config.DefaultProdAppDomain { + t.Fatalf("expected tenant production origin, got %q", value) + } + + // The development domain is not the configured app domain in production. + request = httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) + request.Header.Set("Origin", "https://nmbs."+config.DefaultAppDomain) + request.Header.Set(goloose.XTenantId, "nmbs") + if value := handler.frontendURLForRequest(request); value != handler.frontendURL { + t.Fatalf("expected configured frontend URL, got %q", value) + } +} + +// TestExternalIdentityCarriesThePicture pins that the picture Go Loose serves is +// handed to the service and ends up in the session payload. The URL is resolved on +// every sign-in rather than stored, so it must survive the whole way through +// rather than being dropped at the first boundary. +func TestExternalIdentityCarriesThePicture(t *testing.T) { + identity := externalIdentity(goloose.User{ + Email: "ada@example.test", + DisplayName: "Ada Lovelace", + AvatarURL: "https://auth.example.test/api/v1/avatars/kZ3abc", + Role: "developer", + }) + if identity.Email != "ada@example.test" { + t.Errorf("email = %q", identity.Email) + } + if identity.DisplayName != "Ada Lovelace" { + t.Errorf("display name = %q", identity.DisplayName) + } + if identity.AvatarURL != "https://auth.example.test/api/v1/avatars/kZ3abc" { + t.Errorf("avatar URL = %q", identity.AvatarURL) + } + + // A person without a picture yields an empty URL rather than a broken one, so + // the frontend can fall back to initials. + without := externalIdentity(goloose.User{Email: "bob@example.test"}) + if without.AvatarURL != "" { + t.Errorf("avatar URL = %q, want empty", without.AvatarURL) + } +} + +// TestUserToWebIncludesTheAvatar pins the JSON field name the frontend reads. +func TestUserToWebIncludesTheAvatar(t *testing.T) { + encoded, err := json.Marshal(webmapper.UserToWeb(servicemodel.User{ + ID: 7, Email: "ada@example.test", DisplayName: "Ada", Role: "interviewer", + AvatarURL: "https://auth.example.test/api/v1/avatars/kZ3abc", + })) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatal(err) + } + if payload["avatarUrl"] != "https://auth.example.test/api/v1/avatars/kZ3abc" { + t.Errorf("avatarUrl = %v, payload %s", payload["avatarUrl"], encoded) + } +} diff --git a/backend/internal/web/handler/sso.go b/backend/internal/web/handler/sso.go index d4e333e..6e92b0d 100644 --- a/backend/internal/web/handler/sso.go +++ b/backend/internal/web/handler/sso.go @@ -9,6 +9,7 @@ import ( "github.com/danyel/go-guess/backend/internal/config" "github.com/danyel/go-guess/backend/internal/golooseauth" + "github.com/danyel/go-guess/backend/internal/service" webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" webmodel "github.com/danyel/go-guess/backend/internal/web/model" ) @@ -26,7 +27,7 @@ func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) { func (h *Handler) AppDomain() string { if h.browser == nil { - return "guess.dev" + return config.DefaultAppDomain } return h.browser.AppDomain() } @@ -74,7 +75,7 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, errTenantMismatch) return } - token, local, err := h.auth.EstablishExternalSession(r.Context(), user.Email, user.DisplayName) + token, local, err := h.auth.EstablishExternalSession(r.Context(), externalIdentity(user)) if err != nil { internalError(w, r, err) return @@ -83,6 +84,17 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) { })).ServeHTTP(w, r) } +// externalIdentity is what Go Loose reports about the person signing in. The +// picture URL is carried through rather than looked up: Go Loose serves it +// publicly, so the browser can render it without another round trip. +func externalIdentity(user goloose.User) service.ExternalIdentity { + return service.ExternalIdentity{ + Email: user.Email, + DisplayName: user.DisplayName, + AvatarURL: user.AvatarURL, + } +} + func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) { auth, _, ok := h.externalAuthWithSlug(r) return auth, ok @@ -92,7 +104,7 @@ func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, s if h.browser == nil { return nil, "", false } - return h.browser.ForHost(r.Host) + return h.browser.ForHost(golooseauth.Host(r)) } func writeExternalAuthError(w http.ResponseWriter, h *Handler, slug string) { diff --git a/backend/internal/web/mapper/mapper.go b/backend/internal/web/mapper/mapper.go index 8ce0e12..e102d9f 100644 --- a/backend/internal/web/mapper/mapper.go +++ b/backend/internal/web/mapper/mapper.go @@ -94,6 +94,7 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel func UserToWeb(value servicemodel.User) webmodel.User { return webmodel.User{ ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role, + AvatarURL: value.AvatarURL, } } diff --git a/backend/internal/web/middleware/GoLooseMiddleware.go b/backend/internal/web/middleware/GoLooseMiddleware.go index bb81a99..310a38f 100644 --- a/backend/internal/web/middleware/GoLooseMiddleware.go +++ b/backend/internal/web/middleware/GoLooseMiddleware.go @@ -11,11 +11,18 @@ import ( "sync" "time" + "github.com/danyel/go-guess/backend/internal/config" "github.com/danyel/go-loose/client" ) const guessApplication = "guess" +// defaultBaseURLPattern is the Go Loose identity domain used when +// GO_LOOSE_BASE_URL is unset. +func defaultBaseURLPattern() string { + return "https://%s." + config.DefaultAuthDomain +} + // A tenant used in the Go Loose host must be one DNS label. var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`) @@ -23,11 +30,12 @@ var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$` // from the X-Tenant-Id header, not from process configuration. // // GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The -// default is https://%s.auth.dev. A URL without %s is used as-is. +// default is the development identity domain, https://%s.auth-dev.urpi.be. +// A URL without %s is used as-is. func GoLooseMiddleware() func(http.Handler) http.Handler { pattern := os.Getenv("GO_LOOSE_BASE_URL") if pattern == "" { - pattern = "https://%s.auth.dev" + pattern = defaultBaseURLPattern() } httpClient := &http.Client{Timeout: 2 * time.Second} var ( diff --git a/backend/internal/web/middleware/GoLooseMiddleware_test.go b/backend/internal/web/middleware/GoLooseMiddleware_test.go index 10953e5..6f95a8d 100644 --- a/backend/internal/web/middleware/GoLooseMiddleware_test.go +++ b/backend/internal/web/middleware/GoLooseMiddleware_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/danyel/go-guess/backend/internal/config" "github.com/danyel/go-loose/client" ) @@ -79,8 +80,22 @@ func TestGoLooseMiddlewareRejectsMissingTenantAndKey(t *testing.T) { } } +func TestGoLooseMiddlewareDefaultsToTheDevelopmentIdentityDomain(t *testing.T) { + t.Setenv("GO_LOOSE_BASE_URL", "") + baseURL, err := goLooseBaseURL(defaultBaseURLPattern(), "nmbs") + if err != nil { + t.Fatal(err) + } + if baseURL != "https://nmbs."+config.DefaultAuthDomain { + t.Fatalf("default base URL = %q", baseURL) + } + if defaultBaseURLPattern() != "https://%s.auth-dev.urpi.be" { + t.Fatalf("default pattern = %q", defaultBaseURLPattern()) + } +} + func TestGoLooseMiddlewareRejectsUnsafeTenantInHost(t *testing.T) { - t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth.dev") + t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth-dev.urpi.be") handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("handler reached for unsafe tenant") })) diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware.go b/backend/internal/web/middleware/TenantSchemaMiddleware.go index 67e92cf..0d92814 100644 --- a/backend/internal/web/middleware/TenantSchemaMiddleware.go +++ b/backend/internal/web/middleware/TenantSchemaMiddleware.go @@ -28,7 +28,7 @@ func TenantSchemaMiddleware(db *gorm.DB, appDomain string) func(http.Handler) ht } tenantID := r.Header.Get(client.XTenantId) if tenantID == "" { - tenantID = golooseauth.TenantFromHost(r.Host, appDomain) + tenantID = golooseauth.TenantFromHost(golooseauth.Host(r), appDomain) } if tenantID == "" || db == nil { next.ServeHTTP(w, r) diff --git a/backend/internal/web/model/models.go b/backend/internal/web/model/models.go index 9d48908..43a6ff8 100644 --- a/backend/internal/web/model/models.go +++ b/backend/internal/web/model/models.go @@ -21,6 +21,9 @@ type User struct { Email string `json:"email"` DisplayName string `json:"displayName"` Role string `json:"role"` + // AvatarURL is an absolute, publicly fetchable picture URL served by Go Loose, + // or an empty string when the person has not uploaded one. + AvatarURL string `json:"avatarUrl"` } type CreateUserRequest struct { diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index a9003fe..366d31f 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -21,7 +21,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) router := chi.NewRouter() spec := openapi.New() router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5)) - router.Use(cors(frontendURL)) + router.Use(cors(frontendURL, h.AppDomain())) router.Use(mw.TenantSchemaMiddleware(h.Db(), h.AppDomain())) router.Use(middleware.Timeout(2 * time.Minute)) register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP, @@ -164,11 +164,11 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler } } -func cors(origin string) func(http.Handler) http.Handler { +func cors(origin, appDomain string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestOrigin := r.Header.Get("Origin") - if allowedCORSOrigin(requestOrigin, origin) { + if allowedCORSOrigin(requestOrigin, origin, appDomain) { w.Header().Set("Access-Control-Allow-Origin", requestOrigin) } w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id") @@ -183,7 +183,11 @@ func cors(origin string) func(http.Handler) http.Handler { } } -func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool { +// allowedCORSOrigin accepts the configured frontend origin plus any tenant host +// of the configured application domain, which is what the Vite dev server on +// http://.:5173 sends. *.guess.local stays allowed for the +// offline BDD and local password-only setups. +func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool { if requestOrigin == "" { return false } @@ -194,5 +198,11 @@ func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool { if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { return false } - return strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".guess.local") + hostname := strings.ToLower(parsed.Hostname()) + for _, suffix := range []string{"." + appDomain, ".guess.local"} { + if suffix != "." && strings.HasSuffix(hostname, suffix) { + return true + } + } + return false } diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go index 2e815cb..9216428 100644 --- a/backend/internal/web/router/router_test.go +++ b/backend/internal/web/router/router_test.go @@ -69,7 +69,7 @@ func (authStub) Login(context.Context, string, string) (string, model.User, erro return "", model.User{}, service.ErrInvalidCredentials } -func (authStub) EstablishExternalSession(context.Context, string, string) (string, model.User, error) { +func (authStub) EstablishExternalSession(context.Context, service.ExternalIdentity) (string, model.User, error) { return "", model.User{}, service.ErrInvalidCredentials } @@ -313,8 +313,8 @@ func TestOpenAPIDoesNotRequireTenant(t *testing.T) { func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) { h := newTestHandler(t, nil) browser, err := golooseauth.New(config.GoLooseConfig{ - AuthDomain: "auth.dev", - AppDomain: "guess.dev", + AuthDomain: config.DefaultAuthDomain, + AppDomain: config.DefaultAppDomain, Tenants: map[string]config.GoLooseTenant{ "nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}, "ypto": {ClientID: "glc_ypto", ClientSecret: "secret"}, @@ -328,14 +328,14 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) { for _, slug := range []string{"nmbs", "ypto"} { request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) - request.Host = slug + ".guess.dev" + request.Host = slug + ".guess-dev.urpi.be" response := httptest.NewRecorder() api.ServeHTTP(response, request) location := response.Header().Get("Location") - if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth.dev/connect/authorize?") { + if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth-dev.urpi.be/connect/authorize?") { t.Fatalf("%s login redirect = %d %s", slug, response.Code, location) } - if !strings.Contains(location, "redirect_uri=https%3A%2F%2F"+slug+".guess.dev%2Fapi%2Fauth%2Fcallback") { + if !strings.Contains(location, "redirect_uri=https%3A%2F%2F"+slug+".guess-dev.urpi.be%2Fapi%2Fauth%2Fcallback") { t.Fatalf("%s callback missing from %s", slug, location) } } @@ -344,8 +344,8 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) { func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) { h := newTestHandler(t, nil) browser, err := golooseauth.New(config.GoLooseConfig{ - AuthDomain: "auth.dev", - AppDomain: "guess.dev", + AuthDomain: config.DefaultAuthDomain, + AppDomain: config.DefaultAppDomain, Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, }) if err != nil { @@ -355,7 +355,7 @@ func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) { api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173") request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) - request.Host = "ypto.guess.dev" + request.Host = "ypto.guess-dev.urpi.be" response := httptest.NewRecorder() api.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable || @@ -364,7 +364,7 @@ func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) { } request = httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) - request.Host = "guess.dev" + request.Host = "guess-dev.urpi.be" response = httptest.NewRecorder() api.ServeHTTP(response, request) if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "tenant") { @@ -379,7 +379,7 @@ func TestLoginWithoutGoLooseRegistryIsNotFound(t *testing.T) { "http://localhost:5173", ) request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) - request.Host = "nmbs.guess.dev" + request.Host = "nmbs.guess-dev.urpi.be" response := httptest.NewRecorder() api.ServeHTTP(response, request) if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") { @@ -387,6 +387,125 @@ func TestLoginWithoutGoLooseRegistryIsNotFound(t *testing.T) { } } +// The frontend falls back to password sign-in only when it can tell that Go +// Loose login is unavailable, so a host without a registry must answer 404 +// rather than 401 even when the request carries no session cookie. +func TestSessionWithoutGoLooseRegistryIsNotFound(t *testing.T) { + api := New( + newTestHandler(t, nil), + security.NewTokenManager("01234567890123456789012345678901", time.Hour), + "http://localhost:5173", + ) + request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil) + request.Host = "nmbs.guess-dev.urpi.be" + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") { + t.Fatalf("session without registry = %d %s", response.Code, response.Body.String()) + } +} + +func TestSessionOnAnUnconfiguredTenantHostIsNotFound(t *testing.T) { + h := newTestHandler(t, nil) + browser, err := golooseauth.New(config.GoLooseConfig{ + AuthDomain: config.DefaultAuthDomain, + AppDomain: config.DefaultAppDomain, + Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, + }) + if err != nil { + t.Fatal(err) + } + h.SetBrowserAuth(browser) + api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173") + + request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil) + request.Host = "ypto.guess-dev.urpi.be" + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable || + !strings.Contains(response.Body.String(), "GO_LOOSE_YPTO_CLIENT_ID") { + t.Fatalf("unconfigured tenant session = %d %s", response.Code, response.Body.String()) + } +} + +func TestSessionWithoutCookieIsUnauthorizedOnATenantHost(t *testing.T) { + h := newTestHandler(t, nil) + browser, err := golooseauth.New(config.GoLooseConfig{ + AuthDomain: config.DefaultAuthDomain, + AppDomain: config.DefaultAppDomain, + Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, + }) + if err != nil { + t.Fatal(err) + } + h.SetBrowserAuth(browser) + api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173") + + request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil) + request.Host = "nmbs.guess-dev.urpi.be" + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized || !strings.Contains(response.Body.String(), "missing Go Loose session") { + t.Fatalf("session without cookie = %d %s", response.Code, response.Body.String()) + } +} + +// An ingress proxy may replace the request Host, so the tenant has to be read +// from X-Forwarded-Host when it is present. +func TestTenantHostLoginHonorsTheForwardedHost(t *testing.T) { + h := newTestHandler(t, nil) + browser, err := golooseauth.New(config.GoLooseConfig{ + AuthDomain: config.DefaultProdAuthDomain, + AppDomain: config.DefaultProdAppDomain, + Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, + }) + if err != nil { + t.Fatal(err) + } + h.SetBrowserAuth(browser) + api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "https://nmbs.guess.urpi.be") + + request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) + request.Host = "go-guess-api.go-insane-production.svc.cluster.local:8080" + request.Header.Set("X-Forwarded-Host", "nmbs.guess.urpi.be") + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + + location := response.Header().Get("Location") + if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://nmbs.auth.urpi.be/connect/authorize?") { + t.Fatalf("forwarded host login redirect = %d %s", response.Code, location) + } + if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess.urpi.be%2Fapi%2Fauth%2Fcallback") { + t.Fatalf("forwarded host callback missing from %s", location) + } +} + +func TestCORSAllowsTheConfiguredTenantOrigin(t *testing.T) { + api := New( + newTestHandler(t, nil), + security.NewTokenManager("01234567890123456789012345678901", time.Hour), + "https://nmbs.guess.urpi.be", + ) + for _, tc := range []struct { + origin string + allow string + }{ + {origin: "https://nmbs.guess.urpi.be", allow: "https://nmbs.guess.urpi.be"}, + {origin: "http://nmbs.guess-dev.urpi.be:5173", allow: "http://nmbs.guess-dev.urpi.be:5173"}, + {origin: "http://ypto.guess.local:5173", allow: "http://ypto.guess.local:5173"}, + {origin: "https://evil.test", allow: ""}, + {origin: "https://nmbs.guess.urpi.be.evil.test", allow: ""}, + } { + request := httptest.NewRequest(http.MethodOptions, "/api/jobs", nil) + request.Header.Set("Origin", tc.origin) + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if got := response.Header().Get("Access-Control-Allow-Origin"); got != tc.allow { + t.Fatalf("CORS allow origin for %q = %q, want %q", tc.origin, got, tc.allow) + } + } +} + type stubDriver struct{} func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil } diff --git a/deploy/helm/go-guess/README.md b/deploy/helm/go-guess/README.md index 857d159..8b08e97 100644 --- a/deploy/helm/go-guess/README.md +++ b/deploy/helm/go-guess/README.md @@ -2,7 +2,7 @@ This chart deploys Go Guess as two images, an API and a web front end, plus its own PostgreSQL and RabbitMQ, and publishes one HTTPS host per tenant under -`https://.guess.local`. +`https://.guess.urpi.be`. ## 1. What the chart creates @@ -75,7 +75,7 @@ command that exports the CA certificate. With the local platform, prefer `tenants` is the list of slugs and `domain` is the shared parent domain: ```yaml -domain: guess.local +domain: guess.urpi.be tenants: - nmbs - ypto @@ -83,8 +83,8 @@ tenants: The chart derives, without any extra configuration: -- `GO_LOOSE_APP_DOMAIN=guess.local`, so the API accepts `nmbs.guess.local` and - `ypto.guess.local` as tenant hosts. +- `GO_LOOSE_APP_DOMAIN=`, so the API accepts `nmbs.` and + `ypto.` as tenant hosts. - One ingress rule per tenant, all pointing at `go-guess-web`. - One certificate SAN per tenant, so a single secret covers every host. - `FRONTEND_URL`, when `api.frontendURL` is empty, as @@ -107,7 +107,7 @@ sign-in is used. |---|---|---| | `nameOverride` | `""` | Overrides the chart name in resource names and labels | | `fullnameOverride` | `""` | Replaces the generated resource name prefix | -| `domain` | `guess.local` | Parent domain of every tenant host | +| `domain` | `guess.urpi.be` | Parent domain of every tenant host. `guess-dev.urpi.be` in the development profile | | `tenants` | `[nmbs, ypto]` | Tenant slugs served by the release | | `imagePullSecrets` | `[]` | Pull secrets added to both pods | @@ -126,8 +126,8 @@ sign-in is used. | `api.service.name` | `api` | Service DNS name, fixed by the web image | | `api.service.type` | `ClusterIP` | API service type | | `api.service.port` | `8080` | API service port | -| `api.goLoose.enabled` | `false` | Enables Go Loose browser sign-in | -| `api.goLoose.authDomain` | `auth.dev` | Tenant login host `https://.` | +| `api.goLoose.enabled` | `true` | Enables Go Loose browser sign-in | +| `api.goLoose.authDomain` | `auth.urpi.be` | Tenant login host `https://.`. `auth-dev.urpi.be` in the development profile | | `api.goLoose.ca.existingSecret` | `""` | Secret holding the Go Loose CA bundle | | `api.goLoose.ca.key` | `ca.crt` | Key projected from that secret | | `api.goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target | @@ -264,8 +264,8 @@ kubectl -n go-insane-production get ingress go-guess kubectl -n go-insane-production get certificate kubectl -n go-insane-production rollout status deployment/go-guess-api kubectl -n go-insane-production rollout status deployment/go-guess-web -curl --resolve nmbs.guess.local:443:127.0.0.1 --cacert go-guess-ca.crt \ - https://nmbs.guess.local/api/health +curl --resolve nmbs.guess.urpi.be:443:127.0.0.1 --cacert go-guess-ca.crt \ + https://nmbs.guess.urpi.be/api/health ``` The test pod installed by `helm test` checks the API health endpoint and the web diff --git a/deploy/helm/go-guess/templates/_helpers.tpl b/deploy/helm/go-guess/templates/_helpers.tpl index f977336..3af2c62 100644 --- a/deploy/helm/go-guess/templates/_helpers.tpl +++ b/deploy/helm/go-guess/templates/_helpers.tpl @@ -53,7 +53,7 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- default "api" .Values.api.service.name }} {{- end }} -{{/* Host serving one tenant, for example nmbs.guess.local. */}} +{{/* Host serving one tenant, for example nmbs.guess.urpi.be. */}} {{- define "go-guess.tenantHost" -}} {{- printf "%s.%s" (index . 0) (index . 1) }} {{- end }} @@ -81,7 +81,7 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{/* Cluster-scoped by default so one issuer signs every tenant release. */}} {{- define "go-guess.certManagerIssuerRef" -}} {{- if .Values.certManager.issuerRef }} -toYaml .Values.certManager.issuerRef +{{- toYaml .Values.certManager.issuerRef | nindent 4 }} {{- else }} name: selfsigned kind: Issuer diff --git a/deploy/helm/go-guess/values-dev.yaml b/deploy/helm/go-guess/values-dev.yaml new file mode 100644 index 0000000..a03310c --- /dev/null +++ b/deploy/helm/go-guess/values-dev.yaml @@ -0,0 +1,181 @@ +# Development values for go-guess +nameOverride: "" +fullnameOverride: "" + +# Public application domain. Every tenant is served from https://.. +domain: guess-dev.urpi.be + +# Tenant slugs. Each one gets its own ingress host and TLS SAN. +tenants: + - nmbs + - ypto + +imagePullSecrets: [] + +api: + podSecurityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 100 + fsGroup: 100 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 100 + replicaCount: 1 + image: + repository: batty1039.startdedicated.net:5000/go-guess-api + tag: "latest" + pullPolicy: Always + environment: development + frontendURL: "" + tokenTTL: 8h + maxUploadMB: 10 + service: + name: api + type: ClusterIP + port: 8080 + goLoose: + enabled: true + authDomain: auth-dev.urpi.be + ca: + existingSecret: "" + key: ca.crt + mountPath: /certs/local-ca.crt + tenants: + nmbs: + clientID: "" + clientSecret: "" + ypto: + clientID: "" + clientSecret: "" + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + cpu: 300m + memory: 384Mi + podAnnotations: {} + podLabels: {} + strategy: + type: Recreate + +web: + replicaCount: 1 + image: + repository: batty1039.startdedicated.net:5000/go-guess-web + tag: "latest" + pullPolicy: Always + service: + type: ClusterIP + port: 80 + nodePort: null + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 200m + memory: 96Mi + podAnnotations: {} + podLabels: {} + podSecurityContext: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - CHOWN + - SETGID + - SETUID + - NET_BIND_SERVICE + readOnlyRootFilesystem: false + runAsNonRoot: false + runAsUser: 0 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 10m + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + cert-manager.io/cluster-issuer: letsencrypt-staging + path: / + pathType: Prefix + tls: + enabled: true + existingSecret: "" + secretName: "" + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer + duration: 8760h + renewBefore: 720h + caSecretName: "" + +secrets: + jwtSecret: "" + databaseURL: "" + rabbitmqURL: "" + postgresqlPassword: "" + rabbitmqPassword: "" + +postgresql: + enabled: true + image: + repository: postgres + tag: 18-alpine + pullPolicy: IfNotPresent + auth: + username: go_guess + database: go_guess + persistence: + enabled: true + size: 2Gi + storageClass: "" + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + cpu: 300m + memory: 384Mi + +rabbitmq: + enabled: true + image: + repository: rabbitmq + tag: 4-alpine + pullPolicy: IfNotPresent + auth: + username: go_guess + persistence: + enabled: true + size: 2Gi + storageClass: "" + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + cpu: 300m + memory: 384Mi + +nodeSelector: {} +tolerations: [] +affinity: {} \ No newline at end of file diff --git a/deploy/helm/go-guess/values-development.yaml b/deploy/helm/go-guess/values-development.yaml index 0edd694..2e91a2d 100644 --- a/deploy/helm/go-guess/values-development.yaml +++ b/deploy/helm/go-guess/values-development.yaml @@ -1,7 +1,7 @@ # Development profile: demo fixtures, ephemeral database and broker storage, and -# `latest` image tags. TLS still uses the chart's self-signed certificate so -# https://.guess.local behaves like the production profile. -domain: guess.local +# `latest` image tags. Browser sign-in goes through the development identity +# domain auth-dev.urpi.be, never the production auth.urpi.be. +domain: guess-dev.urpi.be tenants: - nmbs @@ -14,7 +14,7 @@ api: pullPolicy: Always goLoose: enabled: true - authDomain: auth.dev + authDomain: auth-dev.urpi.be resources: requests: cpu: 50m diff --git a/deploy/helm/go-guess/values-prd.yaml b/deploy/helm/go-guess/values-prd.yaml new file mode 100644 index 0000000..a3847ec --- /dev/null +++ b/deploy/helm/go-guess/values-prd.yaml @@ -0,0 +1,184 @@ +# Production values for go-guess +nameOverride: "" +fullnameOverride: "" + +# Public application domain. Every tenant is served from https://.. +domain: guess.urpi.be + +# Tenant slugs. Each one gets its own ingress host and TLS SAN. +tenants: + - nmbs + - ypto + +imagePullSecrets: [] + +api: + podSecurityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 100 + fsGroup: 100 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 100 + replicaCount: 2 + image: + repository: batty1039.startdedicated.net:5000/go-guess-api + tag: "latest" + pullPolicy: Always + environment: production + frontendURL: "" + tokenTTL: 8h + maxUploadMB: 10 + service: + name: api + type: ClusterIP + port: 8080 + goLoose: + enabled: true + authDomain: auth.urpi.be + ca: + existingSecret: "" + key: ca.crt + mountPath: /certs/local-ca.crt + tenants: + nmbs: + clientID: "" + clientSecret: "" + ypto: + clientID: "" + clientSecret: "" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + podAnnotations: {} + podLabels: {} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +web: + replicaCount: 2 + image: + repository: batty1039.startdedicated.net:5000/go-guess-web + tag: "latest" + pullPolicy: Always + service: + type: ClusterIP + port: 80 + nodePort: null + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + podAnnotations: {} + podLabels: {} + podSecurityContext: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + add: + - CHOWN + - SETGID + - SETUID + - NET_BIND_SERVICE + readOnlyRootFilesystem: false + runAsNonRoot: false + runAsUser: 0 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 10m + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + cert-manager.io/cluster-issuer: letsencrypt-prod + path: / + pathType: Prefix + tls: + enabled: true + existingSecret: "" + secretName: "" + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer + duration: 8760h + renewBefore: 720h + caSecretName: "" + +secrets: + jwtSecret: "" + databaseURL: "" + rabbitmqURL: "" + postgresqlPassword: "" + rabbitmqPassword: "" + +postgresql: + enabled: true + image: + repository: postgres + tag: 18-alpine + pullPolicy: IfNotPresent + auth: + username: go_guess + database: go_guess + persistence: + enabled: true + size: 20Gi + storageClass: "" + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +rabbitmq: + enabled: true + image: + repository: rabbitmq + tag: 4-alpine + pullPolicy: IfNotPresent + auth: + username: go_guess + persistence: + enabled: true + size: 10Gi + storageClass: "" + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +nodeSelector: {} +tolerations: [] +affinity: {} \ No newline at end of file diff --git a/deploy/helm/go-guess/values-production.yaml b/deploy/helm/go-guess/values-production.yaml index 6d7cee5..a9c6b4f 100644 --- a/deploy/helm/go-guess/values-production.yaml +++ b/deploy/helm/go-guess/values-production.yaml @@ -1,6 +1,6 @@ # Production profile: production fixtures, persistent volumes, released image -# tags, and self-signed certificates served for https://.guess.local. -domain: guess.local +# tags, and self-signed certificates served for https://.guess.urpi.be. +domain: guess.urpi.be tenants: - nmbs diff --git a/deploy/helm/go-guess/values.yaml b/deploy/helm/go-guess/values.yaml index da1bdda..7e40442 100644 --- a/deploy/helm/go-guess/values.yaml +++ b/deploy/helm/go-guess/values.yaml @@ -2,7 +2,7 @@ nameOverride: "" fullnameOverride: "" # Public application domain. Every tenant is served from https://.. -domain: guess.local +domain: guess.urpi.be # Tenant slugs. Each one gets its own ingress host and TLS SAN. tenants: @@ -12,6 +12,17 @@ tenants: imagePullSecrets: [] api: + podSecurityContext: + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 100 + fsGroup: 100 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 100 + runAsGroup: 100 replicaCount: 1 image: repository: batty1039.startdedicated.net:5000/go-guess-api @@ -30,8 +41,8 @@ api: type: ClusterIP port: 8080 goLoose: - enabled: false - authDomain: auth.dev + enabled: true + authDomain: auth.urpi.be ca: # Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it. existingSecret: "" @@ -54,18 +65,6 @@ api: memory: 512Mi podAnnotations: {} podLabels: {} - # The image runs as its own "app" user (uid 100), so no uid is forced here: - # runAsNonRoot then validates whatever the image declares. - podSecurityContext: {} - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - runAsNonRoot: true - # The API container applies migrations and seeds before serving, so old pods must - # be gone before the new one starts. strategy: type: Recreate diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 37d990c..cb7ef3d 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -55,8 +55,9 @@ curl --fail https://goguess.urpi.be/api/health The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ services should remain `ClusterIP` services without NodePorts. -Protected interviewer routes also call Go Loose from the application pod, using -`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the -request tenant). That address must be reachable from the cluster. The browser -forwards browser authentication and API requests without adding application -credentials. Nginx does not add or strip authorization headers. +Browser sign-in also calls Go Loose from the application pod, using +`GO_LOOSE_AUTH_DOMAIN` (`auth-dev.urpi.be` outside production, `auth.urpi.be` in +production) as `https://.`. That address must be +reachable from the pod. The browser forwards browser authentication and API +requests without adding application credentials. Nginx does not add or strip +authorization headers. diff --git a/docker-compose.bdd.yml b/docker-compose.bdd.yml index b8b0ef1..b72a97b 100644 --- a/docker-compose.bdd.yml +++ b/docker-compose.bdd.yml @@ -25,8 +25,6 @@ services: build: context: . dockerfile: Dockerfile - args: - VITE_GO_LOOSE_API_KEY: gl_bdd_key_that_is_long_enough_for_client environment: DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable&search_path=ypto JWT_SECRET: bdd-test-secret-that-is-at-least-32-characters diff --git a/docker-compose.yml b/docker-compose.yml index c806b3d..0db08dd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,10 +36,10 @@ services: environment: DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable JWT_SECRET: local-development-secret-change-me-12345 - FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess.local} + FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess-dev.urpi.be} APP_ENV: ${APP_ENV:-development} - GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth.dev} - GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess.dev} + GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth-dev.urpi.be} + GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess-dev.urpi.be} GO_LOOSE_CA_FILE: /certs/local-ca.crt GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-} GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-} @@ -52,7 +52,7 @@ services: - go_guess_network - local_dev_edge volumes: - - ${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro + - ${LOCAL_CA_FILE:-/home/dnoulet/go/infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro depends_on: db: condition: service_healthy diff --git a/frontend/README.md b/frontend/README.md index 47bf688..4312378 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -36,14 +36,18 @@ application shell. ## API integration -For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives -tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends +For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and +`nmbs.guess.urpi.be` in production, `src/api/client.ts` derives tenant `nmbs`, +calls same-origin `/api` through the shared HTTPS proxy, and sends `X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream -request. `ypto.guess.dev` behaves identically for tenant `ypto`. Other +request. `ypto.guess-dev.urpi.be` behaves identically for tenant `ypto`. Other hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case. -On the configured tenant hosts, the app checks `/api/auth/session` and redirects -to `/api/auth/login` when no Go Loose browser session exists. The callback +`src/components/actions/Account.tsx` treats any host whose first DNS label is a +tenant as a possible Go Loose host and asks `/api/auth/session` whether browser +login is available there. A `404` means the API has no Go Loose login for the +host, so the password form is shown; any other unauthenticated answer means Go +Loose is configured and the app redirects to `/api/auth/login`. The callback exchanges that session for the bearer token used by the typed API client. Invitation and participant-meeting URLs use the validated request origin so links stay on the tenant hostname. Copy actions fall back to a temporary selected @@ -53,7 +57,7 @@ Login stores the returned JWT in `sessionStorage`; protected requests send it as `Authorization: Bearer `. API failures are shown in the UI and are never replaced with demo data. A `401` from a protected JSON, file, or event-stream request clears authentication and returns the user to -`/login`. On `nmbs.guess.dev` and `ypto.guess.dev`, the app restores +`/login`. On a tenant host with Go Loose configured, the app restores `/api/auth/session` or redirects to `/api/auth/login`; participant routes stay token-based. The app also schedules logout from the JWT expiration claim so an idle expired session cannot remain on a protected page. diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index cb669a4..712c9da 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { api, apiBaseURL, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client' +import { api, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client' import { subscribeToDomainEvent } from './domainEvents' describe('API client', () => { @@ -8,12 +8,11 @@ describe('API client', () => { }) it('uses the same-origin API behind the local HTTPS proxy', () => { - expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs') - expect(tenantFromHostname('ypto.guess.local')).toBe('ypto') + expect(tenantFromHostname('nmbs.guess-dev.urpi.be')).toBe('nmbs') + expect(tenantFromHostname('ypto.guess-dev.urpi.be')).toBe('ypto') + expect(tenantFromHostname('nmbs.guess.urpi.be')).toBe('nmbs') expect(tenantFromHostname('localhost')).toBeNull() expect(tenantFromHostname('127.0.0.1')).toBeNull() - expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'https:' })).toBe('/api') - expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api') }) it('sends the persisted bearer token on protected requests', async () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7ee208e..11f5f1a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -79,16 +79,10 @@ export function tenantFromHostname(hostname: string): string | null { return labels.length >= 3 ? labels[0] : null } -export function apiBaseURL(location: Pick): string { - const hostname = location.hostname.toLowerCase().replace(/\.$/, '') - if (hostname.endsWith('.guess.local')) { - return '/api' - } - return '/api' -} - +// Every tenant host serves the API from its own origin: nginx and Traefik proxy +// /api to the API container, so the base is always the same-origin path. function apiURL(path: string): string { - return `${apiBaseURL(window.location)}${path}` + return `/api${path}` } function tenantHeader(): Record { diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index 217f3a5..1eef0fa 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -258,10 +258,17 @@ describe('Go Guess frontend', () => { 'href', '/invitations', ) - expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute( + // The person is reached through the account menu rather than a bare profile + // link, so the menu trigger names them and holds the actions. + const menu = within(accountNavigation).getByRole('button', { name: /Admin/ }) + expect(menu).toHaveAttribute('aria-expanded', 'false') + await userEvent.click(menu) + expect(within(accountNavigation).getByRole('link', { name: 'Profile' })).toHaveAttribute( 'href', '/profile', ) + expect(within(accountNavigation).getByRole('button', { name: 'Sign out' })).toBeInTheDocument() + await userEvent.keyboard('{Escape}') const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' }) expect( @@ -329,7 +336,10 @@ describe('Go Guess frontend', () => { renderApp('/participants/3') const input = await screen.findByLabelText(/replace cv/i) - await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' })) + await user.upload( + input, + new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }), + ) expect(await screen.findByText('React')).toBeInTheDocument() expect(fetch).toHaveBeenCalledWith( diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 711df6c..d5cf12f 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,33 +1,40 @@ -import {Route, Routes, useLocation} from 'react-router-dom' -import {useAuthSession} from '../components/actions' -import {NotFound} from '../components/ui/NotFound' -import {ParticipantInterviewPage} from '../features/assessments' -import {ParticipantMeetingPage} from '../features/interviews' -import {LoginPage} from '../features/login/LoginPage' -import {AuthenticatedApp} from './AuthenticatedApp' -import {DataProvider} from './DataContext' +import { Route, Routes, useLocation } from 'react-router-dom' +import { useAuthSession } from '../components/actions' +import { NotFound } from '../components/ui/NotFound' +import { ParticipantInterviewPage } from '../features/assessments' +import { ParticipantMeetingPage } from '../features/interviews' +import { LoginPage } from '../features/login/LoginPage' +import { AuthenticatedApp } from './AuthenticatedApp' +import { DataProvider } from './DataContext' export default function App() { - const location = useLocation(); - const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession(); - if (location.pathname.startsWith('/participant/')) { - return ( - - }/> - }/> - }/> - - ) - } - if (!authenticated) { - if (ssoStatus !== 'idle') { - return

Redirecting to sign in…

- } - return - } + const location = useLocation() + const { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } = useAuthSession() + if (location.pathname.startsWith('/participant/')) { return ( - - - + + } /> + } /> + } /> + ) + } + // The sign-in screen is the entry point, so nothing redirects to the identity + // provider on its own. While the API is still answering there is nothing + // useful to show, but not a spinner either: the button is correct either way, + // because it is what the screen offers whether or not Go Loose is configured. + if (!authenticated) { + return ( + + ) + } + return ( + + + + ) } diff --git a/frontend/src/app/AuthenticatedApp.tsx b/frontend/src/app/AuthenticatedApp.tsx index 2c1d489..3c61ed4 100644 --- a/frontend/src/app/AuthenticatedApp.tsx +++ b/frontend/src/app/AuthenticatedApp.tsx @@ -13,7 +13,7 @@ export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
- +
diff --git a/frontend/src/components/actions/Account.test.tsx b/frontend/src/components/actions/Account.test.tsx new file mode 100644 index 0000000..7ae6588 --- /dev/null +++ b/frontend/src/components/actions/Account.test.tsx @@ -0,0 +1,169 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isSSOHost, useAuthSession } from './Account' + +interface FakeLocation { + hostname: string + pathname: string + assign: ReturnType + replace: ReturnType +} + +function setHost(hostname: string, pathname = '/'): FakeLocation { + const location: FakeLocation = { + hostname, + pathname, + assign: vi.fn(), + replace: vi.fn(), + } + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: location, + }) + return location +} + +function json(body: unknown, status: number) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) +} + +function Probe() { + const { authenticated, ssoAvailable } = useAuthSession() + return

{`${String(ssoAvailable)}|${String(authenticated)}`}

+} + +function renderProbe(path = '/') { + return render( + + + , + ) +} + +const authResponse = { + token: 'sso-token', + user: { id: 1, email: 'ssuser@example.com', displayName: 'SSO', role: 'interviewer' }, +} + +describe('isSSOHost', () => { + it('recognises tenant hosts on the development and production domains', () => { + for (const hostname of [ + 'nmbs.guess-dev.urpi.be', + 'ypto.guess-dev.urpi.be', + 'nmbs.guess.urpi.be', + 'ypto.guess.urpi.be', + ]) { + setHost(hostname) + expect(isSSOHost(), hostname).toBe(true) + } + }) + + it('ignores localhost and raw addresses', () => { + for (const hostname of ['localhost', '127.0.0.1', '']) { + setHost(hostname) + expect(isSSOHost(), hostname).toBe(false) + } + }) +}) + +describe('useAuthSession Go Loose bootstrap', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('signs in from an existing Go Loose session without leaving the page', async () => { + const location = setHost('nmbs.guess-dev.urpi.be') + vi.stubGlobal( + 'fetch', + vi.fn(() => json(authResponse, 200)), + ) + + renderProbe() + + await waitFor(() => expect(screen.getByText('true|true')).toBeInTheDocument()) + expect(sessionStorage.getItem('go-guess-token')).toBe('sso-token') + expect(location.replace).not.toHaveBeenCalled() + }) + + it('leaves the sign-in screen in place when there is no session', async () => { + // Signing in starts from the screen and one button. Redirecting on sight would + // skip past the screen and fire on every anonymous visit, which reads as a + // redirect loop rather than as a choice to sign in. + const location = setHost('nmbs.guess.urpi.be') + vi.stubGlobal( + 'fetch', + vi.fn(() => json({ error: 'missing Go Loose session' }, 401)), + ) + + renderProbe() + + await waitFor(() => expect(screen.getByText('true|false')).toBeInTheDocument()) + expect(location.replace).not.toHaveBeenCalled() + expect(location.assign).not.toHaveBeenCalled() + }) + + it('keeps the single sign-on button when the API cannot be reached', async () => { + // Nothing answers, so there is no way to know whether Go Loose is configured. + // The button is the only way in on such a host, so it stays. + const location = setHost('nmbs.guess.urpi.be') + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('offline'))), + ) + + renderProbe() + + await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument()) + expect(location.assign).not.toHaveBeenCalled() + }) + + it('falls back to password sign-in when the API reports no Go Loose login', async () => { + const location = setHost('nmbs.guess-dev.urpi.be') + vi.stubGlobal( + 'fetch', + vi.fn(() => json({ error: 'Go Loose browser login is not enabled' }, 404)), + ) + + renderProbe() + + // false is what switches the screen from the button to the password form. + await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument()) + expect(location.replace).not.toHaveBeenCalled() + }) + + it('never probes the session outside a tenant host', async () => { + setHost('localhost') + const fetchMock = vi.fn(() => json(authResponse, 200)) + vi.stubGlobal('fetch', fetchMock) + + renderProbe() + + // A host with no tenant label cannot have Go Loose login, so it is false + // without asking the API anything. + await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument()) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('skips the bootstrap on participant routes', async () => { + const location = setHost('nmbs.guess-dev.urpi.be', '/participant/meeting/token') + const fetchMock = vi.fn(() => json(authResponse, 200)) + vi.stubGlobal('fetch', fetchMock) + + renderProbe('/participant/meeting/token') + + await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument()) + expect(fetchMock).not.toHaveBeenCalled() + expect(location.replace).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/actions/Account.tsx b/frontend/src/components/actions/Account.tsx index 51743bf..63e685c 100644 --- a/frontend/src/components/actions/Account.tsx +++ b/frontend/src/components/actions/Account.tsx @@ -1,67 +1,93 @@ import { useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client' +import { + authStorage, + invalidateSession, + tenantFromHostname, + SESSION_EXPIRED_EVENT, +} from '../../api/client' -const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev']) +const SESSION_PATH = '/api/auth/session' +const LOGOUT_PATH = '/api/auth/logout' +/** + * Every tenant is served from https://., on guess-dev.urpi.be + * locally and guess.urpi.be in production, so the first DNS label of a host + * with an app domain below it names the tenant. Those hosts may authenticate + * through Go Loose. Whether they actually do is answered by the API, which + * reports 404 for /api/auth/session on any host it has no Go Loose login for, + * and that host then falls back to password sign-in. + */ export function isSSOHost() { - return ssoHosts.has(window.location.hostname) + return tenantFromHostname(window.location.hostname) !== null } +/** + * Tracks who is signed in. + * + * There is deliberately no automatic redirect to the identity provider: signing in + * starts from the sign-in screen and one button. Redirecting on sight would skip + * past the screen, and would also fire on every anonymous visit to the + * application, which reads as a redirect loop rather than as a choice to sign in. + */ export function useAuthSession() { const location = useLocation() - const sso = isSSOHost() && !location.pathname.startsWith('/participant/') + // A participant opens the application through a token link and is never a + // member of the tenant, so nothing here may probe or redirect them. + const participant = location.pathname.startsWith('/participant/') + const navigate = useNavigate() const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token())) const [sessionExpired, setSessionExpired] = useState(false) - const [redirecting, setRedirecting] = useState(false) - const needsSSO = sso && !authenticated && !authStorage.token() - const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking' - const navigate = useNavigate() + // Whether Go Loose login is configured for this host. A host with no tenant + // label cannot have it, so that is known without asking: false there, and null + // on a tenant host until /api/auth/session has answered. + const [ssoAvailable, setSsoAvailable] = useState(() => + isSSOHost() ? null : false, + ) + const storedToken = authStorage.token() useEffect(() => { function handleSessionExpired() { setAuthenticated(false) setSessionExpired(true) - if (isSSOHost() && !window.location.pathname.startsWith('/participant/')) { - window.location.replace('/api/auth/login') - return - } - navigate('/login', { replace: true }) } - window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired) return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired) - }, [navigate]) + }, []) useEffect(() => { - if (!needsSSO) return + // A host without a tenant label cannot have Go Loose login configured, and a + // participant is not signing in at all. + if (participant || !isSSOHost() || storedToken) return let cancelled = false void (async () => { try { - const response = await fetch('/api/auth/session', { + const response = await fetch(SESSION_PATH, { headers: { Accept: 'application/json' }, credentials: 'same-origin', redirect: 'manual', }) if (cancelled) return + if (response.status === 404) { + // Go Loose login is not configured for this host: sign in with a password. + setSsoAvailable(false) + return + } + setSsoAvailable(true) if (response.ok) { authStorage.save(await response.json()) setAuthenticated(true) setSessionExpired(false) - return } } catch { - // The browser redirect below starts a fresh Go Loose login. - } - if (!cancelled) { - setRedirecting(true) - window.location.replace('/api/auth/login') + // An unreachable API leaves ssoAvailable null, which keeps the single + // sign-on button on screen: it is the only way in on such a host anyway. } })() return () => { cancelled = true } - }, [needsSSO]) + }, [storedToken, participant]) useEffect(() => { if (!authenticated) return @@ -79,6 +105,8 @@ export function useAuthSession() { function completeLogin() { setAuthenticated(true) setSessionExpired(false) + // The password path can be entered from any URL, so the welcome page is the + // destination. The Go Loose path is already sent here by its redirect. navigate('/', { replace: true }) } @@ -86,8 +114,10 @@ export function useAuthSession() { authStorage.clear() setAuthenticated(false) setSessionExpired(false) - if (isSSOHost()) window.location.assign('/api/auth/logout') + // Signing out also ends the Go Loose session, so the next application in the + // family asks for credentials again rather than letting this one back in. + if (isSSOHost()) window.location.assign(LOGOUT_PATH) } - return { authenticated, sessionExpired, ssoStatus, completeLogin, logout } + return { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } } diff --git a/frontend/src/components/layout/UserMenu.test.tsx b/frontend/src/components/layout/UserMenu.test.tsx new file mode 100644 index 0000000..e8bb8af --- /dev/null +++ b/frontend/src/components/layout/UserMenu.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import type { User } from '../../types' +import { UserMenu } from './UserMenu' + +const user: User = { + id: 1, + email: 'ada@example.test', + displayName: 'Ada Lovelace', + role: 'interviewer', + avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc', +} + +function renderMenu(onSignOut = vi.fn()) { + render( + + + , + ) + return onSignOut +} + +function trigger() { + return screen.getByRole('button', { name: /Ada Lovelace/ }) +} + +describe('UserMenu', () => { + it('starts closed and says so', () => { + renderMenu() + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('link', { name: 'Profile' })).not.toBeInTheDocument() + }) + + it('opens and closes on the trigger', async () => { + const user_ = userEvent.setup() + renderMenu() + await user_.click(trigger()) + expect(trigger()).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('link', { name: 'Profile' })).toHaveAttribute('href', '/profile') + + await user_.click(trigger()) + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + }) + + it('closes on Escape and returns focus to the trigger', async () => { + const user_ = userEvent.setup() + renderMenu() + await user_.click(trigger()) + await user_.keyboard('{Escape}') + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + expect(trigger()).toHaveFocus() + }) + + it('closes when the click lands outside it', async () => { + const user_ = userEvent.setup() + render( + +
+ + +
+
, + ) + await user_.click(trigger()) + await user_.click(screen.getByRole('button', { name: 'elsewhere' })) + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + }) + + it('signs out and closes', async () => { + const user_ = userEvent.setup() + const onSignOut = renderMenu() + await user_.click(trigger()) + await user_.click(screen.getByRole('button', { name: 'Sign out' })) + expect(onSignOut).toHaveBeenCalledTimes(1) + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + }) + + it('shows the picture and the address once open', async () => { + const user_ = userEvent.setup() + renderMenu() + await user_.click(trigger()) + const panel = screen.getByRole('link', { name: 'Profile' }).closest('.user-menu-panel')! + expect(within(panel as HTMLElement).getByText('ada@example.test')).toBeInTheDocument() + expect(within(panel as HTMLElement).getByTestId('avatar-image')).toBeInTheDocument() + }) + + it('falls back to the address when there is no name', () => { + render( + + + , + ) + expect(screen.getByRole('button', { name: /ada@example.test/ })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/layout/UserMenu.tsx b/frontend/src/components/layout/UserMenu.tsx new file mode 100644 index 0000000..fa11f5e --- /dev/null +++ b/frontend/src/components/layout/UserMenu.tsx @@ -0,0 +1,97 @@ +import { LogOut, UserRound } from 'lucide-react' +import { useEffect, useId, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import type { User } from '../../types' +import { Avatar } from '../ui/Avatar' + +/** + * The account menu in the workspace utilities bar. + * + * It is a disclosure rather than a hover menu: the trigger is a real button with + * aria-expanded, the panel is labelled, Escape closes it, a click anywhere else + * closes it, and focus returns to the trigger. A menu that only opens on hover + * cannot be used from the keyboard or on a touch screen. + * + * The panel is not an ARIA menu. Its two actions are a link and a button, and + * giving them menuitem roles would strip the link semantics from one of them for + * no gain: everything here is reachable by tabbing, which is the behaviour that + * matters. + */ +export function UserMenu({ user, onSignOut }: { user: User | null; onSignOut: () => void }) { + const [open, setOpen] = useState(false) + const container = useRef(null) + const trigger = useRef(null) + const panelId = useId() + + useEffect(() => { + if (!open) return + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false) + trigger.current?.focus() + } + } + function onPointerDown(event: MouseEvent) { + if (!container.current?.contains(event.target as Node)) { + setOpen(false) + } + } + document.addEventListener('keydown', onKeyDown) + document.addEventListener('mousedown', onPointerDown) + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('mousedown', onPointerDown) + } + }, [open]) + + const name = (user?.displayName || user?.email || 'Profile').trim() + const email = user?.email ?? '' + + return ( +
+ + + {open && ( +
+
+ +
+

{name}

+ {email &&

{email}

} +
+
+ setOpen(false)}> +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/WorkspaceUtilities.tsx b/frontend/src/components/layout/WorkspaceUtilities.tsx index c627174..8d66030 100644 --- a/frontend/src/components/layout/WorkspaceUtilities.tsx +++ b/frontend/src/components/layout/WorkspaceUtilities.tsx @@ -1,9 +1,15 @@ import { CalendarDays, Mail } from 'lucide-react' import { NavLink } from 'react-router-dom' import type { User } from '../../types' +import { UserMenu } from './UserMenu' -export function WorkspaceUtilities({ user }: { user: User | null }) { - const profileLabel = user?.displayName || user?.email || 'Profile' +export function WorkspaceUtilities({ + user, + onSignOut, +}: { + user: User | null + onSignOut: () => void +}) { return ( ) } diff --git a/frontend/src/components/ui/Avatar.test.tsx b/frontend/src/components/ui/Avatar.test.tsx new file mode 100644 index 0000000..1a517a6 --- /dev/null +++ b/frontend/src/components/ui/Avatar.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { User } from '../../types' +import { Avatar } from './Avatar' +import { initialsFor } from './initials' + +const withPicture = { + displayName: 'Ada Lovelace', + email: 'ada@example.test', + avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc', +} +const withoutPicture = { displayName: 'Ada Lovelace', email: 'ada@example.test' } + +function renderAvatar(user: Partial> | null) { + return render() +} + +describe('initialsFor', () => { + it('uses the first letter of the first two words', () => { + expect(initialsFor('Ada Lovelace')).toBe('AL') + expect(initialsFor('Ada Byron King Lovelace')).toBe('AB') + }) + + it('falls back to the address, and to a single name', () => { + expect(initialsFor('', 'ada@example.test')).toBe('AD') + expect(initialsFor('Prince')).toBe('PR') + }) + + it('has something to show for nothing at all', () => { + expect(initialsFor('', '')).toBe('?') + expect(initialsFor(null, null)).toBe('?') + }) +}) + +describe('Avatar', () => { + it('renders the picture Go Loose serves', () => { + renderAvatar(withPicture) + const image = screen.getByTestId('avatar-image').querySelector('img') + expect(image).toHaveAttribute('src', withPicture.avatarUrl) + // The surrounding element already names the person, so the image itself is + // decorative and must not be announced twice. + expect(image).toHaveAttribute('alt', '') + }) + + it('falls back to initials when there is no picture', () => { + renderAvatar(withoutPicture) + expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL') + }) + + it('falls back to initials when the stored link no longer resolves', () => { + // Go Loose retires a picture key when the picture changes, so a cached URL in + // this browser can be dead. A broken image icon would be worse than initials. + renderAvatar(withPicture) + const image = screen.getByTestId('avatar-image').querySelector('img')! + // An error event on an image does not bubble, so React only sees it when the + // event is dispatched through the testing library. + fireEvent.error(image) + expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL') + }) + + it('has something to show for no user at all', () => { + // With no person there is no name either, so the neutral placeholder the rest + // of the interface uses stands in. + renderAvatar(null) + expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('PR') + }) +}) + +describe('Avatar sizing', () => { + it('carries the requested size', () => { + const { container } = render() + const avatar = container.querySelector('.avatar')! + expect(avatar.className).toContain('tiny') + expect(avatar.className).toContain('extra') + }) +}) diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..6167dfd --- /dev/null +++ b/frontend/src/components/ui/Avatar.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import type { User } from '../../types' +import { initialsFor } from './initials' + +/** + * A person's picture, falling back to their initials. + * + * The URL comes from Go Loose and stops resolving the moment the picture is + * replaced or removed there, because retiring the key is what stops a stale link + * from being a privacy problem. A cached copy in this browser can therefore be + * dead, so a failed load falls back rather than leaving a broken image. An empty + * or absent URL is the common case for anyone who has not uploaded one. + */ +export function Avatar({ + user, + size = 'default', + className = '', +}: { + user: Partial> | null + size?: 'tiny' | 'default' | 'large' + className?: string +}) { + const url = user?.avatarUrl ?? '' + // Remembering which URL failed, rather than a bare flag, means a new picture + // for the same person is retried without an effect to reset anything. + const [failedUrl, setFailedUrl] = useState(null) + const failed = failedUrl === url && url !== '' + + const classes = ['avatar', size === 'default' ? '' : size, className].filter(Boolean).join(' ') + const label = (user?.displayName || user?.email || 'Profile').trim() + + if (!url || failed) { + return ( + + ) + } + return ( + + setFailedUrl(url)} /> + {label} + + ) +} diff --git a/frontend/src/components/ui/initials.ts b/frontend/src/components/ui/initials.ts new file mode 100644 index 0000000..f694917 --- /dev/null +++ b/frontend/src/components/ui/initials.ts @@ -0,0 +1,16 @@ +/** + * The person's initials, used until a picture loads and whenever there is none. + * The first letter of the first two words reads better than one letter, so + * "Ada Lovelace" gives AL rather than A. + */ +export function initialsFor(name: string | null | undefined, email?: string | null): string { + const source = (name ?? '').trim() || (email ?? '').trim() + if (!source) return '?' + // An address is not a name: "ada@example.test" must not become AE, so only the + // part before the @ counts. + const local = source.includes('@') ? source.slice(0, source.indexOf('@')) : source + const words = local.split(/[\s._-]+/).filter(Boolean) + if (words.length === 0) return '?' + if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase() + return (words[0]!.slice(0, 1) + words[1]!.slice(0, 1)).toUpperCase() +} diff --git a/frontend/src/features/login/LoginPage.test.tsx b/frontend/src/features/login/LoginPage.test.tsx new file mode 100644 index 0000000..e193f36 --- /dev/null +++ b/frontend/src/features/login/LoginPage.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LoginPage } from './LoginPage' + +function stubLocation() { + const location = { assign: vi.fn(), replace: vi.fn(), hostname: 'nmbs.guess.urpi.be' } + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: location, + }) + return location +} + +function renderPage(ssoAvailable: boolean | null) { + return render( + + + , + ) +} + +describe('LoginPage', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('offers only the Go Loose button while the answer is unknown', () => { + renderPage(null) + expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument() + expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/^password$/i)).not.toBeInTheDocument() + }) + + it('offers only the Go Loose button where Go Loose is configured', () => { + renderPage(true) + expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument() + expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument() + }) + + it('sends the browser to Go Loose rather than routing there', async () => { + // A router push cannot follow a redirect chain across two origins, so this has + // to be a real navigation. + const location = stubLocation() + const user = userEvent.setup() + renderPage(true) + await user.click(screen.getByRole('button', { name: /sign in with go loose/i })) + expect(location.assign).toHaveBeenCalledWith('/api/auth/login') + }) + + it('falls back to the password form on a host without Go Loose login', () => { + renderPage(false) + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument() + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /sign in with go loose/i })).not.toBeInTheDocument() + }) + + it('says why the password form is being shown', () => { + renderPage(false) + expect(screen.getByText(/single sign-on is not configured/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index b5ca12e..bd8daf0 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -1,20 +1,46 @@ -import { ArrowRight, CircleHelp, Sparkles } from 'lucide-react' +import { ArrowRight, CircleHelp, KeyRound, Sparkles } from 'lucide-react' import { SyntheticEvent, useState } from 'react' import Logo from '../../components/layout/Logo' import { FormField } from '../../components/form' import { ErrorAlert, errorMessage } from '../../components/actions' import { api, authStorage } from '../../api/client' +const LOGIN_PATH = '/api/auth/login' + +/** + * The sign-in screen. + * + * Signing in happens at Go Loose, so the usual case is one button that hands the + * browser over to it and comes back to the welcome page. That also means a person + * who already signed in at another application passes straight through, because + * Go Loose recognises the session it already holds and issues a code without + * asking anything. + * + * The password form is only for a host where Go Loose login is not configured: + * the API answers /api/auth/session with 404 there, and without a fallback the + * application would be unreachable on a bare localhost. It is not offered where + * the identity provider is in charge. + */ export function LoginPage({ onLogin, sessionExpired, + ssoAvailable, }: { onLogin: () => void sessionExpired: boolean + /** null until the API has said whether Go Loose login is configured here. */ + ssoAvailable: boolean | null }) { const [loading, setLoading] = useState(false) const [error, setError] = useState('') + function beginExternalLogin() { + setLoading(true) + // A full navigation, not a router push: Go Loose answers with a redirect + // chain across two origins that the router has no part in. + window.location.assign(LOGIN_PATH) + } + async function submit(event: SyntheticEvent) { event.preventDefault() setLoading(true) @@ -58,25 +84,46 @@ export function LoginPage({ Your session is no longer active. Sign in again to continue.
)} -

Use your work account to continue.

-
- - - - - - - {error && } - - +

+ Use your work account to continue. If you are already signed in elsewhere, this takes + you straight through. +

+ + {ssoAvailable === false ? ( +
+

+ Single sign-on is not configured for this host. Sign in with a local account. +

+ + + + + + + {error && } + + + ) : ( +
+ +
+ )} +

Contact your administrator for access.

diff --git a/frontend/src/features/users/ProfilePage.tsx b/frontend/src/features/users/ProfilePage.tsx index 0aa604d..e11123f 100644 --- a/frontend/src/features/users/ProfilePage.tsx +++ b/frontend/src/features/users/ProfilePage.tsx @@ -1,5 +1,6 @@ import { Mail, ShieldCheck, UserRound } from 'lucide-react' import { authStorage } from '../../api/client' +import { Avatar } from '../../components/ui/Avatar' export function ProfilePage() { const user = authStorage.user() @@ -14,9 +15,8 @@ export function ProfilePage() {
- + {/* The picture Go Loose serves, with the initials as the fallback. */} +

{user.displayName}

{user.role} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 528f010..acc504e 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2802,3 +2802,117 @@ fieldset { animation-iteration-count: 1 !important; } } + +/* Account menu. + The trigger sits in the utilities bar and the panel is anchored to it, so the + panel is positioned against a relatively positioned wrapper rather than the + viewport: it must stay with its trigger when the utilities bar wraps. */ +.user-menu { + position: relative; +} +.user-menu-trigger { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-height: 40px; + padding: 0.3rem 0.7rem 0.3rem 0.35rem; + background: transparent; + border: 1px solid transparent; + border-radius: 10px; + font: inherit; + color: var(--ink); + cursor: pointer; + text-align: left; + transition: + border-color 0.2s, + background 0.2s; +} +.user-menu-trigger:hover, +.user-menu-trigger:focus-visible { + border-color: var(--line); + background: var(--surface); +} +.user-menu-trigger:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; +} +.user-menu-identity { + display: grid; + line-height: 1.2; +} +.user-menu-name { + font-size: 0.84rem; + font-weight: 700; +} +.user-menu-email { + font-size: 0.72rem; + color: var(--muted); +} +.user-menu-panel { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 30; + min-width: 16rem; + padding: 0.5rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: var(--shadow); +} +.user-menu-header { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.6rem 0.7rem 0.85rem; + border-bottom: 1px solid var(--line); + margin-bottom: 0.4rem; +} +.user-menu-header p { + margin: 0; +} +.user-menu-panel-name { + font-weight: 700; +} +.user-menu-panel-email { + font-size: 0.78rem; + color: var(--muted); + overflow-wrap: anywhere; +} +.user-menu-item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.6rem 0.7rem; + background: transparent; + border: 0; + border-radius: 10px; + font: inherit; + font-size: 0.86rem; + font-weight: 600; + color: var(--ink); + text-decoration: none; + cursor: pointer; +} +.user-menu-item:hover, +.user-menu-item:focus-visible { + background: #eef6f7; +} +.user-menu-item:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: -2px; +} +.user-menu-panel + .user-menu-item { + margin-top: 0.15rem; +} +@media (max-width: 720px) { + /* The sidebar carries the same actions on a narrow screen, so the menu keeps + only the picture and its accessible name. */ + .user-menu-identity { + display: none; + } + .user-menu-trigger { + padding: 0.3rem; + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7499fc2..c2d4623 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,6 +5,12 @@ export interface User { email: string displayName: string role: string + /** + * Absolute URL of the picture Go Loose serves for this person, or empty when + * they have not uploaded one. Resolved on every sign-in rather than stored, so + * it always reflects the current picture. + */ + avatarUrl?: string } export interface AuthResponse { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6895cc8..9715e84 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,7 +4,14 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [react()], server: { - allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'], + allowedHosts: [ + 'nmbs.guess-dev.urpi.be', + 'ypto.guess-dev.urpi.be', + 'nmbs.guess.urpi.be', + 'ypto.guess.urpi.be', + 'nmbs.guess.local', + 'ypto.guess.local', + ], proxy: { '/api': { target: 'http://localhost:8080', diff --git a/rancher-install.md b/rancher-install.md deleted file mode 100644 index 9c18b67..0000000 --- a/rancher-install.md +++ /dev/null @@ -1,179 +0,0 @@ -# Rancher single-server installation - -This runbook reproduces the local Rancher management instance installed on the -dedicated server. It binds Rancher only to loopback; Nginx and Certbot provide -public access separately. - -This Docker-based Rancher installation is appropriate for a single-server, -non-HA environment. A production environment that requires control-plane high -availability should run Rancher with its Helm chart on a dedicated multi-node -Kubernetes cluster instead of copying this single-container topology. - -## Installed versions and paths - -- Rancher: `v2.15.1` -- Image: - `rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5` -- Persistent data: `/opt/rancher` -- Registry configuration: `~/.config/kubectl/registries.yaml` -- Local HTTP: `http://127.0.0.1:8000` -- Local HTTPS: `https://127.0.0.1:8443` -- Development application NodePort: `http://127.0.0.1:30080` -- Production application NodePort: `http://127.0.0.1:31374` - -Pinning the digest makes another installation reproducible. Review and update -the Rancher version deliberately rather than running an unpinned `latest` image -in production. - -## 1. Configure the HTTP image registry - -The source file is committed at `deploy/rancher/registries.yaml`. Install it: - -```bash -install -Dm0644 deploy/rancher/registries.yaml \ - "$HOME/.config/kubectl/registries.yaml" -``` - -Its content is: - -```yaml -mirrors: - "batty1039.startdedicated.net:5000": - endpoint: - - "http://batty1039.startdedicated.net:5000" - -configs: - "batty1039.startdedicated.net:5000": - tls: - insecure_skip_verify: true -``` - -The original draft contained duplicate YAML keys. Duplicate keys are invalid -for this purpose because parsers may silently discard one occurrence. - -## 2. Prepare persistent storage - -```bash -sudo install -d -m 0755 /opt/rancher -``` - -Back up this directory before Rancher upgrades. It contains the embedded K3s -management cluster and all Rancher state. - -## 3. Pull and run Rancher - -```bash -docker pull \ - rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5 - -docker run -d \ - --restart=unless-stopped \ - -p 127.0.0.1:8000:80 \ - -p 127.0.0.1:8443:443 \ - -p 127.0.0.1:10000:10000 \ - -p 127.0.0.1:10100:10100 \ - -v /opt/rancher:/var/lib/rancher \ - -v "$HOME/.config/kubectl/registries.yaml:/etc/rancher/k3s/registries.yaml:ro" \ - --privileged \ - --name rancher \ - rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5 -``` - -The explicit `127.0.0.1` bindings prevent Rancher and application NodePorts -from being published directly to the internet. Nginx on the same server can -still reach them. Docker cannot add port mappings to an existing container, so -new NodePorts must be included when recreating the Rancher container. - -## 4. Verify the installation - -Wait until the readiness endpoint returns `pong`: - -```bash -until curl -ksSf https://127.0.0.1:8443/ping | grep -q pong; do - sleep 5 -done - -docker ps --filter name='^/rancher$' -docker exec rancher kubectl get nodes -o wide -``` - -Expected results: - -- The container is running with restart policy `unless-stopped`. -- Only loopback ports `8000`, `8443`, `30080`, and `31374` are published. -- `https://127.0.0.1:8443/ping` returns `pong`. -- The embedded `local-node` reports `Ready`. - -After deploying Go Guess, verify the application NodePort: - -```bash -curl --fail http://127.0.0.1:30080/api/health -``` - -Production PostgreSQL and RabbitMQ require persistent volumes. Install the -single-node local-path provisioner before the production Helm release: - -```bash -make rancher-storage -make helm-deploy-production -``` - -The provisioner stores data under `/var/lib/rancher/k3s/storage`, which is -inside the persistent `/opt/rancher` host mount. Its StorageClass uses a -`Retain` reclaim policy so deleting a claim does not automatically delete the -underlying production data. - -Retrieve the one-time bootstrap password locally, open -`https://127.0.0.1:8443`, and immediately choose a permanent administrator -password: - -```bash -docker logs rancher 2>&1 | - sed -n 's/.*Bootstrap Password: //p' | - tail -n 1 -``` - -Do not save that password in this repository. - -## 5. Add Nginx and Certbot separately - -For a public Rancher hostname, configure one.com DNS to point at the dedicated -server. Proxy Nginx to `https://127.0.0.1:8443`, enable WebSocket upgrade -headers, and disable upstream certificate verification because Rancher's local -certificate is self-signed. Then request the public certificate: - -```bash -sudo certbot --nginx -d rancher.urpi.be -sudo certbot renew --dry-run -``` - -Expose only public TCP ports `80` and `443`. Keep `8000` and `8443` bound to -loopback as configured above. - -## Operations - -Inspect status and logs: - -```bash -docker ps --filter name='^/rancher$' -docker logs --tail 200 rancher -``` - -Restart Rancher: - -```bash -docker restart rancher -``` - -Create a consistent backup: - -```bash -docker stop rancher -sudo tar -C /opt -czf "rancher-backup-$(date +%Y%m%d-%H%M%S).tar.gz" rancher -docker start rancher -``` - -Before an upgrade, read the Rancher release notes and compatibility matrix, -back up `/opt/rancher`, pull a specifically pinned replacement image, remove -only the stopped `rancher` container, and recreate it with the same mounts and -port bindings.