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:
parent
6be21597d5
commit
5449cd3d8d
52 changed files with 1927 additions and 434 deletions
42
.github/copilot-instructions.md
vendored
42
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
16
Makefile
16
Makefile
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
87
README.md
87
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 != "" {
|
||||||
|
|
|
||||||
181
backend/internal/golooseauth/registry_test.go
Normal file
181
backend/internal/golooseauth/registry_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
181
deploy/helm/go-guess/values-dev.yaml
Normal file
181
deploy/helm/go-guess/values-dev.yaml
Normal 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: {}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
184
deploy/helm/go-guess/values-prd.yaml
Normal file
184
deploy/helm/go-guess/values-prd.yaml
Normal 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: {}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ 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 (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -19,11 +19,18 @@ export default function App() {
|
||||||
</Routes>
|
</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) {
|
if (!authenticated) {
|
||||||
if (ssoStatus !== 'idle') {
|
return (
|
||||||
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
|
<LoginPage
|
||||||
}
|
onLogin={completeLogin}
|
||||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
|
sessionExpired={sessionExpired}
|
||||||
|
ssoAvailable={ssoAvailable}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
169
frontend/src/components/actions/Account.test.tsx
Normal file
169
frontend/src/components/actions/Account.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
frontend/src/components/layout/UserMenu.test.tsx
Normal file
97
frontend/src/components/layout/UserMenu.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
97
frontend/src/components/layout/UserMenu.tsx
Normal file
97
frontend/src/components/layout/UserMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
frontend/src/components/ui/Avatar.test.tsx
Normal file
76
frontend/src/components/ui/Avatar.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
45
frontend/src/components/ui/Avatar.tsx
Normal file
45
frontend/src/components/ui/Avatar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
frontend/src/components/ui/initials.ts
Normal file
16
frontend/src/components/ui/initials.ts
Normal 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()
|
||||||
|
}
|
||||||
64
frontend/src/features/login/LoginPage.test.tsx
Normal file
64
frontend/src/features/login/LoginPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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,8 +84,16 @@ 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">
|
||||||
|
Use your work account to continue. If you are already signed in elsewhere, this takes
|
||||||
|
you straight through.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{ssoAvailable === false ? (
|
||||||
<form onSubmit={submit} className="stack-form">
|
<form onSubmit={submit} className="stack-form">
|
||||||
|
<p className="muted">
|
||||||
|
Single sign-on is not configured for this host. Sign in with a local account.
|
||||||
|
</p>
|
||||||
<FormField label="Email address" htmlFor="email">
|
<FormField label="Email address" htmlFor="email">
|
||||||
<input id="email" name="email" type="email" autoComplete="email" required />
|
<input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -77,6 +111,19 @@ export function LoginPage({
|
||||||
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
|
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue