feat: integrate tenant SSO and remote toggle
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
660d5291ec
commit
ca5765b988
27 changed files with 529 additions and 159 deletions
15
.github/copilot-instructions.md
vendored
15
.github/copilot-instructions.md
vendored
|
|
@ -64,11 +64,16 @@ Goose files in `backend/migrations` are schema-only and run in every environment
|
||||||
Every entity change requires a migration plus corresponding fixture and integration
|
Every entity change requires a migration plus corresponding fixture and integration
|
||||||
test updates. Never put demo users or test records in schema migrations.
|
test updates. Never put demo users or test records in schema migrations.
|
||||||
|
|
||||||
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Interviewer
|
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
|
||||||
routes additionally require a Go Loose API key for the request tenant. Health,
|
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
|
||||||
OpenAPI, login, and opaque-token participant interview routes do not. Never
|
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
|
||||||
expose password hashes, CV/photo bytes, interviewer reference answers, or API
|
participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev`
|
||||||
keys in public JSON or logs.
|
start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects
|
||||||
|
to `https://<tenant>.auth.dev/connect/authorize` and Go Loose returns to
|
||||||
|
`/api/auth/callback`. Go Loose client credentials come from uncommitted
|
||||||
|
environment variables. Missing tenant context uses the public schema; a
|
||||||
|
`*.guess.dev` host supplies the schema when `X-Tenant-Id` is absent. Never expose
|
||||||
|
password hashes, CV/photo bytes, or interviewer reference answers in public JSON.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ WORKDIR /src/frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
ARG VITE_GO_LOOSE_API_KEY
|
|
||||||
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Compile the API and seed binaries.
|
# Compile the API and seed binaries.
|
||||||
|
|
|
||||||
86
README.md
86
README.md
|
|
@ -20,16 +20,17 @@ The complete stack runs with:
|
||||||
make dev
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the local tenant hostnames once:
|
`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:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
127.0.0.1 nmbs.guess.local ypto.guess.local
|
127.0.0.1 nmbs.guess.dev ypto.guess.dev
|
||||||
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev
|
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
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/infrasctruture/docs/APPLICATIONS.md`, then open
|
||||||
<https://nmbs.guess.local> or <https://ypto.guess.local>. The hostname's first
|
<https://nmbs.guess.dev> or <https://ypto.guess.dev>. The hostname's first
|
||||||
label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the
|
label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the
|
||||||
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream
|
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream
|
||||||
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and
|
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and
|
||||||
|
|
@ -37,8 +38,10 @@ builds participant invitation and meeting links from the matching tenant browser
|
||||||
origin instead of the static localhost fallback.
|
origin instead of the static localhost fallback.
|
||||||
|
|
||||||
Compose mounts the infrastructure CA from
|
Compose mounts the infrastructure CA from
|
||||||
`${LOCAL_DEV_CERTS_DIR:-/home/dnoulet/go/infrasctruture/certs}` into the API so
|
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The
|
||||||
its HTTPS authorization calls to Go Loose validate normally.
|
browser is redirected through the matching Go Loose tenant and returns to
|
||||||
|
`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and
|
||||||
|
`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them.
|
||||||
|
|
||||||
Sign in with:
|
Sign in with:
|
||||||
|
|
||||||
|
|
@ -58,15 +61,14 @@ 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 `*.guess.local` development origins and
|
||||||
the `Authorization`, `X-API-Key`, 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. Protected interviewer calls also need a Go Loose API
|
outside local development.
|
||||||
key, documented below.
|
|
||||||
|
|
||||||
## 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.local/api/openapi.json>. The document is assembled in memory
|
<https://nmbs.guess.dev/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
|
||||||
|
|
@ -74,55 +76,34 @@ requires no generation command and cannot miss a newly registered route.
|
||||||
|
|
||||||
`/api/openapi.json` and `/api/health` are system endpoints and do not require a
|
`/api/openapi.json` and `/api/health` are system endpoints and do not require a
|
||||||
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 `apiKeyAuth` (`X-API-Key`) and `bearerAuth`.
|
operations also declare bearer authentication.
|
||||||
|
|
||||||
## Go Loose authorization
|
## Go Loose authorization
|
||||||
|
|
||||||
Interviewer routes are authorized by Go Loose before the JWT check. The tenant
|
Tenant hosts start browser authentication at `GET /api/auth/login`. Go Loose
|
||||||
is read from the request `X-Tenant-Id` header and sent to Go Loose with
|
returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the
|
||||||
application slug `guess`. It is not taken from process configuration. The client
|
resulting secure browser session for the application's bearer JWT. The tenant
|
||||||
calls `POST /api/v1/authorize` on every protected request, removes `X-API-Key`
|
is derived from the request host, or from `X-Tenant-Id` for API calls.
|
||||||
before the handler runs, and fails closed on a missing key, a denial, or a
|
|
||||||
control-plane error. There is no allow-on-error fallback. The client timeout is
|
|
||||||
2 seconds.
|
|
||||||
|
|
||||||
`GO_LOOSE_BASE_URL` is the authorize base URL. The default is
|
Compose configures `GO_LOOSE_AUTH_DOMAIN`, `GO_LOOSE_APP_DOMAIN`,
|
||||||
`https://%s.auth.dev`. A single `%s` is replaced with the tenant, which must
|
`GO_LOOSE_CA_FILE`, and per-tenant client ID and secret variables. These routes
|
||||||
then be one DNS label (`nmbs`, not `nmbs.auth.dev` or a host with a port). A
|
do not require a JWT:
|
||||||
URL without `%s` is used unchanged, while the tenant still comes from the
|
|
||||||
header. Docker Compose passes this variable to the API. Helm sets it from
|
|
||||||
`app.goLooseBaseURL`.
|
|
||||||
|
|
||||||
These routes do not require `X-API-Key` or a JWT:
|
|
||||||
|
|
||||||
- `GET /api/health` and `GET /api/openapi.json`
|
- `GET /api/health` and `GET /api/openapi.json`
|
||||||
- `POST /api/auth/login`
|
- browser login, callback, logout, and session routes under `/api/auth`
|
||||||
|
- `POST /api/auth/login` for password login outside configured SSO hosts
|
||||||
- opaque-token participant interview and meeting routes under
|
- opaque-token participant interview and meeting routes under
|
||||||
`/api/interviews/{token}` and `/api/participant-meetings/{token}`
|
`/api/interviews/{token}` and `/api/participant-meetings/{token}`
|
||||||
|
|
||||||
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.
|
not. Browser authentication routes derive the tenant from the configured host.
|
||||||
|
|
||||||
Issue one API key per tenant in the Go Loose console for the `guess`
|
## Switch the Git remote
|
||||||
application. The local installer seed creates that application for `nmbs` and
|
|
||||||
`ypto`. For a containerized frontend, pass the key at image build time:
|
|
||||||
|
|
||||||
```bash
|
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between
|
||||||
VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' docker compose build web
|
`https://github.com/danyel/go-guess.git` and
|
||||||
```
|
`https://forgejo.dev/exr462/go-guess.git`. The script exits without changing an
|
||||||
|
unrecognized remote URL.
|
||||||
For Vite outside Docker, put the same variable in `frontend/.env.local`. That
|
|
||||||
file is ignored. Vite inlines `VITE_` variables into the browser bundle, so this
|
|
||||||
key is visible to anyone who can load the interviewer application. It identifies
|
|
||||||
the Guess application to Go Loose; it does not replace the interviewer JWT or
|
|
||||||
the participant token. Do not commit it or reuse a key across tenants.
|
|
||||||
|
|
||||||
The published production image does not receive this build argument. A manual
|
|
||||||
production image can:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --build-arg VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' -t go-guess .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quality commands
|
## Quality commands
|
||||||
|
|
||||||
|
|
@ -183,8 +164,7 @@ docker build -t go-guess .
|
||||||
|
|
||||||
The image serves the frontend and API on port `8080`. It applies database
|
The image serves the frontend and API on port `8080`. It applies database
|
||||||
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
|
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
|
||||||
and these environment variables must be provided. `GO_LOOSE_BASE_URL` is
|
and these environment variables must be provided:
|
||||||
optional and defaults to `https://%s.auth.dev`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 \
|
docker run --rm -p 8080:8080 \
|
||||||
|
|
@ -192,7 +172,6 @@ docker run --rm -p 8080:8080 \
|
||||||
-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://go-guess.example.com' \
|
-e FRONTEND_URL='https://go-guess.example.com' \
|
||||||
-e GO_LOOSE_BASE_URL='https://%s.auth.dev' \
|
|
||||||
go-guess
|
go-guess
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -219,15 +198,6 @@ For the repository's single-node Docker Rancher setup, run
|
||||||
installs the default persistent StorageClass documented in
|
installs the default persistent StorageClass documented in
|
||||||
`rancher-install.txt`.
|
`rancher-install.txt`.
|
||||||
|
|
||||||
The chart passes `app.goLooseBaseURL` to the API as `GO_LOOSE_BASE_URL`. The
|
|
||||||
default is `https://%s.auth.dev`. Override it when Go Loose is not reachable
|
|
||||||
at `<tenant>.auth.dev`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
helm upgrade --install go-guess deploy/helm/go-guess \
|
|
||||||
--set-string app.goLooseBaseURL='https://%s.auth.example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
Development uses demo fixtures, ephemeral database and message-broker storage,
|
Development uses demo fixtures, ephemeral database and message-broker storage,
|
||||||
and NodePort `30080`. Production uses production fixtures, persistent volumes,
|
and NodePort `30080`. Production uses production fixtures, persistent volumes,
|
||||||
generated secrets that are preserved across upgrades, and larger resource
|
generated secrets that are preserved across upgrades, and larger resource
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"github.com/danyel/go-guess/backend/internal/database"
|
"github.com/danyel/go-guess/backend/internal/database"
|
||||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||||
"github.com/danyel/go-guess/backend/internal/eventbus"
|
"github.com/danyel/go-guess/backend/internal/eventbus"
|
||||||
|
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||||
"github.com/danyel/go-guess/backend/internal/security"
|
"github.com/danyel/go-guess/backend/internal/security"
|
||||||
"github.com/danyel/go-guess/backend/internal/service"
|
"github.com/danyel/go-guess/backend/internal/service"
|
||||||
"github.com/danyel/go-guess/backend/internal/web/handler"
|
"github.com/danyel/go-guess/backend/internal/web/handler"
|
||||||
|
|
@ -46,21 +47,28 @@ func main() {
|
||||||
}
|
}
|
||||||
defer bus.Close()
|
defer bus.Close()
|
||||||
tokens := security.NewTokenManager(cfg.JWTSecret, cfg.TokenTTL)
|
tokens := security.NewTokenManager(cfg.JWTSecret, cfg.TokenTTL)
|
||||||
|
browser, err := golooseauth.New(cfg.GoLoose)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("configure Go Loose login", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
invitations := service.NewInvitationService(store, cfg.FrontendURL)
|
invitations := service.NewInvitationService(store, cfg.FrontendURL)
|
||||||
interviews := service.NewScheduledInterviewService(store, bus)
|
interviews := service.NewScheduledInterviewService(store, bus)
|
||||||
|
apiHandler := handler.New(
|
||||||
|
db,
|
||||||
|
service.NewAuthService(store, tokens),
|
||||||
|
service.NewJobService(store),
|
||||||
|
service.NewQuestionService(store),
|
||||||
|
service.NewParticipantService(store),
|
||||||
|
invitations,
|
||||||
|
service.NewUserService(store),
|
||||||
|
interviews,
|
||||||
|
cfg.MaxUploadMB,
|
||||||
|
cfg.FrontendURL,
|
||||||
|
)
|
||||||
|
apiHandler.SetBrowserAuth(browser)
|
||||||
api := router.New(
|
api := router.New(
|
||||||
handler.New(
|
apiHandler,
|
||||||
db,
|
|
||||||
service.NewAuthService(store, tokens),
|
|
||||||
service.NewJobService(store),
|
|
||||||
service.NewQuestionService(store),
|
|
||||||
service.NewParticipantService(store),
|
|
||||||
invitations,
|
|
||||||
service.NewUserService(store),
|
|
||||||
interviews,
|
|
||||||
cfg.MaxUploadMB,
|
|
||||||
cfg.FrontendURL,
|
|
||||||
),
|
|
||||||
tokens,
|
tokens,
|
||||||
cfg.FrontendURL,
|
cfg.FrontendURL,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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-20261002165652-46030a5b144f
|
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14
|
||||||
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-20261002165652-46030a5b144f h1:9GV60iNpwRsZONzZcFHsGxYNeU0iItiitOvFNLqlkkA=
|
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14 h1:kX0zybG4E3VLB4QpOsyJEBu4HoXx33CY2x8rtACHRRM=
|
||||||
github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14/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=
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,19 @@ type Config struct {
|
||||||
MaxUploadMB int64
|
MaxUploadMB int64
|
||||||
FrontendURL string
|
FrontendURL string
|
||||||
RabbitMQURL string
|
RabbitMQURL string
|
||||||
|
GoLoose GoLooseConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoLooseConfig struct {
|
||||||
|
AuthDomain string
|
||||||
|
AppDomain string
|
||||||
|
CAFile string
|
||||||
|
Tenants map[string]GoLooseTenant
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoLooseTenant struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
|
|
@ -34,6 +47,7 @@ func Load() (Config, error) {
|
||||||
MaxUploadMB: maxUploadMB,
|
MaxUploadMB: maxUploadMB,
|
||||||
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
|
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
|
||||||
RabbitMQURL: env("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
|
RabbitMQURL: env("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
|
||||||
|
GoLoose: loadGoLoose(),
|
||||||
}
|
}
|
||||||
if len(cfg.JWTSecret) < 32 {
|
if len(cfg.JWTSecret) < 32 {
|
||||||
return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters")
|
return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters")
|
||||||
|
|
@ -41,6 +55,25 @@ func Load() (Config, error) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadGoLoose() GoLooseConfig {
|
||||||
|
cfg := GoLooseConfig{
|
||||||
|
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
|
||||||
|
AppDomain: env("GO_LOOSE_APP_DOMAIN", "guess.dev"),
|
||||||
|
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
|
||||||
|
Tenants: map[string]GoLooseTenant{},
|
||||||
|
}
|
||||||
|
add := func(slug, idEnv, secretEnv string) {
|
||||||
|
id, secret := os.Getenv(idEnv), os.Getenv(secretEnv)
|
||||||
|
if id == "" || secret == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Tenants[slug] = GoLooseTenant{ClientID: id, ClientSecret: secret}
|
||||||
|
}
|
||||||
|
add("nmbs", "GO_LOOSE_NMBS_CLIENT_ID", "GO_LOOSE_NMBS_CLIENT_SECRET")
|
||||||
|
add("ypto", "GO_LOOSE_YPTO_CLIENT_ID", "GO_LOOSE_YPTO_CLIENT_SECRET")
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
func env(key, fallback string) string {
|
func env(key, fallback string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
122
backend/internal/golooseauth/registry.go
Normal file
122
backend/internal/golooseauth/registry.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package golooseauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
goloose "github.com/danyel/go-loose/client"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionCookie = "go_loose_user_session"
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
appDomain string
|
||||||
|
bySlug map[string]*goloose.BrowserAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.GoLooseConfig) (*Registry, error) {
|
||||||
|
registry := &Registry{
|
||||||
|
appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."),
|
||||||
|
bySlug: map[string]*goloose.BrowserAuth{},
|
||||||
|
}
|
||||||
|
if registry.appDomain == "" {
|
||||||
|
registry.appDomain = "guess.dev"
|
||||||
|
}
|
||||||
|
if len(cfg.Tenants) == 0 {
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".")
|
||||||
|
if authDomain == "" {
|
||||||
|
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
|
||||||
|
}
|
||||||
|
httpClient, err := client(cfg.CAFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for slug, tenant := range cfg.Tenants {
|
||||||
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||||
|
if slug == "" || strings.Contains(slug, ".") {
|
||||||
|
return nil, fmt.Errorf("invalid Go Loose tenant slug %q", slug)
|
||||||
|
}
|
||||||
|
auth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||||
|
BaseURL: "https://" + slug + "." + authDomain,
|
||||||
|
ClientID: tenant.ClientID,
|
||||||
|
ClientSecret: tenant.ClientSecret,
|
||||||
|
RedirectURL: "https://" + slug + "." + registry.appDomain + "/api/auth/callback",
|
||||||
|
AfterLoginURL: "/",
|
||||||
|
AfterLogoutURL: "/",
|
||||||
|
LoginPath: "/api/auth/login",
|
||||||
|
CookieName: SessionCookie,
|
||||||
|
CookieSecure: true,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("configure %s Go Loose login: %w", slug, err)
|
||||||
|
}
|
||||||
|
registry.bySlug[slug] = auth
|
||||||
|
}
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) AppDomain() string {
|
||||||
|
if r == nil || r.appDomain == "" {
|
||||||
|
return "guess.dev"
|
||||||
|
}
|
||||||
|
return r.appDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
slug := TenantFromHost(host, r.appDomain)
|
||||||
|
auth, ok := r.bySlug[slug]
|
||||||
|
return auth, slug, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TenantFromHost(host, appDomain string) string {
|
||||||
|
hostname := strings.ToLower(host)
|
||||||
|
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
hostname = strings.ToLower(parsed)
|
||||||
|
}
|
||||||
|
appDomain = strings.TrimPrefix(strings.ToLower(appDomain), ".")
|
||||||
|
if appDomain == "" {
|
||||||
|
appDomain = "guess.dev"
|
||||||
|
}
|
||||||
|
suffix := "." + appDomain
|
||||||
|
if !strings.HasSuffix(hostname, suffix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slug := strings.TrimSuffix(hostname, suffix)
|
||||||
|
if slug == "" || strings.Contains(slug, ".") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func client(caFile string) (*http.Client, error) {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
if caFile != "" {
|
||||||
|
pem, err := os.ReadFile(caFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read Go Loose CA: %w", err)
|
||||||
|
}
|
||||||
|
pool, err := x509.SystemCertPool()
|
||||||
|
if err != nil || pool == nil {
|
||||||
|
pool = x509.NewCertPool()
|
||||||
|
}
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return nil, fmt.Errorf("parse Go Loose CA %s", caFile)
|
||||||
|
}
|
||||||
|
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil
|
||||||
|
}
|
||||||
|
|
@ -28,6 +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)
|
||||||
}
|
}
|
||||||
|
|
||||||
type IJobService interface {
|
type IJobService interface {
|
||||||
|
|
@ -88,6 +89,36 @@ 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) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
displayName = strings.TrimSpace(displayName)
|
||||||
|
if email == "" {
|
||||||
|
return "", model.User{}, ErrValidation
|
||||||
|
}
|
||||||
|
user, err := s.store.FindUserByEmail(ctx, email)
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = email
|
||||||
|
}
|
||||||
|
hash, hashErr := security.HashPassword(uuid.NewString())
|
||||||
|
if hashErr != nil {
|
||||||
|
return "", model.User{}, hashErr
|
||||||
|
}
|
||||||
|
user, err = s.store.CreateUser(ctx, model.User{
|
||||||
|
Email: email, DisplayName: displayName, Role: "interviewer", PasswordHash: hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", model.User{}, err
|
||||||
|
}
|
||||||
|
token, err := s.tokens.Issue(user.ID, user.Email, user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return "", model.User{}, err
|
||||||
|
}
|
||||||
|
user.PasswordHash = ""
|
||||||
|
return token, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
type JobService struct {
|
type JobService struct {
|
||||||
store repository.IStore
|
store repository.IStore
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
|
|
||||||
"github.com/danyel/go-guess/backend/internal/database/repository"
|
"github.com/danyel/go-guess/backend/internal/database/repository"
|
||||||
"github.com/danyel/go-guess/backend/internal/eventbus"
|
"github.com/danyel/go-guess/backend/internal/eventbus"
|
||||||
|
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||||
"github.com/danyel/go-guess/backend/internal/security"
|
"github.com/danyel/go-guess/backend/internal/security"
|
||||||
"github.com/danyel/go-guess/backend/internal/service"
|
"github.com/danyel/go-guess/backend/internal/service"
|
||||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||||
|
|
@ -37,6 +38,7 @@ type Handler struct {
|
||||||
interviews service.IScheduledInterviewService
|
interviews service.IScheduledInterviewService
|
||||||
maxUpload int64
|
maxUpload int64
|
||||||
frontendURL string
|
frontendURL string
|
||||||
|
browser *golooseauth.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
|
|
@ -651,7 +653,8 @@ 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") {
|
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") ||
|
||||||
|
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") {
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
return h.frontendURL
|
return h.frontendURL
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,14 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
||||||
t.Fatalf("expected configured frontend URL, got %q", value)
|
t.Fatalf("expected configured frontend URL, got %q", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) {
|
||||||
|
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||||
|
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||||
|
request.Header.Set("Origin", "https://ypto.guess.dev")
|
||||||
|
request.Header.Set(client.XTenantId, "ypto")
|
||||||
|
|
||||||
|
if value := handler.frontendURLForRequest(request); value != "https://ypto.guess.dev" {
|
||||||
|
t.Fatalf("expected tenant dev origin, got %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
92
backend/internal/web/handler/sso.go
Normal file
92
backend/internal/web/handler/sso.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
goloose "github.com/danyel/go-loose/client"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||||
|
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
||||||
|
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errMissingSession = errors.New("missing Go Loose session")
|
||||||
|
errTenantMismatch = errors.New("Go Loose session does not match this tenant")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
|
||||||
|
h.browser = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AppDomain() string {
|
||||||
|
if h.browser == nil {
|
||||||
|
return "guess.dev"
|
||||||
|
}
|
||||||
|
return h.browser.AppDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, ok := h.externalAuth(r)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.LoginHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, ok := h.externalAuth(r)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.CallbackHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, ok := h.externalAuth(r)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.LogoutHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := r.Cookie(golooseauth.SessionCookie); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, errMissingSession)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := goloose.UserFromContext(r.Context())
|
||||||
|
if !ok || user.TenantSlug != slug {
|
||||||
|
writeError(w, http.StatusForbidden, errTenantMismatch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, local, err := h.auth.EstablishExternalSession(r.Context(), user.Email, user.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, webmodel.LoginResponse{Token: token, User: webmapper.UserToWeb(local)})
|
||||||
|
})).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) {
|
||||||
|
auth, _, ok := h.externalAuthWithSlug(r)
|
||||||
|
return auth, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, string, bool) {
|
||||||
|
if h.browser == nil {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
return h.browser.ForHost(r.Host)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||||
"github.com/danyel/go-loose/client"
|
"github.com/danyel/go-loose/client"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
@ -18,7 +19,7 @@ const TxKey contextKey = "tenant_db_tx"
|
||||||
var safeSchemaRegex = regexp.MustCompile(`^[a-zA-Z0-9_:-]+$`)
|
var safeSchemaRegex = regexp.MustCompile(`^[a-zA-Z0-9_:-]+$`)
|
||||||
|
|
||||||
// TenantSchemaMiddleware isolates database connections per request using PostgreSQL schemas
|
// TenantSchemaMiddleware isolates database connections per request using PostgreSQL schemas
|
||||||
func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
|
func TenantSchemaMiddleware(db *gorm.DB, 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) {
|
||||||
if r.URL.Path == "/api/health" || r.URL.Path == "/api/openapi.json" {
|
if r.URL.Path == "/api/health" || r.URL.Path == "/api/openapi.json" {
|
||||||
|
|
@ -27,9 +28,10 @@ func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
tenantID := r.Header.Get(client.XTenantId)
|
tenantID := r.Header.Get(client.XTenantId)
|
||||||
if tenantID == "" {
|
if tenantID == "" {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
tenantID = golooseauth.TenantFromHost(r.Host, appDomain)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
}
|
||||||
_, _ = w.Write([]byte(`{"error": "Missing X-Tenant-Id header"}`))
|
if tenantID == "" || db == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ func TestTenantMiddlewareAllowsSystemEndpointsWithoutTenant(t *testing.T) {
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
handler := TenantSchemaMiddleware(nil)(next)
|
handler := TenantSchemaMiddleware(nil, "guess.dev")(next)
|
||||||
|
|
||||||
for _, path := range []string{"/api/health", "/api/openapi.json"} {
|
for _, path := range []string{"/api/health", "/api/openapi.json"} {
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
@ -21,15 +21,15 @@ func TestTenantMiddlewareAllowsSystemEndpointsWithoutTenant(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantMiddlewareStillRequiresTenantForDataEndpoints(t *testing.T) {
|
func TestTenantMiddlewareUsesPublicSchemaWithoutTenant(t *testing.T) {
|
||||||
handler := TenantSchemaMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
handler := TenantSchemaMiddleware(nil, "guess.dev")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
t.Fatal("data endpoint reached without a tenant")
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
|
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
|
||||||
|
|
||||||
if response.Code != http.StatusBadRequest {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d", response.Code)
|
t.Fatalf("expected 200, got %d", response.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,6 @@ func (b *Builder) Add(method, path string, operation Operation) {
|
||||||
}
|
}
|
||||||
if operation.Protected {
|
if operation.Protected {
|
||||||
value["security"] = []map[string][]string{{
|
value["security"] = []map[string][]string{{
|
||||||
"apiKeyAuth": {},
|
|
||||||
"bearerAuth": {},
|
"bearerAuth": {},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +130,6 @@ func (b *Builder) Document() map[string]any {
|
||||||
"paths": b.paths,
|
"paths": b.paths,
|
||||||
"components": map[string]any{
|
"components": map[string]any{
|
||||||
"securitySchemes": map[string]any{
|
"securitySchemes": map[string]any{
|
||||||
"apiKeyAuth": map[string]any{"type": "apiKey", "in": "header", "name": client.DefaultHeader},
|
|
||||||
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
|
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
|
||||||
},
|
},
|
||||||
"schemas": b.schemas,
|
"schemas": b.schemas,
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,22 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
||||||
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))
|
||||||
router.Use(mw.TenantSchemaMiddleware(h.Db()))
|
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,
|
||||||
openapi.Operation{Summary: "Get the current OpenAPI contract", Response: map[string]any{}, NoTenant: true})
|
openapi.Operation{Summary: "Get the current OpenAPI contract", Response: map[string]any{}, NoTenant: true})
|
||||||
register(router, spec, false, http.MethodGet, "/api/health", h.Health,
|
register(router, spec, false, http.MethodGet, "/api/health", h.Health,
|
||||||
openapi.Operation{Summary: "Check API health", Response: map[string]string{}, NoTenant: true})
|
openapi.Operation{Summary: "Check API health", Response: map[string]string{}, NoTenant: true})
|
||||||
|
register(router, spec, false, http.MethodGet, "/api/auth/login", h.BeginExternalLogin,
|
||||||
|
openapi.Operation{Summary: "Start Go Loose browser authentication", NoTenant: true})
|
||||||
|
register(router, spec, false, http.MethodGet, "/api/auth/callback", h.CompleteExternalLogin,
|
||||||
|
openapi.Operation{Summary: "Complete Go Loose browser authentication", NoTenant: true})
|
||||||
|
register(router, spec, false, http.MethodGet, "/api/auth/logout", h.ExternalLogout,
|
||||||
|
openapi.Operation{Summary: "End the Go Loose browser session", NoTenant: true})
|
||||||
|
register(router, spec, false, http.MethodPost, "/api/auth/logout", h.ExternalLogout,
|
||||||
|
openapi.Operation{Summary: "End the Go Loose browser session", NoTenant: true})
|
||||||
|
register(router, spec, false, http.MethodGet, "/api/auth/session", h.ExternalSession,
|
||||||
|
openapi.Operation{Summary: "Exchange a Go Loose browser session for an API token", Response: webmodel.LoginResponse{}, NoTenant: true})
|
||||||
register(router, spec, false, http.MethodPost, "/api/auth/login", h.Login,
|
register(router, spec, false, http.MethodPost, "/api/auth/login", h.Login,
|
||||||
openapi.Operation{Summary: "Authenticate an interviewer", Request: webmodel.LoginRequest{}, Response: webmodel.LoginResponse{}})
|
openapi.Operation{Summary: "Authenticate an interviewer", Request: webmodel.LoginRequest{}, Response: webmodel.LoginResponse{}})
|
||||||
register(router, spec, false, http.MethodGet, "/api/interviews/{token}", h.GetInterview,
|
register(router, spec, false, http.MethodGet, "/api/interviews/{token}", h.GetInterview,
|
||||||
|
|
@ -43,7 +53,6 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
||||||
register(router, spec, false, http.MethodGet, "/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents,
|
register(router, spec, false, http.MethodGet, "/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents,
|
||||||
openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
|
openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
|
||||||
router.Group(func(protected chi.Router) {
|
router.Group(func(protected chi.Router) {
|
||||||
protected.Use(mw.GoLooseMiddleware())
|
|
||||||
protected.Use(authenticate(tokens))
|
protected.Use(authenticate(tokens))
|
||||||
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
|
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
|
||||||
openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})
|
openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,13 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/danyel/go-guess/backend/internal/config"
|
||||||
"github.com/danyel/go-guess/backend/internal/eventbus"
|
"github.com/danyel/go-guess/backend/internal/eventbus"
|
||||||
|
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||||
"github.com/danyel/go-guess/backend/internal/security"
|
"github.com/danyel/go-guess/backend/internal/security"
|
||||||
"github.com/danyel/go-guess/backend/internal/service"
|
"github.com/danyel/go-guess/backend/internal/service"
|
||||||
"github.com/danyel/go-guess/backend/internal/service/model"
|
"github.com/danyel/go-guess/backend/internal/service/model"
|
||||||
|
|
@ -24,7 +27,6 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
testTenant = "ypto"
|
testTenant = "ypto"
|
||||||
testAPIKey = "gl_test_key_that_is_long_enough_for_client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -45,16 +47,6 @@ func openStubDB(t *testing.T) *gorm.DB {
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowGoLoose(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"allowed":true,"tenant_slug":"ypto","application_slug":"guess","api_key_name":"guess"}`))
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
t.Setenv("GO_LOOSE_BASE_URL", server.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestHandler(t *testing.T, interviews service.IScheduledInterviewService) *handler.Handler {
|
func newTestHandler(t *testing.T, interviews service.IScheduledInterviewService) *handler.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if interviews == nil {
|
if interviews == nil {
|
||||||
|
|
@ -71,16 +63,16 @@ func withTenant(request *http.Request) {
|
||||||
request.Header.Set(client.XTenantId, testTenant)
|
request.Header.Set(client.XTenantId, testTenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
func withAPIKey(request *http.Request) {
|
|
||||||
request.Header.Set(client.DefaultHeader, testAPIKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
type authStub struct{}
|
type authStub struct{}
|
||||||
|
|
||||||
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
|
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
|
||||||
return "", model.User{}, service.ErrInvalidCredentials
|
return "", model.User{}, service.ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (authStub) EstablishExternalSession(context.Context, string, string) (string, model.User, error) {
|
||||||
|
return "", model.User{}, service.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
type jobStub struct{}
|
type jobStub struct{}
|
||||||
|
|
||||||
func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil }
|
func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil }
|
||||||
|
|
@ -237,13 +229,11 @@ func TestHealthDoesNotRequireAuthentication(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||||
allowGoLoose(t)
|
|
||||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||||
router := New(newTestHandler(t, nil), tokens, "http://localhost:5173")
|
router := New(newTestHandler(t, nil), tokens, "http://localhost:5173")
|
||||||
|
|
||||||
unauthorizedRequest := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
unauthorizedRequest := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
||||||
withTenant(unauthorizedRequest)
|
withTenant(unauthorizedRequest)
|
||||||
withAPIKey(unauthorizedRequest)
|
|
||||||
unauthorized := httptest.NewRecorder()
|
unauthorized := httptest.NewRecorder()
|
||||||
router.ServeHTTP(unauthorized, unauthorizedRequest)
|
router.ServeHTTP(unauthorized, unauthorizedRequest)
|
||||||
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
|
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" {
|
||||||
|
|
@ -255,7 +245,6 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
|
||||||
withTenant(request)
|
withTenant(request)
|
||||||
withAPIKey(request)
|
|
||||||
request.Header.Set("Authorization", "Bearer "+token)
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
authorized := httptest.NewRecorder()
|
authorized := httptest.NewRecorder()
|
||||||
router.ServeHTTP(authorized, request)
|
router.ServeHTTP(authorized, request)
|
||||||
|
|
@ -265,7 +254,6 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) {
|
func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) {
|
||||||
allowGoLoose(t)
|
|
||||||
interviews := &interviewCapture{}
|
interviews := &interviewCapture{}
|
||||||
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
|
||||||
api := New(newTestHandler(t, interviews), tokens, "http://localhost:5173")
|
api := New(newTestHandler(t, interviews), tokens, "http://localhost:5173")
|
||||||
|
|
@ -275,7 +263,6 @@ func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) {
|
||||||
}
|
}
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/scheduled-interviews/9", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/scheduled-interviews/9", nil)
|
||||||
withTenant(request)
|
withTenant(request)
|
||||||
withAPIKey(request)
|
|
||||||
request.Header.Set("Authorization", "Bearer "+token)
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
api.ServeHTTP(response, request)
|
api.ServeHTTP(response, request)
|
||||||
|
|
@ -310,6 +297,50 @@ func TestParticipantMeetingDoesNotExposeInternalCandidateData(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAPIDoesNotRequireTenant(t *testing.T) {
|
||||||
|
api := New(
|
||||||
|
newTestHandler(t, nil),
|
||||||
|
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
|
||||||
|
"http://localhost:5173",
|
||||||
|
)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
api.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/openapi.json", nil))
|
||||||
|
if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte(`"openapi"`)) {
|
||||||
|
t.Fatalf("expected OpenAPI document, got %d %s", response.Code, response.Body.Bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
|
||||||
|
h := newTestHandler(t, nil)
|
||||||
|
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||||
|
AuthDomain: "auth.dev",
|
||||||
|
AppDomain: "guess.dev",
|
||||||
|
Tenants: map[string]config.GoLooseTenant{
|
||||||
|
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
|
||||||
|
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h.SetBrowserAuth(browser)
|
||||||
|
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
|
||||||
|
|
||||||
|
for _, slug := range []string{"nmbs", "ypto"} {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||||
|
request.Host = slug + ".guess.dev"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
api.ServeHTTP(response, request)
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth.dev/connect/authorize?") {
|
||||||
|
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") {
|
||||||
|
t.Fatalf("%s callback missing from %s", slug, location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 }
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@ spec:
|
||||||
value: {{ .Values.app.tokenTTL | quote }}
|
value: {{ .Values.app.tokenTTL | quote }}
|
||||||
- name: MAX_UPLOAD_MB
|
- name: MAX_UPLOAD_MB
|
||||||
value: {{ .Values.app.maxUploadMB | quote }}
|
value: {{ .Values.app.maxUploadMB | quote }}
|
||||||
- name: GO_LOOSE_BASE_URL
|
|
||||||
value: {{ .Values.app.goLooseBaseURL | quote }}
|
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ app:
|
||||||
frontendURL: http://localhost:8080
|
frontendURL: http://localhost:8080
|
||||||
tokenTTL: 8h
|
tokenTTL: 8h
|
||||||
maxUploadMB: 10
|
maxUploadMB: 10
|
||||||
# Authorize URL for Go Loose. %s is the request X-Tenant-Id.
|
|
||||||
goLooseBaseURL: https://%s.auth.dev
|
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
jwtSecret: ""
|
jwtSecret: ""
|
||||||
|
|
|
||||||
|
|
@ -58,5 +58,5 @@ services should remain `ClusterIP` services without NodePorts.
|
||||||
Protected interviewer routes also call Go Loose from the application pod, using
|
Protected interviewer routes also call Go Loose from the application pod, using
|
||||||
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
|
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
|
||||||
request tenant). That address must be reachable from the cluster. The browser
|
request tenant). That address must be reachable from the cluster. The browser
|
||||||
sends `X-API-Key` only when the published frontend image was built with
|
forwards browser authentication and API requests without adding application
|
||||||
`VITE_GO_LOOSE_API_KEY`. Nginx does not add or strip that header.
|
credentials. Nginx does not add or strip authorization headers.
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,13 @@ services:
|
||||||
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.local}
|
||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
GO_LOOSE_BASE_URL: ${GO_LOOSE_BASE_URL:-https://%s.auth.dev}
|
GO_LOOSE_AUTH_DOMAIN: auth.dev
|
||||||
SSL_CERT_FILE: /certs/local-ca.crt
|
GO_LOOSE_APP_DOMAIN: guess.dev
|
||||||
|
GO_LOOSE_CA_FILE: /certs/local-ca.crt
|
||||||
|
GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-}
|
||||||
|
GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-}
|
||||||
|
GO_LOOSE_YPTO_CLIENT_ID: ${GO_LOOSE_YPTO_CLIENT_ID:-}
|
||||||
|
GO_LOOSE_YPTO_CLIENT_SECRET: ${GO_LOOSE_YPTO_CLIENT_SECRET:-}
|
||||||
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
|
||||||
expose:
|
expose:
|
||||||
- "8080"
|
- "8080"
|
||||||
|
|
@ -46,7 +51,7 @@ services:
|
||||||
- go_guess_network
|
- go_guess_network
|
||||||
- local_dev_edge
|
- local_dev_edge
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOCAL_DEV_CERTS_DIR:-/home/dnoulet/go/infrasctruture/certs}/local-ca.crt:/certs/local-ca.crt:ro
|
- ${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -54,10 +59,7 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build: ./frontend
|
||||||
context: ./frontend
|
|
||||||
args:
|
|
||||||
VITE_GO_LOOSE_API_KEY: ${VITE_GO_LOOSE_API_KEY:-}
|
|
||||||
expose:
|
expose:
|
||||||
- "80"
|
- "80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_GO_LOOSE_API_KEY
|
|
||||||
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.29-alpine
|
FROM nginx:1.29-alpine
|
||||||
|
|
|
||||||
|
|
@ -36,27 +36,15 @@ application shell.
|
||||||
|
|
||||||
## API integration
|
## API integration
|
||||||
|
|
||||||
For local tenant hosts such as `nmbs.guess.local`, `src/api/client.ts` derives
|
For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives
|
||||||
tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends
|
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.local` behaves identically for tenant `ypto`. Other
|
request. `ypto.guess.dev` 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.
|
||||||
|
|
||||||
Set `VITE_GO_LOOSE_API_KEY` before `npm run dev` or `npm run build` to also send
|
On the configured tenant hosts, the app checks `/api/auth/session` and redirects
|
||||||
`X-API-Key`. Vite reads `frontend/.env.local` for local development; that file
|
to `/api/auth/login` when no Go Loose browser session exists. The callback
|
||||||
matches `*.local` and must not be committed. The standalone frontend image
|
exchanges that session for the bearer token used by the typed API client.
|
||||||
accepts the same value as a build argument:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --build-arg VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' \
|
|
||||||
-t go-guess-frontend .
|
|
||||||
```
|
|
||||||
|
|
||||||
The variable is inlined into the JavaScript bundle. Treat it as an application
|
|
||||||
credential for Go Loose, not as a secret equivalent to the interviewer JWT. If
|
|
||||||
it is unset or empty, the client omits the header and protected interviewer
|
|
||||||
routes fail closed with 401. Login and participant routes do not require the
|
|
||||||
key, but the client still sends it when the variable is present.
|
|
||||||
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
|
||||||
textarea when the Clipboard API is unavailable on local HTTP domains.
|
textarea when the Clipboard API is unavailable on local HTTP domains.
|
||||||
|
|
@ -65,7 +53,9 @@ 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`. The app also schedules logout from the JWT expiration claim so an idle
|
`/login`. On `nmbs.guess.dev` and `ypto.guess.dev`, the app restores
|
||||||
|
`/api/auth/session` or redirects to `/api/auth/login`; participant routes stay
|
||||||
|
token-based. The app also schedules logout from the JWT expiration claim so an idle
|
||||||
expired session cannot remain on a protected page.
|
expired session cannot remain on a protected page.
|
||||||
|
|
||||||
The machine-readable OpenAPI 3.0 contract at `/api/openapi.json` is the source
|
The machine-readable OpenAPI 3.0 contract at `/api/openapi.json` is the source
|
||||||
|
|
@ -120,7 +110,5 @@ receives interviewer notes or assessment reference answers.
|
||||||
|
|
||||||
## Container
|
## Container
|
||||||
|
|
||||||
Build with `docker build -t go-guess-frontend .`. Pass
|
Build with `docker build -t go-guess-frontend .`. The production Nginx server
|
||||||
`--build-arg VITE_GO_LOOSE_API_KEY=...` when the image must call protected
|
|
||||||
interviewer routes; see the API integration section. The production Nginx server
|
|
||||||
serves the SPA and proxies `/api` to the Docker Compose service `api:8080`.
|
serves the SPA and proxies `/api` to the Docker Compose service `api:8080`.
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,7 @@ function apiURL(path: string): string {
|
||||||
|
|
||||||
function tenantHeader(): Record<string, string> {
|
function tenantHeader(): Record<string, string> {
|
||||||
const tenant = tenantFromHostname(window.location.hostname)
|
const tenant = tenantFromHostname(window.location.hostname)
|
||||||
const apiKey = import.meta.env.VITE_GO_LOOSE_API_KEY
|
return tenant ? { 'X-Tenant-Id': tenant } : {}
|
||||||
return {
|
|
||||||
...(tenant ? { 'X-Tenant-Id': tenant } : {}),
|
|
||||||
...(typeof apiKey === 'string' && apiKey !== '' ? { 'X-API-Key': apiKey } : {}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {DataProvider} from './DataContext'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession();
|
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
|
||||||
if (location.pathname.startsWith('/participant/')) {
|
if (location.pathname.startsWith('/participant/')) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -20,6 +20,9 @@ export default function App() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
|
if (ssoStatus !== 'idle') {
|
||||||
|
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
|
||||||
|
}
|
||||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
|
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
|
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
|
||||||
|
|
||||||
|
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
|
||||||
|
|
||||||
|
export function isSSOHost() {
|
||||||
|
return ssoHosts.has(window.location.hostname)
|
||||||
|
}
|
||||||
|
|
||||||
export function useAuthSession() {
|
export function useAuthSession() {
|
||||||
|
const location = useLocation()
|
||||||
|
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
|
||||||
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)
|
||||||
|
const needsSSO = sso && !authenticated && !authStorage.token()
|
||||||
|
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking'
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
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 })
|
navigate('/login', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,6 +33,36 @@ export function useAuthSession() {
|
||||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||||
}, [navigate])
|
}, [navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needsSSO) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/session', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
redirect: 'manual',
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
if (response.ok) {
|
||||||
|
authStorage.save(await response.json())
|
||||||
|
setAuthenticated(true)
|
||||||
|
setSessionExpired(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The browser redirect below starts a fresh Go Loose login.
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setRedirecting(true)
|
||||||
|
window.location.replace('/api/auth/login')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [needsSSO])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authenticated) return
|
if (!authenticated) return
|
||||||
const expiresAt = authStorage.expiresAt()
|
const expiresAt = authStorage.expiresAt()
|
||||||
|
|
@ -41,7 +86,8 @@ export function useAuthSession() {
|
||||||
authStorage.clear()
|
authStorage.clear()
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
setSessionExpired(false)
|
setSessionExpired(false)
|
||||||
|
if (isSSOHost()) window.location.assign('/api/auth/logout')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { authenticated, sessionExpired, completeLogin, logout }
|
return { authenticated, sessionExpired, ssoStatus, completeLogin, logout }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
scripts/toggle-origin.sh
Executable file
28
scripts/toggle-origin.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
github_url='https://github.com/danyel/go-guess.git'
|
||||||
|
forgejo_url='https://forgejo.dev/exr462/go-guess.git'
|
||||||
|
remote='origin'
|
||||||
|
|
||||||
|
current_url=$(git remote get-url "$remote")
|
||||||
|
case "$current_url" in
|
||||||
|
"$github_url")
|
||||||
|
target_url=$forgejo_url
|
||||||
|
;;
|
||||||
|
"$forgejo_url")
|
||||||
|
target_url=$github_url
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Refusing to change %s: unrecognized URL %s\n' "$remote" "$current_url" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
git remote set-url "$remote" "$target_url"
|
||||||
|
if git config --get-all "remote.$remote.pushurl" >/dev/null; then
|
||||||
|
git config --replace-all "remote.$remote.pushurl" "$target_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s now uses %s\n' "$remote" "$(git remote get-url "$remote")"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue