feat: integrate tenant SSO and remote toggle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-10-03 00:14:23 +02:00
parent 660d5291ec
commit ca5765b988
27 changed files with 529 additions and 159 deletions

View file

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

View file

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

View file

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

View file

@ -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,
) )

View file

@ -3,7 +3,7 @@ module github.com/danyel/go-guess/backend
go 1.27.1 go 1.27.1
require ( require (
github.com/danyel/go-loose v0.0.0-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

View file

@ -22,8 +22,8 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danyel/go-loose v0.0.0-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=

View file

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

View 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
}

View file

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

View file

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

View file

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

View 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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: ""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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