feat: integrate tenant SSO and remote toggle
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
660d5291ec
commit
ca5765b988
27 changed files with 529 additions and 159 deletions
15
.github/copilot-instructions.md
vendored
15
.github/copilot-instructions.md
vendored
|
|
@ -64,11 +64,16 @@ Goose files in `backend/migrations` are schema-only and run in every environment
|
|||
Every entity change requires a migration plus corresponding fixture and integration
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
86
README.md
86
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
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
122
backend/internal/golooseauth/registry.go
Normal file
122
backend/internal/golooseauth/registry.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package golooseauth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/config"
|
||||
)
|
||||
|
||||
const SessionCookie = "go_loose_user_session"
|
||||
|
||||
type Registry struct {
|
||||
appDomain string
|
||||
bySlug map[string]*goloose.BrowserAuth
|
||||
}
|
||||
|
||||
func New(cfg config.GoLooseConfig) (*Registry, error) {
|
||||
registry := &Registry{
|
||||
appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."),
|
||||
bySlug: map[string]*goloose.BrowserAuth{},
|
||||
}
|
||||
if registry.appDomain == "" {
|
||||
registry.appDomain = "guess.dev"
|
||||
}
|
||||
if len(cfg.Tenants) == 0 {
|
||||
return registry, nil
|
||||
}
|
||||
authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".")
|
||||
if authDomain == "" {
|
||||
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
|
||||
}
|
||||
httpClient, err := client(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for slug, tenant := range cfg.Tenants {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
if slug == "" || strings.Contains(slug, ".") {
|
||||
return nil, fmt.Errorf("invalid Go Loose tenant slug %q", slug)
|
||||
}
|
||||
auth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: "https://" + slug + "." + authDomain,
|
||||
ClientID: tenant.ClientID,
|
||||
ClientSecret: tenant.ClientSecret,
|
||||
RedirectURL: "https://" + slug + "." + registry.appDomain + "/api/auth/callback",
|
||||
AfterLoginURL: "/",
|
||||
AfterLogoutURL: "/",
|
||||
LoginPath: "/api/auth/login",
|
||||
CookieName: SessionCookie,
|
||||
CookieSecure: true,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure %s Go Loose login: %w", slug, err)
|
||||
}
|
||||
registry.bySlug[slug] = auth
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func (r *Registry) AppDomain() string {
|
||||
if r == nil || r.appDomain == "" {
|
||||
return "guess.dev"
|
||||
}
|
||||
return r.appDomain
|
||||
}
|
||||
|
||||
func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
|
||||
if r == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
slug := TenantFromHost(host, r.appDomain)
|
||||
auth, ok := r.bySlug[slug]
|
||||
return auth, slug, ok
|
||||
}
|
||||
|
||||
func TenantFromHost(host, appDomain string) string {
|
||||
hostname := strings.ToLower(host)
|
||||
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
||||
hostname = strings.ToLower(parsed)
|
||||
}
|
||||
appDomain = strings.TrimPrefix(strings.ToLower(appDomain), ".")
|
||||
if appDomain == "" {
|
||||
appDomain = "guess.dev"
|
||||
}
|
||||
suffix := "." + appDomain
|
||||
if !strings.HasSuffix(hostname, suffix) {
|
||||
return ""
|
||||
}
|
||||
slug := strings.TrimSuffix(hostname, suffix)
|
||||
if slug == "" || strings.Contains(slug, ".") {
|
||||
return ""
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func client(caFile string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if caFile != "" {
|
||||
pem, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Go Loose CA: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("parse Go Loose CA %s", caFile)
|
||||
}
|
||||
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ const CandidateThreshold = 60.0
|
|||
|
||||
type IAuthService interface {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
backend/internal/web/handler/sso.go
Normal file
92
backend/internal/web/handler/sso.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
||||
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||
)
|
||||
|
||||
var (
|
||||
errMissingSession = errors.New("missing Go Loose session")
|
||||
errTenantMismatch = errors.New("Go Loose session does not match this tenant")
|
||||
)
|
||||
|
||||
func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
|
||||
h.browser = auth
|
||||
}
|
||||
|
||||
func (h *Handler) AppDomain() string {
|
||||
if h.browser == nil {
|
||||
return "guess.dev"
|
||||
}
|
||||
return h.browser.AppDomain()
|
||||
}
|
||||
|
||||
func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := h.externalAuth(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth.LoginHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := h.externalAuth(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth.CallbackHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
|
||||
auth, ok := h.externalAuth(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth.LogoutHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := r.Cookie(golooseauth.SessionCookie); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, errMissingSession)
|
||||
return
|
||||
}
|
||||
auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := goloose.UserFromContext(r.Context())
|
||||
if !ok || user.TenantSlug != slug {
|
||||
writeError(w, http.StatusForbidden, errTenantMismatch)
|
||||
return
|
||||
}
|
||||
token, local, err := h.auth.EstablishExternalSession(r.Context(), user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, webmodel.LoginResponse{Token: token, User: webmapper.UserToWeb(local)})
|
||||
})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) {
|
||||
auth, _, ok := h.externalAuthWithSlug(r)
|
||||
return auth, ok
|
||||
}
|
||||
|
||||
func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, string, bool) {
|
||||
if h.browser == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return h.browser.ForHost(r.Host)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{}})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
28
scripts/toggle-origin.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
github_url='https://github.com/danyel/go-guess.git'
|
||||
forgejo_url='https://forgejo.dev/exr462/go-guess.git'
|
||||
remote='origin'
|
||||
|
||||
current_url=$(git remote get-url "$remote")
|
||||
case "$current_url" in
|
||||
"$github_url")
|
||||
target_url=$forgejo_url
|
||||
;;
|
||||
"$forgejo_url")
|
||||
target_url=$github_url
|
||||
;;
|
||||
*)
|
||||
printf 'Refusing to change %s: unrecognized URL %s\n' "$remote" "$current_url" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
git remote set-url "$remote" "$target_url"
|
||||
if git config --get-all "remote.$remote.pushurl" >/dev/null; then
|
||||
git config --replace-all "remote.$remote.pushurl" "$target_url"
|
||||
fi
|
||||
|
||||
printf '%s now uses %s\n' "$remote" "$(git remote get-url "$remote")"
|
||||
Loading…
Add table
Add a link
Reference in a new issue