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
test updates. Never put demo users or test records in schema migrations.
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Interviewer
routes additionally require a Go Loose API key for the request tenant. Health,
OpenAPI, login, and opaque-token participant interview routes do not. Never
expose password hashes, CV/photo bytes, interviewer reference answers, or API
keys in public JSON or logs.
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev`
start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects
to `https://<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
`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 ./
RUN npm ci
COPY frontend/ ./
ARG VITE_GO_LOOSE_API_KEY
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
RUN npm run build
# Compile the API and seed binaries.

View file

@ -20,16 +20,17 @@ The complete stack runs with:
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
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
```
Start the shared TLS proxy described in
`/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
`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
@ -37,8 +38,10 @@ builds participant invitation and meeting links from the matching tenant browser
origin instead of the static localhost fallback.
Compose mounts the infrastructure CA from
`${LOCAL_DEV_CERTS_DIR:-/home/dnoulet/go/infrasctruture/certs}` into the API so
its HTTPS authorization calls to Go Loose validate normally.
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The
browser is redirected through the matching Go Loose tenant and returns to
`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and
`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them.
Sign in with:
@ -58,15 +61,14 @@ make frontend
The API listens on port `8080` inside Docker and is exposed to browsers only
through Traefik and the frontend's same-origin `/api` proxy. Vite permits the
documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and
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`
outside local development. Protected interviewer calls also need a Go Loose API
key, documented below.
outside local development.
## API contract
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
response schemas, status codes, authentication, tenant headers, path/query
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
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
Interviewer routes are authorized by Go Loose before the JWT check. The tenant
is read from the request `X-Tenant-Id` header and sent to Go Loose with
application slug `guess`. It is not taken from process configuration. The client
calls `POST /api/v1/authorize` on every protected request, removes `X-API-Key`
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.
Tenant hosts start browser authentication at `GET /api/auth/login`. Go Loose
returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the
resulting secure browser session for the application's bearer JWT. The tenant
is derived from the request host, or from `X-Tenant-Id` for API calls.
`GO_LOOSE_BASE_URL` is the authorize base URL. The default is
`https://%s.auth.dev`. A single `%s` is replaced with the tenant, which must
then be one DNS label (`nmbs`, not `nmbs.auth.dev` or a host with a port). A
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:
Compose configures `GO_LOOSE_AUTH_DOMAIN`, `GO_LOOSE_APP_DOMAIN`,
`GO_LOOSE_CA_FILE`, and per-tenant client ID and secret variables. These routes
do not require a JWT:
- `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
`/api/interviews/{token}` and `/api/participant-meetings/{token}`
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`
application. The local installer seed creates that application for `nmbs` and
`ypto`. For a containerized frontend, pass the key at image build time:
## Switch the Git remote
```bash
VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' docker compose build web
```
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 .
```
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between
`https://github.com/danyel/go-guess.git` and
`https://forgejo.dev/exr462/go-guess.git`. The script exits without changing an
unrecognized remote URL.
## Quality commands
@ -183,8 +164,7 @@ docker build -t go-guess .
The image serves the frontend and API on port `8080`. It applies database
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
and these environment variables must be provided. `GO_LOOSE_BASE_URL` is
optional and defaults to `https://%s.auth.dev`:
and these environment variables must be provided:
```bash
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 JWT_SECRET='replace-with-at-least-32-characters' \
-e FRONTEND_URL='https://go-guess.example.com' \
-e GO_LOOSE_BASE_URL='https://%s.auth.dev' \
go-guess
```
@ -219,15 +198,6 @@ For the repository's single-node Docker Rancher setup, run
installs the default persistent StorageClass documented in
`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,
and NodePort `30080`. Production uses production fixtures, persistent volumes,
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/repository"
"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/service"
"github.com/danyel/go-guess/backend/internal/web/handler"
@ -46,10 +47,14 @@ func main() {
}
defer bus.Close()
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)
interviews := service.NewScheduledInterviewService(store, bus)
api := router.New(
handler.New(
apiHandler := handler.New(
db,
service.NewAuthService(store, tokens),
service.NewJobService(store),
@ -60,7 +65,10 @@ func main() {
interviews,
cfg.MaxUploadMB,
cfg.FrontendURL,
),
)
apiHandler.SetBrowserAuth(browser)
api := router.New(
apiHandler,
tokens,
cfg.FrontendURL,
)

View file

@ -3,7 +3,7 @@ module github.com/danyel/go-guess/backend
go 1.27.1
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/golang-jwt/jwt/v5 v5.3.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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f h1:9GV60iNpwRsZONzZcFHsGxYNeU0iItiitOvFNLqlkkA=
github.com/danyel/go-loose v0.0.0-20261002165652-46030a5b144f/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14 h1:kX0zybG4E3VLB4QpOsyJEBu4HoXx33CY2x8rtACHRRM=
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -15,6 +15,19 @@ type Config struct {
MaxUploadMB int64
FrontendURL 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) {
@ -34,6 +47,7 @@ func Load() (Config, error) {
MaxUploadMB: maxUploadMB,
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
RabbitMQURL: env("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
GoLoose: loadGoLoose(),
}
if len(cfg.JWTSecret) < 32 {
return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters")
@ -41,6 +55,25 @@ func Load() (Config, error) {
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 {
if value := os.Getenv(key); 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 {
Login(context.Context, string, string) (string, model.User, error)
EstablishExternalSession(context.Context, string, string) (string, model.User, error)
}
type IJobService interface {
@ -88,6 +89,36 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
return token, user, nil
}
func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
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 {
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/eventbus"
"github.com/danyel/go-guess/backend/internal/golooseauth"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
@ -37,6 +38,7 @@ type Handler struct {
interviews service.IScheduledInterviewService
maxUpload int64
frontendURL string
browser *golooseauth.Registry
}
func New(
@ -651,7 +653,8 @@ func (h *Handler) frontendURLForRequest(r *http.Request) string {
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return h.frontendURL
}
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") {
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") ||
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") {
return origin
}
return h.frontendURL

View file

@ -28,3 +28,14 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
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"
"regexp"
"github.com/danyel/go-guess/backend/internal/golooseauth"
"github.com/danyel/go-loose/client"
"gorm.io/gorm"
)
@ -18,7 +19,7 @@ const TxKey contextKey = "tenant_db_tx"
var safeSchemaRegex = regexp.MustCompile(`^[a-zA-Z0-9_:-]+$`)
// 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error": "Missing X-Tenant-Id header"}`))
tenantID = golooseauth.TenantFromHost(r.Host, appDomain)
}
if tenantID == "" || db == nil {
next.ServeHTTP(w, r)
return
}

View file

@ -10,7 +10,7 @@ func TestTenantMiddlewareAllowsSystemEndpointsWithoutTenant(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := TenantSchemaMiddleware(nil)(next)
handler := TenantSchemaMiddleware(nil, "guess.dev")(next)
for _, path := range []string{"/api/health", "/api/openapi.json"} {
response := httptest.NewRecorder()
@ -21,15 +21,15 @@ func TestTenantMiddlewareAllowsSystemEndpointsWithoutTenant(t *testing.T) {
}
}
func TestTenantMiddlewareStillRequiresTenantForDataEndpoints(t *testing.T) {
handler := TenantSchemaMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("data endpoint reached without a tenant")
func TestTenantMiddlewareUsesPublicSchemaWithoutTenant(t *testing.T) {
handler := TenantSchemaMiddleware(nil, "guess.dev")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
if response.Code != http.StatusOK {
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 {
value["security"] = []map[string][]string{{
"apiKeyAuth": {},
"bearerAuth": {},
}}
}
@ -131,7 +130,6 @@ func (b *Builder) Document() map[string]any {
"paths": b.paths,
"components": 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"},
},
"schemas": b.schemas,

View file

@ -22,12 +22,22 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
spec := openapi.New()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
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))
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})
register(router, spec, false, http.MethodGet, "/api/health", h.Health,
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,
openapi.Operation{Summary: "Authenticate an interviewer", Request: webmodel.LoginRequest{}, Response: webmodel.LoginResponse{}})
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,
openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
router.Group(func(protected chi.Router) {
protected.Use(mw.GoLooseMiddleware())
protected.Use(authenticate(tokens))
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})

View file

@ -9,10 +9,13 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/danyel/go-guess/backend/internal/config"
"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/service"
"github.com/danyel/go-guess/backend/internal/service/model"
@ -24,7 +27,6 @@ import (
const (
testTenant = "ypto"
testAPIKey = "gl_test_key_that_is_long_enough_for_client"
)
func init() {
@ -45,16 +47,6 @@ func openStubDB(t *testing.T) *gorm.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 {
t.Helper()
if interviews == nil {
@ -71,16 +63,16 @@ func withTenant(request *http.Request) {
request.Header.Set(client.XTenantId, testTenant)
}
func withAPIKey(request *http.Request) {
request.Header.Set(client.DefaultHeader, testAPIKey)
}
type authStub struct{}
func (authStub) Login(context.Context, string, string) (string, model.User, error) {
return "", model.User{}, service.ErrInvalidCredentials
}
func (authStub) EstablishExternalSession(context.Context, string, string) (string, model.User, error) {
return "", model.User{}, service.ErrInvalidCredentials
}
type jobStub struct{}
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) {
allowGoLoose(t)
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(newTestHandler(t, nil), tokens, "http://localhost:5173")
unauthorizedRequest := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
withTenant(unauthorizedRequest)
withAPIKey(unauthorizedRequest)
unauthorized := httptest.NewRecorder()
router.ServeHTTP(unauthorized, unauthorizedRequest)
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)
withTenant(request)
withAPIKey(request)
request.Header.Set("Authorization", "Bearer "+token)
authorized := httptest.NewRecorder()
router.ServeHTTP(authorized, request)
@ -265,7 +254,6 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
}
func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) {
allowGoLoose(t)
interviews := &interviewCapture{}
tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
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)
withTenant(request)
withAPIKey(request)
request.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
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{}
func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil }

View file

@ -48,8 +48,6 @@ spec:
value: {{ .Values.app.tokenTTL | quote }}
- name: MAX_UPLOAD_MB
value: {{ .Values.app.maxUploadMB | quote }}
- name: GO_LOOSE_BASE_URL
value: {{ .Values.app.goLooseBaseURL | quote }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:

View file

@ -15,8 +15,6 @@ app:
frontendURL: http://localhost:8080
tokenTTL: 8h
maxUploadMB: 10
# Authorize URL for Go Loose. %s is the request X-Tenant-Id.
goLooseBaseURL: https://%s.auth.dev
secrets:
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
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
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
`VITE_GO_LOOSE_API_KEY`. Nginx does not add or strip that header.
forwards browser authentication and API requests without adding application
credentials. Nginx does not add or strip authorization headers.

View file

@ -37,8 +37,13 @@ services:
JWT_SECRET: local-development-secret-change-me-12345
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess.local}
APP_ENV: ${APP_ENV:-development}
GO_LOOSE_BASE_URL: ${GO_LOOSE_BASE_URL:-https://%s.auth.dev}
SSL_CERT_FILE: /certs/local-ca.crt
GO_LOOSE_AUTH_DOMAIN: auth.dev
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/
expose:
- "8080"
@ -46,7 +51,7 @@ services:
- go_guess_network
- local_dev_edge
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:
db:
condition: service_healthy
@ -54,10 +59,7 @@ services:
condition: service_healthy
web:
build:
context: ./frontend
args:
VITE_GO_LOOSE_API_KEY: ${VITE_GO_LOOSE_API_KEY:-}
build: ./frontend
expose:
- "80"
depends_on:

View file

@ -3,8 +3,6 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_GO_LOOSE_API_KEY
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
RUN npm run build
FROM nginx:1.29-alpine

View file

@ -36,27 +36,15 @@ application shell.
## 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
`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.
Set `VITE_GO_LOOSE_API_KEY` before `npm run dev` or `npm run build` to also send
`X-API-Key`. Vite reads `frontend/.env.local` for local development; that file
matches `*.local` and must not be committed. The standalone frontend image
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.
On the configured tenant hosts, the app checks `/api/auth/session` and redirects
to `/api/auth/login` when no Go Loose browser session exists. The callback
exchanges that session for the bearer token used by the typed API client.
Invitation and participant-meeting URLs use the validated request origin so
links stay on the tenant hostname. Copy actions fall back to a temporary selected
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
shown in the UI and are never replaced with demo data. A `401` from a protected
JSON, file, or event-stream request clears authentication and returns the user to
`/login`. 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.
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
Build with `docker build -t go-guess-frontend .`. Pass
`--build-arg VITE_GO_LOOSE_API_KEY=...` when the image must call protected
interviewer routes; see the API integration section. The production Nginx server
Build with `docker build -t go-guess-frontend .`. The production Nginx server
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> {
const tenant = tenantFromHostname(window.location.hostname)
const apiKey = import.meta.env.VITE_GO_LOOSE_API_KEY
return {
...(tenant ? { 'X-Tenant-Id': tenant } : {}),
...(typeof apiKey === 'string' && apiKey !== '' ? { 'X-API-Key': apiKey } : {}),
}
return tenant ? { 'X-Tenant-Id': tenant } : {}
}
async function request<T>(

View file

@ -9,7 +9,7 @@ import {DataProvider} from './DataContext'
export default function App() {
const location = useLocation();
const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession();
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
@ -20,6 +20,9 @@ export default function App() {
)
}
if (!authenticated) {
if (ssoStatus !== 'idle') {
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
}
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
}
return (

View file

@ -1,16 +1,31 @@
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'
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
export function isSSOHost() {
return ssoHosts.has(window.location.hostname)
}
export function useAuthSession() {
const location = useLocation()
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
const [sessionExpired, setSessionExpired] = useState(false)
const [redirecting, setRedirecting] = useState(false)
const needsSSO = sso && !authenticated && !authStorage.token()
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking'
const navigate = useNavigate()
useEffect(() => {
function handleSessionExpired() {
setAuthenticated(false)
setSessionExpired(true)
if (isSSOHost() && !window.location.pathname.startsWith('/participant/')) {
window.location.replace('/api/auth/login')
return
}
navigate('/login', { replace: true })
}
@ -18,6 +33,36 @@ export function useAuthSession() {
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
}, [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(() => {
if (!authenticated) return
const expiresAt = authStorage.expiresAt()
@ -41,7 +86,8 @@ export function useAuthSession() {
authStorage.clear()
setAuthenticated(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")"