feat: avatar, account menu, and single sign-on from Go Loose

Signing in now starts at the sign-in screen and one button that hands the
browser to Go Loose, which returns to the welcome page. Nothing redirects to the
identity provider on its own any more: that fired on every anonymous visit and
read as a redirect loop rather than as a choice to sign in. The password form
remains only where the API reports that Go Loose login is not configured for the
host, because otherwise the application is unreachable on a bare localhost.

A person's picture is carried through from the Go Loose identity and rendered in
the account menu and on the profile page, with their initials as the fallback.
Two things make that fallback necessary rather than decorative: Go Loose retires a
picture key when a picture is replaced, so a link cached in a browser can be dead,
and most people have not uploaded one. The URL is deliberately not persisted
locally, since a stored key would keep failing after the picture changed.

The account menu is a disclosure rather than a hover menu: a real button with
aria-expanded, Escape and outside clicks closing it, and focus returning to the
trigger. It is not an ARIA menu, because its actions are a link and a button and
menuitem roles would strip the link semantics from one of them for no gain.

The account URL of the Go Loose identity is resolved on each sign-in and is read
from the live identity provider every time, which is also what makes it correct
after a picture change.

Depends on the Go Loose revision that serves avatar_url through
/connect/userinfo, so the picture URL reaches this application at all.
This commit is contained in:
Daniel Noulet 2026-10-05 21:50:06 +02:00
parent 6be21597d5
commit 5449cd3d8d
52 changed files with 1927 additions and 434 deletions

View file

@ -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. domain types, and `src/styles/global.css` contains application-wide styles.
Keep feature-specific components inside their feature directory. Vite proxies Keep feature-specific components inside their feature directory. Vite proxies
`/api` to the Go service in development. `/api` to the Go service in development.
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the - Multi-tenant URLs use `<tenant>.<app domain>`, where the app domain is
tenant from the hostname, calls same-origin `/api` through the HTTPS proxy, and `guess-dev.urpi.be` in local development and the Kubernetes development
sends `X-Tenant-Id` on every request type. Other deployments use same-origin profile, and `guess.urpi.be` in production. The frontend derives the tenant from
`/api`. Generated participant links must use the validated matching tenant the hostname, calls same-origin `/api` through the HTTPS proxy, and sends
origin. Keep this contract documented in `README.md` and `frontend/README.md`. `X-Tenant-Id` on every request type. Generated participant links must use the
- Protected interviewer routes also require Go Loose `X-API-Key` authorization validated matching tenant origin. Keep this contract documented in `README.md`
for application `guess`. The tenant passed to Go Loose comes from and `frontend/README.md`.
`X-Tenant-Id`. `GO_LOOSE_BASE_URL` defaults to `https://%s.auth.dev`; - Protected interviewer routes require a bearer JWT only. Go Loose is used for
`%s` is that tenant and must be one DNS label. Do not require the API key on browser login, not for API-key authorization.
`/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.
Goose files in `backend/migrations` are schema-only and run in every environment. Goose files in `backend/migrations` are schema-only and run in every environment.
`internal/database/seed/common.sql` contains shared reference data; `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 Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`, protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token `/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev` participant interview routes. Any tenant host starts browser login with
start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects `github.com/danyel/go-loose/client`: `/api/auth/login` redirects to
to `https://<tenant>.auth.dev/connect/authorize` and Go Loose returns to `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize` and Go Loose returns to
`/api/auth/callback`. Go Loose client credentials come from uncommitted `/api/auth/callback`. `GO_LOOSE_AUTH_DOMAIN` is `auth-dev.urpi.be` locally and in
environment variables. Missing tenant context uses the public schema; a the development profile, and `auth.urpi.be` in production; `GO_LOOSE_APP_DOMAIN`
`*.guess.dev` host supplies the schema when `X-Tenant-Id` is absent. Never expose is the matching application domain. Both default to the development domains.
password hashes, CV/photo bytes, or interviewer reference answers in public JSON. `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 Questions are permanent reusable records linked to jobs through
`job_posting_questions`; detach links rather than deleting questions. Deprecated `job_posting_questions`; detach links rather than deleting questions. Deprecated

View file

@ -37,6 +37,7 @@ COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint
USER app USER app
USER 1000
EXPOSE 8080 EXPOSE 8080
ENV ADDRESS=:8081 \ ENV ADDRESS=:8081 \
APP_ENV=production \ APP_ENV=production \

View file

@ -1,5 +1,5 @@
SHELL := /bin/sh SHELL := /bin/sh
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
JWT_SECRET ?= local-development-secret-change-me-12345 JWT_SECRET ?= local-development-secret-change-me-12345
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0 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 test-env-down stops the test image"
@echo "make lint runs lint on the frontend" @echo "make lint runs lint on the frontend"
@echo "make build builds the entire project" @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 rancher-storage installs the persistent local-path StorageClass"
@echo "make ingress-nginx-install installs the ingress-nginx controller serving 80/443" @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" @echo "make cert-manager-install installs cert-manager for chart-issued TLS certificates"
@ -109,6 +112,17 @@ build:
cd backend && go build ./... cd backend && go build ./...
cd frontend && npm run 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: rancher-storage:
KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml

View file

@ -21,33 +21,46 @@ make dev
``` ```
`docker compose up -d --build` serves the UI through the local Traefik edge at `docker compose up -d --build` serves the UI through the local Traefik edge at
<https://nmbs.guess.dev> and <https://ypto.guess.dev>. Add the hostnames once: <https://nmbs.guess-dev.urpi.be> and <https://ypto.guess-dev.urpi.be>. Browser
sign-in goes through the development Go Loose installation at
<https://nmbs.auth-dev.urpi.be>. Both domains must resolve to the local edge:
```text ```text
127.0.0.1 nmbs.guess.dev ypto.guess.dev 127.0.0.1 nmbs.guess-dev.urpi.be ypto.guess-dev.urpi.be
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev 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 Start the shared TLS proxy described in
`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open `/home/dnoulet/go/infrastructure/docs/APPLICATIONS.md`, then open
<https://nmbs.guess.dev> or <https://ypto.guess.dev>. The hostname's first <https://nmbs.guess-dev.urpi.be> or <https://ypto.guess-dev.urpi.be>. The
label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the hostname's first label is the tenant ID: the frontend calls same-origin `/api` and
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream sends the same value in the `X-Tenant-Id` header on JSON, multipart,
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and file-download, and event-stream requests. The backend uses that tenant ID as the
builds participant invitation and meeting links from the matching tenant browser isolated PostgreSQL schema and builds participant invitation and meeting links
origin instead of the static localhost fallback. from the matching tenant browser origin instead of the static localhost fallback.
Compose mounts the infrastructure CA from Compose mounts the infrastructure CA from
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The `${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API at
browser is redirected through the matching Go Loose tenant and returns to `/certs/local-ca.crt`, which `GO_LOOSE_CA_FILE` points at so the API trusts the
`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and local Traefik certificate. The browser is redirected through the matching Go Loose
`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them. 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: Sign in with:
- Email: `interviewer@ypto.local` - Email: `interviewer@ypto.local`
- Password: `admin123` - 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: To run the applications outside containers:
```bash ```bash
@ -60,7 +73,8 @@ make frontend
The API listens on port `8080` inside Docker and is exposed to browsers only 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 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 the `Authorization` and `X-Tenant-Id` request headers. Non-local
deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET` deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET`
outside local development. outside local development.
@ -68,7 +82,7 @@ outside local development.
## API contract ## API contract
The backend exposes its current OpenAPI 3.0 contract at The backend exposes its current OpenAPI 3.0 contract at
<https://nmbs.guess.dev/api/openapi.json>. The document is assembled in memory <https://nmbs.guess-dev.urpi.be/api/openapi.json>. The document is assembled in memory
from the same typed registrations that add routes to Chi, including request and from the same typed registrations that add routes to Chi, including request and
response schemas, status codes, authentication, tenant headers, path/query response schemas, status codes, authentication, tenant headers, path/query
parameters, multipart uploads, downloads, and event streams. It therefore 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 tenant header. Every other route declares required `X-Tenant-Id`. Protected
operations also declare bearer authentication. 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://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`; Go Loose
returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the
resulting secure browser session for the application's bearer JWT. The tenant resulting secure browser session for the application's bearer JWT. The tenant is
is derived from the request host, or from `X-Tenant-Id` for API calls. 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`, | Environment | `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: | 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` - `GET /api/health` and `GET /api/openapi.json`
- browser login, callback, logout, and session routes under `/api/auth` - 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 Login and participant routes still require `X-Tenant-Id`. Health and OpenAPI do
not. Browser authentication routes derive the tenant from the configured host. 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 ## Switch the Git remote
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between 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 DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \
-e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \ -e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \
-e JWT_SECRET='replace-with-at-least-32-characters' \ -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 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 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 their PostgreSQL and RabbitMQ. Every tenant gets its own HTTPS host at
`https://<tenant>.guess.local`. The chart values are documented in `https://<tenant>.guess.urpi.be` in production and
`https://<tenant>.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). [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 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 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 through Go Loose needs the per-tenant client credentials; pass them from the
environment to `make helm-deploy-production`: environment to `make helm-deploy-production`:
@ -275,8 +305,9 @@ the registry in production when possible.
## Architecture ## Architecture
Requests enter the Chi router and web handlers under `backend/internal/web`. Requests enter the Chi router and web handlers under `backend/internal/web`.
Tenant isolation runs first. The protected interviewer group then applies Go Tenant isolation runs first. The protected interviewer group then requires a
Loose API-key authorization and JWT authentication. 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 Web request/response models are translated to service models before business logic
runs. Services own validation, CV trait extraction, and candidate scoring. The runs. Services own validation, CV trait extraction, and candidate scoring. The
repository maps service models to GORM entities and persists them in PostgreSQL. repository maps service models to GORM entities and persists them in PostgreSQL.

View file

@ -3,7 +3,7 @@ module github.com/danyel/go-guess/backend
go 1.27.1 go 1.27.1
require ( 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/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0

View file

@ -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/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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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-20261005194805-49ab89ae46fd h1:YCvHmqMNr0cfEqdAzRyVe4MOn+yYyMh8/FLSMydvYQM=
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -31,6 +31,20 @@ type GoLooseTenant struct {
ClientSecret string 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 // GoLooseCredentialEnv returns the environment variable names holding the Go
// Loose client credentials of a tenant. // Loose client credentials of a tenant.
func GoLooseCredentialEnv(slug string) (idEnv, secretEnv string) { func GoLooseCredentialEnv(slug string) (idEnv, secretEnv string) {
@ -63,10 +77,17 @@ func Load() (Config, error) {
return cfg, nil 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.<AuthDomain> and is served from https://nmbs.<AppDomain>.
//
// 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 { func loadGoLoose() GoLooseConfig {
cfg := GoLooseConfig{ cfg := GoLooseConfig{
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", "auth.dev"), AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", DefaultAuthDomain),
AppDomain: env("GO_LOOSE_APP_DOMAIN", "guess.dev"), AppDomain: env("GO_LOOSE_APP_DOMAIN", DefaultAppDomain),
CAFile: os.Getenv("GO_LOOSE_CA_FILE"), CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
Tenants: map[string]GoLooseTenant{}, Tenants: map[string]GoLooseTenant{},
} }

View file

@ -24,16 +24,13 @@ type Registry struct {
func New(cfg config.GoLooseConfig) (*Registry, error) { func New(cfg config.GoLooseConfig) (*Registry, error) {
registry := &Registry{ registry := &Registry{
appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."), appDomain: domain(cfg.AppDomain),
bySlug: map[string]*goloose.BrowserAuth{}, bySlug: map[string]*goloose.BrowserAuth{},
} }
if registry.appDomain == "" {
registry.appDomain = "guess.dev"
}
if len(cfg.Tenants) == 0 { if len(cfg.Tenants) == 0 {
return registry, nil return registry, nil
} }
authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".") authDomain := domain(cfg.AuthDomain)
if authDomain == "" { if authDomain == "" {
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured") 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 return nil, err
} }
for slug, tenant := range cfg.Tenants { for slug, tenant := range cfg.Tenants {
slug = strings.ToLower(strings.TrimSpace(slug)) slug = domain(slug)
if slug == "" || strings.Contains(slug, ".") { if slug == "" || strings.Contains(slug, ".") {
return nil, fmt.Errorf("invalid Go Loose tenant slug %q", 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 { func (r *Registry) AppDomain() string {
if r == nil || r.appDomain == "" { if r == nil || r.appDomain == "" {
return "guess.dev" return config.DefaultAppDomain
} }
return r.appDomain 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) { func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
if r == nil { if r == nil {
return nil, "", false return nil, "", false
@ -82,16 +81,21 @@ func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
return auth, slug, ok 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 { 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 { if parsed, _, err := net.SplitHostPort(host); err == nil {
hostname = strings.ToLower(parsed) hostname = strings.ToLower(strings.TrimSuffix(parsed, "."))
} }
appDomain = strings.TrimPrefix(strings.ToLower(appDomain), ".") suffix := "." + domain(appDomain)
if appDomain == "" {
appDomain = "guess.dev"
}
suffix := "." + appDomain
if !strings.HasSuffix(hostname, suffix) { if !strings.HasSuffix(hostname, suffix) {
return "" return ""
} }
@ -102,6 +106,11 @@ func TenantFromHost(host, appDomain string) string {
return slug 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) { func client(caFile string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
if caFile != "" { if caFile != "" {

View file

@ -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")
}

View file

@ -8,6 +8,10 @@ type User struct {
PasswordHash string PasswordHash string
DisplayName string DisplayName string
Role 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 { type Trait struct {

View file

@ -28,7 +28,7 @@ const CandidateThreshold = 60.0
type IAuthService interface { type IAuthService interface {
Login(context.Context, string, string) (string, model.User, error) 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 { type IJobService interface {
@ -89,9 +89,19 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
return token, user, nil return token, user, nil
} }
func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) { // ExternalIdentity is what Go Loose reports about the person signing in. The
email = strings.ToLower(strings.TrimSpace(email)) // avatar URL is deliberately not persisted: Go Loose retires a picture key when
displayName = strings.TrimSpace(displayName) // 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 == "" { if email == "" {
return "", model.User{}, ErrValidation return "", model.User{}, ErrValidation
} }
@ -116,6 +126,7 @@ func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displ
return "", model.User{}, err return "", model.User{}, err
} }
user.PasswordHash = "" user.PasswordHash = ""
user.AvatarURL = strings.TrimSpace(identity.AvatarURL)
return token, user, nil return token, user, nil
} }

View file

@ -653,8 +653,10 @@ func (h *Handler) frontendURLForRequest(r *http.Request) string {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return h.frontendURL return h.frontendURL
} }
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") || // Only trust the Origin when it is the browser origin of the requesting
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") { // 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 origin
} }
return h.frontendURL return h.frontendURL

View file

@ -1,17 +1,22 @@
package handler package handler
import ( import (
"encoding/json"
"net/http/httptest" "net/http/httptest"
"testing" "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) { func TestFrontendURLForTenantRequest(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"} handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "http://ypto.guess.local:5173") 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" { if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
t.Fatalf("unexpected frontend URL %q", value) t.Fatalf("unexpected frontend URL %q", value)
@ -22,7 +27,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"} handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "http://nmbs.guess.local:5173") 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 { if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
t.Fatalf("expected configured frontend URL, got %q", value) t.Fatalf("expected configured frontend URL, got %q", value)
@ -32,10 +37,87 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) { func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) {
handler := &Handler{frontendURL: "http://localhost:5173"} handler := &Handler{frontendURL: "http://localhost:5173"}
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil) request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
request.Header.Set("Origin", "https://ypto.guess.dev") request.Header.Set("Origin", "https://ypto."+config.DefaultAppDomain)
request.Header.Set(client.XTenantId, "ypto") 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) 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)
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/danyel/go-guess/backend/internal/config" "github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-guess/backend/internal/golooseauth" "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" webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
webmodel "github.com/danyel/go-guess/backend/internal/web/model" 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 { func (h *Handler) AppDomain() string {
if h.browser == nil { if h.browser == nil {
return "guess.dev" return config.DefaultAppDomain
} }
return h.browser.AppDomain() return h.browser.AppDomain()
} }
@ -74,7 +75,7 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, errTenantMismatch) writeError(w, http.StatusForbidden, errTenantMismatch)
return 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 { if err != nil {
internalError(w, r, err) internalError(w, r, err)
return return
@ -83,6 +84,17 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
})).ServeHTTP(w, r) })).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) { func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) {
auth, _, ok := h.externalAuthWithSlug(r) auth, _, ok := h.externalAuthWithSlug(r)
return auth, ok return auth, ok
@ -92,7 +104,7 @@ func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, s
if h.browser == nil { if h.browser == nil {
return nil, "", false 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) { func writeExternalAuthError(w http.ResponseWriter, h *Handler, slug string) {

View file

@ -94,6 +94,7 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
func UserToWeb(value servicemodel.User) webmodel.User { func UserToWeb(value servicemodel.User) webmodel.User {
return webmodel.User{ return webmodel.User{
ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role, ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role,
AvatarURL: value.AvatarURL,
} }
} }

View file

@ -11,11 +11,18 @@ import (
"sync" "sync"
"time" "time"
"github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-loose/client" "github.com/danyel/go-loose/client"
) )
const guessApplication = "guess" 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. // 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})?$`) 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. // from the X-Tenant-Id header, not from process configuration.
// //
// GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The // 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 { func GoLooseMiddleware() func(http.Handler) http.Handler {
pattern := os.Getenv("GO_LOOSE_BASE_URL") pattern := os.Getenv("GO_LOOSE_BASE_URL")
if pattern == "" { if pattern == "" {
pattern = "https://%s.auth.dev" pattern = defaultBaseURLPattern()
} }
httpClient := &http.Client{Timeout: 2 * time.Second} httpClient := &http.Client{Timeout: 2 * time.Second}
var ( var (

View file

@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-loose/client" "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) { 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) { handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("handler reached for unsafe tenant") t.Fatal("handler reached for unsafe tenant")
})) }))

View file

@ -28,7 +28,7 @@ func TenantSchemaMiddleware(db *gorm.DB, appDomain string) func(http.Handler) ht
} }
tenantID := r.Header.Get(client.XTenantId) tenantID := r.Header.Get(client.XTenantId)
if tenantID == "" { if tenantID == "" {
tenantID = golooseauth.TenantFromHost(r.Host, appDomain) tenantID = golooseauth.TenantFromHost(golooseauth.Host(r), appDomain)
} }
if tenantID == "" || db == nil { if tenantID == "" || db == nil {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)

View file

@ -21,6 +21,9 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` 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 { type CreateUserRequest struct {

View file

@ -21,7 +21,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
router := chi.NewRouter() router := chi.NewRouter()
spec := openapi.New() spec := openapi.New()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5)) 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(mw.TenantSchemaMiddleware(h.Db(), h.AppDomain()))
router.Use(middleware.Timeout(2 * time.Minute)) router.Use(middleware.Timeout(2 * time.Minute))
register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP, 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestOrigin := r.Header.Get("Origin") 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-Origin", requestOrigin)
} }
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id") 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://<tenant>.<appDomain>:5173 sends. *.guess.local stays allowed for the
// offline BDD and local password-only setups.
func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool {
if requestOrigin == "" { if requestOrigin == "" {
return false return false
} }
@ -194,5 +198,11 @@ func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return false 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
} }

View file

@ -69,7 +69,7 @@ func (authStub) Login(context.Context, string, string) (string, model.User, erro
return "", model.User{}, service.ErrInvalidCredentials 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 return "", model.User{}, service.ErrInvalidCredentials
} }
@ -313,8 +313,8 @@ func TestOpenAPIDoesNotRequireTenant(t *testing.T) {
func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) { func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
h := newTestHandler(t, nil) h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{ browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: "auth.dev", AuthDomain: config.DefaultAuthDomain,
AppDomain: "guess.dev", AppDomain: config.DefaultAppDomain,
Tenants: map[string]config.GoLooseTenant{ Tenants: map[string]config.GoLooseTenant{
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}, "nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"}, "ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
@ -328,14 +328,14 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
for _, slug := range []string{"nmbs", "ypto"} { for _, slug := range []string{"nmbs", "ypto"} {
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = slug + ".guess.dev" request.Host = slug + ".guess-dev.urpi.be"
response := httptest.NewRecorder() response := httptest.NewRecorder()
api.ServeHTTP(response, request) api.ServeHTTP(response, request)
location := response.Header().Get("Location") 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) 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) t.Fatalf("%s callback missing from %s", slug, location)
} }
} }
@ -344,8 +344,8 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) { func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) {
h := newTestHandler(t, nil) h := newTestHandler(t, nil)
browser, err := golooseauth.New(config.GoLooseConfig{ browser, err := golooseauth.New(config.GoLooseConfig{
AuthDomain: "auth.dev", AuthDomain: config.DefaultAuthDomain,
AppDomain: "guess.dev", AppDomain: config.DefaultAppDomain,
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}}, Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
}) })
if err != nil { if err != nil {
@ -355,7 +355,7 @@ func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) {
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173") api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "ypto.guess.dev" request.Host = "ypto.guess-dev.urpi.be"
response := httptest.NewRecorder() response := httptest.NewRecorder()
api.ServeHTTP(response, request) api.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || if response.Code != http.StatusServiceUnavailable ||
@ -364,7 +364,7 @@ func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) {
} }
request = httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) request = httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "guess.dev" request.Host = "guess-dev.urpi.be"
response = httptest.NewRecorder() response = httptest.NewRecorder()
api.ServeHTTP(response, request) api.ServeHTTP(response, request)
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "tenant") { if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "tenant") {
@ -379,7 +379,7 @@ func TestLoginWithoutGoLooseRegistryIsNotFound(t *testing.T) {
"http://localhost:5173", "http://localhost:5173",
) )
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil) request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
request.Host = "nmbs.guess.dev" request.Host = "nmbs.guess-dev.urpi.be"
response := httptest.NewRecorder() response := httptest.NewRecorder()
api.ServeHTTP(response, request) api.ServeHTTP(response, request)
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") { 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{} type stubDriver struct{}
func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil } func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil }

View file

@ -2,7 +2,7 @@
This chart deploys Go Guess as two images, an API and a web front end, plus its 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 own PostgreSQL and RabbitMQ, and publishes one HTTPS host per tenant under
`https://<tenant>.guess.local`. `https://<tenant>.guess.urpi.be`.
## 1. What the chart creates ## 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: `tenants` is the list of slugs and `domain` is the shared parent domain:
```yaml ```yaml
domain: guess.local domain: guess.urpi.be
tenants: tenants:
- nmbs - nmbs
- ypto - ypto
@ -83,8 +83,8 @@ tenants:
The chart derives, without any extra configuration: The chart derives, without any extra configuration:
- `GO_LOOSE_APP_DOMAIN=guess.local`, so the API accepts `nmbs.guess.local` and - `GO_LOOSE_APP_DOMAIN=<domain>`, so the API accepts `nmbs.<domain>` and
`ypto.guess.local` as tenant hosts. `ypto.<domain>` as tenant hosts.
- One ingress rule per tenant, all pointing at `go-guess-web`. - One ingress rule per tenant, all pointing at `go-guess-web`.
- One certificate SAN per tenant, so a single secret covers every host. - One certificate SAN per tenant, so a single secret covers every host.
- `FRONTEND_URL`, when `api.frontendURL` is empty, as - `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 | | `nameOverride` | `""` | Overrides the chart name in resource names and labels |
| `fullnameOverride` | `""` | Replaces the generated resource name prefix | | `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 | | `tenants` | `[nmbs, ypto]` | Tenant slugs served by the release |
| `imagePullSecrets` | `[]` | Pull secrets added to both pods | | `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.name` | `api` | Service DNS name, fixed by the web image |
| `api.service.type` | `ClusterIP` | API service type | | `api.service.type` | `ClusterIP` | API service type |
| `api.service.port` | `8080` | API service port | | `api.service.port` | `8080` | API service port |
| `api.goLoose.enabled` | `false` | Enables Go Loose browser sign-in | | `api.goLoose.enabled` | `true` | Enables Go Loose browser sign-in |
| `api.goLoose.authDomain` | `auth.dev` | Tenant login host `https://<tenant>.<authDomain>` | | `api.goLoose.authDomain` | `auth.urpi.be` | Tenant login host `https://<tenant>.<authDomain>`. `auth-dev.urpi.be` in the development profile |
| `api.goLoose.ca.existingSecret` | `""` | Secret holding the Go Loose CA bundle | | `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.key` | `ca.crt` | Key projected from that secret |
| `api.goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target | | `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 get certificate
kubectl -n go-insane-production rollout status deployment/go-guess-api kubectl -n go-insane-production rollout status deployment/go-guess-api
kubectl -n go-insane-production rollout status deployment/go-guess-web 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 \ curl --resolve nmbs.guess.urpi.be:443:127.0.0.1 --cacert go-guess-ca.crt \
https://nmbs.guess.local/api/health https://nmbs.guess.urpi.be/api/health
``` ```
The test pod installed by `helm test` checks the API health endpoint and the web The test pod installed by `helm test` checks the API health endpoint and the web

View file

@ -53,7 +53,7 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- default "api" .Values.api.service.name }} {{- default "api" .Values.api.service.name }}
{{- end }} {{- end }}
{{/* Host serving one tenant, for example nmbs.guess.local. */}} {{/* Host serving one tenant, for example nmbs.guess.urpi.be. */}}
{{- define "go-guess.tenantHost" -}} {{- define "go-guess.tenantHost" -}}
{{- printf "%s.%s" (index . 0) (index . 1) }} {{- printf "%s.%s" (index . 0) (index . 1) }}
{{- end }} {{- end }}
@ -81,7 +81,7 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{/* Cluster-scoped by default so one issuer signs every tenant release. */}} {{/* Cluster-scoped by default so one issuer signs every tenant release. */}}
{{- define "go-guess.certManagerIssuerRef" -}} {{- define "go-guess.certManagerIssuerRef" -}}
{{- if .Values.certManager.issuerRef }} {{- if .Values.certManager.issuerRef }}
toYaml .Values.certManager.issuerRef {{- toYaml .Values.certManager.issuerRef | nindent 4 }}
{{- else }} {{- else }}
name: selfsigned name: selfsigned
kind: Issuer kind: Issuer

View file

@ -0,0 +1,181 @@
# Development values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
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: {}

View file

@ -1,7 +1,7 @@
# Development profile: demo fixtures, ephemeral database and broker storage, and # Development profile: demo fixtures, ephemeral database and broker storage, and
# `latest` image tags. TLS still uses the chart's self-signed certificate so # `latest` image tags. Browser sign-in goes through the development identity
# https://<tenant>.guess.local behaves like the production profile. # domain auth-dev.urpi.be, never the production auth.urpi.be.
domain: guess.local domain: guess-dev.urpi.be
tenants: tenants:
- nmbs - nmbs
@ -14,7 +14,7 @@ api:
pullPolicy: Always pullPolicy: Always
goLoose: goLoose:
enabled: true enabled: true
authDomain: auth.dev authDomain: auth-dev.urpi.be
resources: resources:
requests: requests:
cpu: 50m cpu: 50m

View file

@ -0,0 +1,184 @@
# Production values for go-guess
nameOverride: ""
fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
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: {}

View file

@ -1,6 +1,6 @@
# Production profile: production fixtures, persistent volumes, released image # Production profile: production fixtures, persistent volumes, released image
# tags, and self-signed certificates served for https://<tenant>.guess.local. # tags, and self-signed certificates served for https://<tenant>.guess.urpi.be.
domain: guess.local domain: guess.urpi.be
tenants: tenants:
- nmbs - nmbs

View file

@ -2,7 +2,7 @@ nameOverride: ""
fullnameOverride: "" fullnameOverride: ""
# Public application domain. Every tenant is served from https://<tenant>.<domain>. # Public application domain. Every tenant is served from https://<tenant>.<domain>.
domain: guess.local domain: guess.urpi.be
# Tenant slugs. Each one gets its own ingress host and TLS SAN. # Tenant slugs. Each one gets its own ingress host and TLS SAN.
tenants: tenants:
@ -12,6 +12,17 @@ tenants:
imagePullSecrets: [] imagePullSecrets: []
api: api:
podSecurityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
fsGroup: 100
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 100
runAsGroup: 100
replicaCount: 1 replicaCount: 1
image: image:
repository: batty1039.startdedicated.net:5000/go-guess-api repository: batty1039.startdedicated.net:5000/go-guess-api
@ -30,8 +41,8 @@ api:
type: ClusterIP type: ClusterIP
port: 8080 port: 8080
goLoose: goLoose:
enabled: false enabled: true
authDomain: auth.dev authDomain: auth.urpi.be
ca: ca:
# Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it. # Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it.
existingSecret: "" existingSecret: ""
@ -54,18 +65,6 @@ api:
memory: 512Mi memory: 512Mi
podAnnotations: {} podAnnotations: {}
podLabels: {} 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: strategy:
type: Recreate type: Recreate

View file

@ -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 The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ
services should remain `ClusterIP` services without NodePorts. services should remain `ClusterIP` services without NodePorts.
Protected interviewer routes also call Go Loose from the application pod, using Browser sign-in also calls Go Loose from the application pod, using
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the `GO_LOOSE_AUTH_DOMAIN` (`auth-dev.urpi.be` outside production, `auth.urpi.be` in
request tenant). That address must be reachable from the cluster. The browser production) as `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>`. That address must be
forwards browser authentication and API requests without adding application reachable from the pod. The browser forwards browser authentication and API
credentials. Nginx does not add or strip authorization headers. requests without adding application credentials. Nginx does not add or strip
authorization headers.

View file

@ -25,8 +25,6 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
VITE_GO_LOOSE_API_KEY: gl_bdd_key_that_is_long_enough_for_client
environment: environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable&search_path=ypto 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 JWT_SECRET: bdd-test-secret-that-is-at-least-32-characters

View file

@ -36,10 +36,10 @@ services:
environment: environment:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
JWT_SECRET: local-development-secret-change-me-12345 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} APP_ENV: ${APP_ENV:-development}
GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth.dev} GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth-dev.urpi.be}
GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess.dev} GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess-dev.urpi.be}
GO_LOOSE_CA_FILE: /certs/local-ca.crt GO_LOOSE_CA_FILE: /certs/local-ca.crt
GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-} GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-}
GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-} GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-}
@ -52,7 +52,7 @@ services:
- go_guess_network - go_guess_network
- local_dev_edge - local_dev_edge
volumes: 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View file

@ -36,14 +36,18 @@ application shell.
## API integration ## API integration
For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and
tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends `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 `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. 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 `src/components/actions/Account.tsx` treats any host whose first DNS label is a
to `/api/auth/login` when no Go Loose browser session exists. The callback 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. exchanges that session for the bearer token used by the typed API client.
Invitation and participant-meeting URLs use the validated request origin so 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 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 <token>`. API failures are `sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
shown in the UI and are never replaced with demo data. A `401` from a protected 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 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 `/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 token-based. The app also schedules logout from the JWT expiration claim so an idle
expired session cannot remain on a protected page. expired session cannot remain on a protected page.

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' 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' import { subscribeToDomainEvent } from './domainEvents'
describe('API client', () => { describe('API client', () => {
@ -8,12 +8,11 @@ describe('API client', () => {
}) })
it('uses the same-origin API behind the local HTTPS proxy', () => { it('uses the same-origin API behind the local HTTPS proxy', () => {
expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs') expect(tenantFromHostname('nmbs.guess-dev.urpi.be')).toBe('nmbs')
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto') expect(tenantFromHostname('ypto.guess-dev.urpi.be')).toBe('ypto')
expect(tenantFromHostname('nmbs.guess.urpi.be')).toBe('nmbs')
expect(tenantFromHostname('localhost')).toBeNull() expect(tenantFromHostname('localhost')).toBeNull()
expect(tenantFromHostname('127.0.0.1')).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 () => { it('sends the persisted bearer token on protected requests', async () => {

View file

@ -79,16 +79,10 @@ export function tenantFromHostname(hostname: string): string | null {
return labels.length >= 3 ? labels[0] : null return labels.length >= 3 ? labels[0] : null
} }
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string { // Every tenant host serves the API from its own origin: nginx and Traefik proxy
const hostname = location.hostname.toLowerCase().replace(/\.$/, '') // /api to the API container, so the base is always the same-origin path.
if (hostname.endsWith('.guess.local')) {
return '/api'
}
return '/api'
}
function apiURL(path: string): string { function apiURL(path: string): string {
return `${apiBaseURL(window.location)}${path}` return `/api${path}`
} }
function tenantHeader(): Record<string, string> { function tenantHeader(): Record<string, string> {

View file

@ -258,10 +258,17 @@ describe('Go Guess frontend', () => {
'href', 'href',
'/invitations', '/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', 'href',
'/profile', '/profile',
) )
expect(within(accountNavigation).getByRole('button', { name: 'Sign out' })).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' }) const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
expect( expect(
@ -329,7 +336,10 @@ describe('Go Guess frontend', () => {
renderApp('/participants/3') renderApp('/participants/3')
const input = await screen.findByLabelText(/replace cv/i) 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(await screen.findByText('React')).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(

View file

@ -1,33 +1,40 @@
import {Route, Routes, useLocation} from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import {useAuthSession} from '../components/actions' import { useAuthSession } from '../components/actions'
import {NotFound} from '../components/ui/NotFound' import { NotFound } from '../components/ui/NotFound'
import {ParticipantInterviewPage} from '../features/assessments' import { ParticipantInterviewPage } from '../features/assessments'
import {ParticipantMeetingPage} from '../features/interviews' import { ParticipantMeetingPage } from '../features/interviews'
import {LoginPage} from '../features/login/LoginPage' import { LoginPage } from '../features/login/LoginPage'
import {AuthenticatedApp} from './AuthenticatedApp' import { AuthenticatedApp } from './AuthenticatedApp'
import {DataProvider} from './DataContext' import { DataProvider } from './DataContext'
export default function App() { export default function App() {
const location = useLocation(); const location = useLocation()
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession(); const { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } = useAuthSession()
if (location.pathname.startsWith('/participant/')) { if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage/>}/>
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
)
}
if (!authenticated) {
if (ssoStatus !== 'idle') {
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
}
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
}
return ( return (
<DataProvider> <Routes>
<AuthenticatedApp onLogout={logout}/> <Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
</DataProvider> <Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
) )
}
// 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 (
<LoginPage
onLogin={completeLogin}
sessionExpired={sessionExpired}
ssoAvailable={ssoAvailable}
/>
)
}
return (
<DataProvider>
<AuthenticatedApp onLogout={logout} />
</DataProvider>
)
} }

View file

@ -13,7 +13,7 @@ export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
<div className="app-shell"> <div className="app-shell">
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/> <GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
<main className="main-content"> <main className="main-content">
<WorkspaceUtilities user={user}/> <WorkspaceUtilities user={user} onSignOut={onLogout}/>
<ApplicationRoutes/> <ApplicationRoutes/>
<AppFooter/> <AppFooter/>
</main> </main>

View file

@ -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<typeof vi.fn>
replace: ReturnType<typeof vi.fn>
}
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 <p>{`${String(ssoAvailable)}|${String(authenticated)}`}</p>
}
function renderProbe(path = '/') {
return render(
<MemoryRouter initialEntries={[path]}>
<Probe />
</MemoryRouter>,
)
}
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()
})
})

View file

@ -1,67 +1,93 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' 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://<tenant>.<app domain>, 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() { 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() { export function useAuthSession() {
const location = useLocation() 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 [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
const [sessionExpired, setSessionExpired] = useState(false) const [sessionExpired, setSessionExpired] = useState(false)
const [redirecting, setRedirecting] = useState(false) // Whether Go Loose login is configured for this host. A host with no tenant
const needsSSO = sso && !authenticated && !authStorage.token() // label cannot have it, so that is known without asking: false there, and null
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking' // on a tenant host until /api/auth/session has answered.
const navigate = useNavigate() const [ssoAvailable, setSsoAvailable] = useState<boolean | null>(() =>
isSSOHost() ? null : false,
)
const storedToken = authStorage.token()
useEffect(() => { useEffect(() => {
function handleSessionExpired() { function handleSessionExpired() {
setAuthenticated(false) setAuthenticated(false)
setSessionExpired(true) 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) window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired) return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
}, [navigate]) }, [])
useEffect(() => { 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 let cancelled = false
void (async () => { void (async () => {
try { try {
const response = await fetch('/api/auth/session', { const response = await fetch(SESSION_PATH, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
redirect: 'manual', redirect: 'manual',
}) })
if (cancelled) return 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) { if (response.ok) {
authStorage.save(await response.json()) authStorage.save(await response.json())
setAuthenticated(true) setAuthenticated(true)
setSessionExpired(false) setSessionExpired(false)
return
} }
} catch { } catch {
// The browser redirect below starts a fresh Go Loose 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.
if (!cancelled) {
setRedirecting(true)
window.location.replace('/api/auth/login')
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [needsSSO]) }, [storedToken, participant])
useEffect(() => { useEffect(() => {
if (!authenticated) return if (!authenticated) return
@ -79,6 +105,8 @@ export function useAuthSession() {
function completeLogin() { function completeLogin() {
setAuthenticated(true) setAuthenticated(true)
setSessionExpired(false) 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 }) navigate('/', { replace: true })
} }
@ -86,8 +114,10 @@ export function useAuthSession() {
authStorage.clear() authStorage.clear()
setAuthenticated(false) setAuthenticated(false)
setSessionExpired(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 }
} }

View file

@ -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(
<MemoryRouter>
<UserMenu user={user} onSignOut={onSignOut} />
</MemoryRouter>,
)
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(
<MemoryRouter>
<div>
<UserMenu user={user} onSignOut={vi.fn()} />
<button type="button">elsewhere</button>
</div>
</MemoryRouter>,
)
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(
<MemoryRouter>
<UserMenu user={{ ...user, displayName: '' }} onSignOut={vi.fn()} />
</MemoryRouter>,
)
expect(screen.getByRole('button', { name: /ada@example.test/ })).toBeInTheDocument()
})
})

View file

@ -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<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(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 (
<div className="user-menu" ref={container}>
<button
type="button"
ref={trigger}
className="user-menu-trigger"
aria-expanded={open}
aria-haspopup="menu"
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
>
<Avatar user={user} size="tiny" />
{/* The trigger stays to the name alone: the address is one click away in
the panel, and repeating it on every screen is noise. */}
<span className="user-menu-identity">
<span className="user-menu-name">{name}</span>
</span>
</button>
{open && (
<div className="user-menu-panel" id={panelId} aria-label="Account">
<div className="user-menu-header">
<Avatar user={user} size="large" />
<div>
<p className="user-menu-panel-name">{name}</p>
{email && <p className="user-menu-panel-email">{email}</p>}
</div>
</div>
<Link className="user-menu-item" to="/profile" onClick={() => setOpen(false)}>
<UserRound size={18} aria-hidden="true" />
<span>Profile</span>
</Link>
<button
type="button"
className="user-menu-item"
onClick={() => {
setOpen(false)
onSignOut()
}}
>
<LogOut size={18} aria-hidden="true" />
<span>Sign out</span>
</button>
</div>
)}
</div>
)
}

View file

@ -1,9 +1,15 @@
import { CalendarDays, Mail } from 'lucide-react' import { CalendarDays, Mail } from 'lucide-react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import type { User } from '../../types' import type { User } from '../../types'
import { UserMenu } from './UserMenu'
export function WorkspaceUtilities({ user }: { user: User | null }) { export function WorkspaceUtilities({
const profileLabel = user?.displayName || user?.email || 'Profile' user,
onSignOut,
}: {
user: User | null
onSignOut: () => void
}) {
return ( return (
<nav className="workspace-utilities" aria-label="Account navigation"> <nav className="workspace-utilities" aria-label="Account navigation">
<NavLink className="utility-link" to="/invitations"> <NavLink className="utility-link" to="/invitations">
@ -14,12 +20,7 @@ export function WorkspaceUtilities({ user }: { user: User | null }) {
<CalendarDays size={18} aria-hidden="true" /> <CalendarDays size={18} aria-hidden="true" />
<span>Schedule</span> <span>Schedule</span>
</NavLink> </NavLink>
<NavLink className="utility-link profile-utility" to="/profile"> <UserMenu user={user} onSignOut={onSignOut} />
<span className="avatar tiny" aria-hidden="true">
{profileLabel.slice(0, 2).toUpperCase()}
</span>
<span>{profileLabel}</span>
</NavLink>
</nav> </nav>
) )
} }

View file

@ -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<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null) {
return render(<Avatar user={user} />)
}
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(<Avatar user={withoutPicture} size="tiny" className="extra" />)
const avatar = container.querySelector('.avatar')!
expect(avatar.className).toContain('tiny')
expect(avatar.className).toContain('extra')
})
})

View file

@ -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<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | 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<string | null>(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 (
<span className={classes} aria-hidden="true" data-testid="avatar-fallback">
{initialsFor(label)}
</span>
)
}
return (
<span className={classes} data-testid="avatar-image">
<img src={url} alt="" onError={() => setFailedUrl(url)} />
<span className="sr-only">{label}</span>
</span>
)
}

View file

@ -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()
}

View file

@ -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(
<MemoryRouter>
<LoginPage onLogin={vi.fn()} sessionExpired={false} ssoAvailable={ssoAvailable} />
</MemoryRouter>,
)
}
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()
})
})

View file

@ -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 { SyntheticEvent, useState } from 'react'
import Logo from '../../components/layout/Logo' import Logo from '../../components/layout/Logo'
import { FormField } from '../../components/form' import { FormField } from '../../components/form'
import { ErrorAlert, errorMessage } from '../../components/actions' import { ErrorAlert, errorMessage } from '../../components/actions'
import { api, authStorage } from '../../api/client' 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({ export function LoginPage({
onLogin, onLogin,
sessionExpired, sessionExpired,
ssoAvailable,
}: { }: {
onLogin: () => void onLogin: () => void
sessionExpired: boolean sessionExpired: boolean
/** null until the API has said whether Go Loose login is configured here. */
ssoAvailable: boolean | null
}) { }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') 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<HTMLFormElement>) { async function submit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
setLoading(true) setLoading(true)
@ -58,25 +84,46 @@ export function LoginPage({
Your session is no longer active. Sign in again to continue. Your session is no longer active. Sign in again to continue.
</div> </div>
)} )}
<p className="muted">Use your work account to continue.</p> <p className="muted">
<form onSubmit={submit} className="stack-form"> Use your work account to continue. If you are already signed in elsewhere, this takes
<FormField label="Email address" htmlFor="email"> you straight through.
<input id="email" name="email" type="email" autoComplete="email" required /> </p>
</FormField>
<FormField label="Password" htmlFor="password"> {ssoAvailable === false ? (
<input <form onSubmit={submit} className="stack-form">
id="password" <p className="muted">
name="password" Single sign-on is not configured for this host. Sign in with a local account.
type="password" </p>
autoComplete="current-password" <FormField label="Email address" htmlFor="email">
required <input id="email" name="email" type="email" autoComplete="email" required />
/> </FormField>
</FormField> <FormField label="Password" htmlFor="password">
{error && <ErrorAlert message={error} />} <input
<button className="button primary wide" type="submit" disabled={loading}> id="password"
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} /> name="password"
</button> type="password"
</form> autoComplete="current-password"
required
/>
</FormField>
{error && <ErrorAlert message={error} />}
<button className="button primary wide" type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
</button>
</form>
) : (
<div className="stack-form">
<button
className="button primary wide"
type="button"
onClick={beginExternalLogin}
disabled={loading}
>
{loading ? 'Redirecting…' : 'Sign in with Go Loose'} <KeyRound size={18} />
</button>
</div>
)}
<p className="demo-note"> <p className="demo-note">
<CircleHelp size={16} /> Contact your administrator for access. <CircleHelp size={16} /> Contact your administrator for access.
</p> </p>

View file

@ -1,5 +1,6 @@
import { Mail, ShieldCheck, UserRound } from 'lucide-react' import { Mail, ShieldCheck, UserRound } from 'lucide-react'
import { authStorage } from '../../api/client' import { authStorage } from '../../api/client'
import { Avatar } from '../../components/ui/Avatar'
export function ProfilePage() { export function ProfilePage() {
const user = authStorage.user() const user = authStorage.user()
@ -14,9 +15,8 @@ export function ProfilePage() {
</header> </header>
<section className="card profile-card"> <section className="card profile-card">
<div className="profile-identity"> <div className="profile-identity">
<span className="avatar profile-avatar" aria-hidden="true"> {/* The picture Go Loose serves, with the initials as the fallback. */}
{user.displayName.slice(0, 2).toUpperCase()} <Avatar user={user} size="large" className="profile-avatar" />
</span>
<div> <div>
<h1>{user.displayName}</h1> <h1>{user.displayName}</h1>
<span className={`status status-${user.role}`}>{user.role}</span> <span className={`status status-${user.role}`}>{user.role}</span>

View file

@ -2802,3 +2802,117 @@ fieldset {
animation-iteration-count: 1 !important; 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;
}
}

View file

@ -5,6 +5,12 @@ export interface User {
email: string email: string
displayName: string displayName: string
role: 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 { export interface AuthResponse {

View file

@ -4,7 +4,14 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { 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: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080', target: 'http://localhost:8080',

View file

@ -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.