From ca5765b98836a0a17b86aab1a26f850fb43b98f5 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 3 Oct 2026 00:14:23 +0200 Subject: [PATCH] feat: integrate tenant SSO and remote toggle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 15 ++- Dockerfile | 2 - README.md | 86 ++++-------- backend/cmd/api/main.go | 32 +++-- backend/go.mod | 2 +- backend/go.sum | 4 +- backend/internal/config/config.go | 33 +++++ backend/internal/golooseauth/registry.go | 122 ++++++++++++++++++ backend/internal/service/services.go | 31 +++++ backend/internal/web/handler/handler.go | 5 +- backend/internal/web/handler/handler_test.go | 11 ++ backend/internal/web/handler/sso.go | 92 +++++++++++++ .../web/middleware/TenantSchemaMiddleware.go | 10 +- .../middleware/TenantSchemaMiddleware_test.go | 12 +- backend/internal/web/openapi/openapi.go | 2 - backend/internal/web/router/router.go | 13 +- backend/internal/web/router/router_test.go | 71 +++++++--- .../go-guess/templates/app-deployment.yaml | 2 - deploy/helm/go-guess/values.yaml | 2 - deploy/nginx/README.md | 4 +- docker-compose.yml | 16 ++- frontend/Dockerfile | 2 - frontend/README.md | 30 ++--- frontend/src/api/client.ts | 6 +- frontend/src/app/App.tsx | 5 +- frontend/src/components/actions/Account.tsx | 50 ++++++- scripts/toggle-origin.sh | 28 ++++ 27 files changed, 529 insertions(+), 159 deletions(-) create mode 100644 backend/internal/golooseauth/registry.go create mode 100644 backend/internal/web/handler/sso.go create mode 100755 scripts/toggle-origin.sh diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 748ac28..2694dcb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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://.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 diff --git a/Dockerfile b/Dockerfile index 30ed6b8..76597c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/README.md b/README.md index df0f961..a740172 100644 --- a/README.md +++ b/README.md @@ -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 + and . 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 - or . The hostname's first + or . 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 -. The document is assembled in memory +. 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 `.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 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 3ee70ff..0e7847c 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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,21 +47,28 @@ 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) + 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( - 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, tokens, cfg.FrontendURL, ) diff --git a/backend/go.mod b/backend/go.mod index 83aa22b..25c353d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 8d7e738..ba99118 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4355a53..e68a2b1 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 diff --git a/backend/internal/golooseauth/registry.go b/backend/internal/golooseauth/registry.go new file mode 100644 index 0000000..f0e5a7f --- /dev/null +++ b/backend/internal/golooseauth/registry.go @@ -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 +} diff --git a/backend/internal/service/services.go b/backend/internal/service/services.go index a53e520..b8fb9d7 100644 --- a/backend/internal/service/services.go +++ b/backend/internal/service/services.go @@ -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 } diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index 204ed77..ece7e22 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -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 diff --git a/backend/internal/web/handler/handler_test.go b/backend/internal/web/handler/handler_test.go index 764675a..714da23 100644 --- a/backend/internal/web/handler/handler_test.go +++ b/backend/internal/web/handler/handler_test.go @@ -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) + } +} diff --git a/backend/internal/web/handler/sso.go b/backend/internal/web/handler/sso.go new file mode 100644 index 0000000..5d799e0 --- /dev/null +++ b/backend/internal/web/handler/sso.go @@ -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) +} diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware.go b/backend/internal/web/middleware/TenantSchemaMiddleware.go index 56b68a8..67e92cf 100644 --- a/backend/internal/web/middleware/TenantSchemaMiddleware.go +++ b/backend/internal/web/middleware/TenantSchemaMiddleware.go @@ -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 } diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware_test.go b/backend/internal/web/middleware/TenantSchemaMiddleware_test.go index d968acc..5d9e5bb 100644 --- a/backend/internal/web/middleware/TenantSchemaMiddleware_test.go +++ b/backend/internal/web/middleware/TenantSchemaMiddleware_test.go @@ -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) } } diff --git a/backend/internal/web/openapi/openapi.go b/backend/internal/web/openapi/openapi.go index ad0f60c..b70bf28 100644 --- a/backend/internal/web/openapi/openapi.go +++ b/backend/internal/web/openapi/openapi.go @@ -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, diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index bc98870..a9003fe 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -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{}}) diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go index dde7dd7..c4011d7 100644 --- a/backend/internal/web/router/router_test.go +++ b/backend/internal/web/router/router_test.go @@ -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 } diff --git a/deploy/helm/go-guess/templates/app-deployment.yaml b/deploy/helm/go-guess/templates/app-deployment.yaml index c25caed..d271706 100644 --- a/deploy/helm/go-guess/templates/app-deployment.yaml +++ b/deploy/helm/go-guess/templates/app-deployment.yaml @@ -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: diff --git a/deploy/helm/go-guess/values.yaml b/deploy/helm/go-guess/values.yaml index 3dc2eb3..6537aa2 100644 --- a/deploy/helm/go-guess/values.yaml +++ b/deploy/helm/go-guess/values.yaml @@ -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: "" diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 23cfc4b..37d990c 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index e469c0f..78f3869 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fcefeea..16970b8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/README.md b/frontend/README.md index 6992669..47bf688 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 `. 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`. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 006830a..7ee208e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -93,11 +93,7 @@ function apiURL(path: string): string { function tenantHeader(): Record { 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( diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 04a610c..711df6c 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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 ( @@ -20,6 +20,9 @@ export default function App() { ) } if (!authenticated) { + if (ssoStatus !== 'idle') { + return

Redirecting to sign in…

+ } return } return ( diff --git a/frontend/src/components/actions/Account.tsx b/frontend/src/components/actions/Account.tsx index 4b21720..51743bf 100644 --- a/frontend/src/components/actions/Account.tsx +++ b/frontend/src/components/actions/Account.tsx @@ -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 } } diff --git a/scripts/toggle-origin.sh b/scripts/toggle-origin.sh new file mode 100755 index 0000000..bc6098e --- /dev/null +++ b/scripts/toggle-origin.sh @@ -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")"