Added OAuth and login

main
Tom Peltonen 2026-10-24 06:36:02 +11:00
parent 5c9a7a0f64
commit be2cfc8bfd
35 changed files with 2687 additions and 159 deletions

View File

@ -28,6 +28,8 @@ npm run dev
Open <http://localhost:5173>. Vite proxies `/api/*` to `http://localhost:8080/api/*`. The API is also available without the `/api` prefix for probes and direct clients. Open <http://localhost:5173>. Vite proxies `/api/*` to `http://localhost:8080/api/*`. The API is also available without the `/api` prefix for probes and direct clients.
With no OIDC provider configured, management sign-in uses the local development password `admin` and grants both admin and view roles. Change `auth.localPassword` (or `KERERU_AUTH_LOCAL_PASSWORD`) before exposing the service.
## Configuration ## Configuration
Settings are resolved in this order (highest wins): Settings are resolved in this order (highest wins):
@ -57,6 +59,16 @@ kereru-forms --conf ./config/config.example.yaml
| – | `OTEL_EXPORTER_OTLP_ENDPOINT` | `localhost:4318` | | – | `OTEL_EXPORTER_OTLP_ENDPOINT` | `localhost:4318` |
| – | `KERERU_CORS_ORIGINS` | `http://localhost:5173` | | – | `KERERU_CORS_ORIGINS` | `http://localhost:5173` |
| – | `KERERU_ENVIRONMENT` | `development` | | – | `KERERU_ENVIRONMENT` | `development` |
| – | `KERERU_AUTH_LOCAL_PASSWORD` | `admin` |
| – | `KERERU_AUTH_LOCAL_TOKEN_SECRET` | random per process |
| – | `KERERU_AUTH_LOCAL_TOKEN_TTL_HOURS` | `8` |
| – | `KERERU_AUTH_REQUIRE_SUBMISSION_AUTH` | `false` |
| – | `KERERU_OIDC_ISSUER_URL` | – |
| – | `KERERU_OIDC_CLIENT_ID` | – |
| – | `KERERU_OIDC_SCOPES` | `openid,profile,email` |
| – | `KERERU_OIDC_ROLE_CLAIM` | `roles` |
| – | `KERERU_OIDC_ADMIN_ROLE` | `admin` |
| – | `KERERU_OIDC_VIEW_ROLE` | `view` |
Both an IPv4 and an IPv6 listener may be configured at the same time. Set `--listen-addr ""` to disable the IPv4 listener. Both an IPv4 and an IPv6 listener may be configured at the same time. Set `--listen-addr ""` to disable the IPv4 listener.
@ -64,6 +76,23 @@ Frontend build-time setting: `VITE_API_BASE_URL` (defaults to `/api`; set to `ht
Sample files live in [`config/`](config). Sample files live in [`config/`](config).
## Authentication
Management is always protected. Two modes are supported:
- **Local password fallback** when `auth.oidc.issuerUrl` is empty. The configured password grants both `admin` and `view` roles, which is intended for local development and testing.
- **OAuth/OIDC** when an issuer is configured. The React SPA uses the authorization-code flow with PKCE and presents the provider's ID token as a bearer token. Configure a **public SPA client** in Okta/Keycloak and register the frontend callback URL, for example `http://localhost:5173/auth/callback` in development or `https://forms.example.com/auth/callback` in production.
The backend validates the ID token signature, issuer, audience, expiry and configured role claim. Role mappings are configurable:
- `adminRole` (`admin` by default) is required for form create/update/delete, activation, versioning and access-code management.
- `viewRole` (`view` by default) is required to list or read submissions. An admin-only token is deliberately denied submission viewing.
- `requireSubmissionAuth: true` requires any authenticated user (no role) to list/open forms and submit responses.
Common role claim examples: `roles`, `groups`, or `realm_access.roles` for Keycloak. Set `localTokenSecret` to a stable value if local-password tokens must survive restarts or multiple replicas.
Local tokens and OIDC ID tokens are kept in browser `sessionStorage` and sent as `Authorization: Bearer …`.
## API ## API
The contract is defined in [`backend/openapi/openapi.yaml`](backend/openapi/openapi.yaml) and the server stubs are generated with: The contract is defined in [`backend/openapi/openapi.yaml`](backend/openapi/openapi.yaml) and the server stubs are generated with:
@ -80,7 +109,11 @@ Key routes:
| Method | Path | Purpose | | Method | Path | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/health` | Health and version | | `GET` | `/health` | Health and version |
| `GET`/`POST` | `/forms` | List / create forms | | `GET` | `/auth/config` | Browser authentication mode / OIDC details |
| `POST` | `/auth/login` | Local password login fallback |
| `GET` | `/auth/me` | Current user and role flags |
| `POST` | `/auth/logout` | Stateless logout acknowledgement |
| `GET`/`POST` | `/forms` | List / create forms (create requires admin) |
| `GET`/`PUT`/`DELETE` | `/forms/{formId}` | Read / update / delete a form version | | `GET`/`PUT`/`DELETE` | `/forms/{formId}` | Read / update / delete a form version |
| `POST` | `/forms/{formId}/activate` | Activate a form version | | `POST` | `/forms/{formId}/activate` | Activate a form version |
| `POST` | `/forms/{formId}/inactivate` | Inactivate a form version | | `POST` | `/forms/{formId}/inactivate` | Inactivate a form version |
@ -111,4 +144,7 @@ See [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) and [`deploy/k8s.yaml`](deploy/k8
## Security note ## Security note
The management endpoints are intentionally unauthenticated to match the current scope. Deploy behind an authenticating reverse proxy or add an auth layer before exposing management to untrusted networks. Access codes protect individual form submissions, not administration. Management endpoints require a bearer JWT. In local-password mode change the default `admin` password and set a stable `localTokenSecret`;
in OIDC mode enforce MFA and role assignment in the identity provider. Use HTTPS in production, keep `corsOrigins` narrow,
and enable `requireSubmissionAuth` if forms must not be publicly accessible. Access codes protect
individual form submissions, not administration.

View File

@ -12,6 +12,7 @@ import (
"time" "time"
"kereru.forms/internal/api" "kereru.forms/internal/api"
"kereru.forms/internal/auth"
"kereru.forms/internal/config" "kereru.forms/internal/config"
"kereru.forms/internal/database" "kereru.forms/internal/database"
"kereru.forms/internal/logging" "kereru.forms/internal/logging"
@ -73,7 +74,19 @@ func run() error {
} }
}() }()
server := api.NewServer(store, logger, version) authService, err := auth.New(ctx, cfg.Auth)
if err != nil {
return fmt.Errorf("setup authentication: %w", err)
}
logger.Info().
Bool("oidc", cfg.Auth.OIDCEnabled()).
Bool("submission_auth_required", cfg.Auth.RequireSubmissionAuth).
Msg("authentication configured")
if !cfg.Auth.OIDCEnabled() && cfg.Auth.LocalPassword == "admin" {
logger.Warn().Msg("using the default local password; set KERERU_AUTH_LOCAL_PASSWORD or auth.localPassword")
}
server := api.NewServer(store, logger, version, authService)
router := api.NewRouter(server, logger, api.RouterOptions{ router := api.NewRouter(server, logger, api.RouterOptions{
CORSOrigins: cfg.CORSOrigins, CORSOrigins: cfg.CORSOrigins,
ServiceName: "kereru-forms", ServiceName: "kereru-forms",

View File

@ -28,9 +28,11 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect github.com/cloudwego/base64x v0.1.7 // indirect
github.com/coreos/go-oidc/v3 v3.21.0 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect github.com/dustin/go-humanize v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect github.com/gin-contrib/sse v1.1.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect
@ -39,6 +41,7 @@ require (
github.com/go-playground/validator/v10 v10.30.5 // indirect github.com/go-playground/validator/v10 v10.30.5 // indirect
github.com/goccy/go-json v0.11.2 // indirect github.com/goccy/go-json v0.11.2 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
@ -66,6 +69,7 @@ require (
golang.org/x/arch v0.31.0 // indirect golang.org/x/arch v0.31.0 // indirect
golang.org/x/crypto v0.57.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.59.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect

View File

@ -14,6 +14,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -29,6 +31,8 @@ github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@ -52,6 +56,8 @@ github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -174,6 +180,8 @@ golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=

View File

@ -0,0 +1,121 @@
package api
import (
"context"
"errors"
"github.com/gin-gonic/gin"
openapi_types "github.com/oapi-codegen/runtime/types"
"kereru.forms/internal/auth"
)
func (s *Server) GetAuthConfig(ctx context.Context, _ GetAuthConfigRequestObject) (GetAuthConfigResponseObject, error) {
_, span := s.tracer.Start(ctx, "GetAuthConfig")
defer span.End()
if s.auth == nil {
return GetAuthConfig200JSONResponse(AuthConfig{
Mode: AuthConfigMode("password"),
}), nil
}
cfg := s.auth.PublicConfig()
out := AuthConfig{
Mode: AuthConfigMode(cfg.Mode),
RequireSubmissionAuth: cfg.RequireSubmissionAuth,
}
if cfg.OIDC != nil {
out.Oidc = &OIDCClientConfig{
IssuerUrl: cfg.OIDC.IssuerURL,
AuthorizationEndpoint: cfg.OIDC.AuthorizationEndpoint,
TokenEndpoint: cfg.OIDC.TokenEndpoint,
ClientId: cfg.OIDC.ClientID,
Scopes: cfg.OIDC.Scopes,
}
}
return GetAuthConfig200JSONResponse(out), nil
}
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
_, span := s.tracer.Start(ctx, "Login")
defer span.End()
if s.auth == nil {
return Login400JSONResponse{ErrorJSONResponse: errJSON("authentication is not configured", "auth_not_configured")}, nil
}
if request.Body == nil {
return Login400JSONResponse{ErrorJSONResponse: errJSON("password is required", "invalid_request")}, nil
}
token, user, err := s.auth.Login(request.Body.Password)
switch {
case errors.Is(err, auth.ErrLocalLoginDisabled):
return Login400JSONResponse{ErrorJSONResponse: errJSON("local password login is disabled", "local_login_disabled")}, nil
case errors.Is(err, auth.ErrInvalidCredentials):
return Login401JSONResponse(Error{
Error: "invalid credentials",
Code: stringPtr("invalid_credentials"),
}), nil
case err != nil:
s.logger.Error().Err(err).Msg("local login")
return Login400JSONResponse{ErrorJSONResponse: errJSON("could not sign in", "internal_error")}, nil
default:
return Login200JSONResponse(LoginResponse{
Token: token,
User: toAPIAuthUser(user),
}), nil
}
}
func (s *Server) GetCurrentUser(ctx context.Context, _ GetCurrentUserRequestObject) (GetCurrentUserResponseObject, error) {
_, span := s.tracer.Start(ctx, "GetCurrentUser")
defer span.End()
ginCtx, ok := ctx.(*gin.Context)
if !ok {
return GetCurrentUser401JSONResponse{ErrorJSONResponse: errJSON("authentication required", "unauthenticated")}, nil
}
user, ok := CurrentUser(ginCtx)
if !ok {
return GetCurrentUser401JSONResponse{ErrorJSONResponse: errJSON("authentication required", "unauthenticated")}, nil
}
return GetCurrentUser200JSONResponse(AuthMeResponse{
Authenticated: true,
User: authUserPtr(toAPIAuthUser(user)),
}), nil
}
func (s *Server) Logout(context.Context, LogoutRequestObject) (LogoutResponseObject, error) {
return Logout204Response{}, nil
}
func toAPIAuthUser(user auth.User) AuthUser {
roles := user.Roles
if roles == nil {
roles = []string{}
}
out := AuthUser{
Id: user.ID,
Roles: roles,
CanAdmin: user.CanAdmin,
CanView: user.CanView,
}
if user.Name != "" {
name := user.Name
out.Name = &name
}
if user.Email != "" {
email := openapi_types.Email(user.Email)
out.Email = &email
}
return out
}
func authUserPtr(user AuthUser) *AuthUser {
return &user
}
func stringPtr(value string) *string {
return &value
}

View File

@ -0,0 +1,206 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"kereru.forms/internal/auth"
)
type authUserContextKey struct{}
type accessPermission int
const (
permissionAuthenticated accessPermission = iota
permissionAdmin
permissionView
)
type accessRule struct {
requiresAuth bool
permission accessPermission
}
func authMiddleware(service *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
path := normalizeAPIPath(c.Request.URL.Path)
if path == "/auth/me" {
// /auth/me reports authentication state, so a missing or invalid
// token is handled by the endpoint rather than aborting here.
if rawToken, ok := bearerToken(c.GetHeader("Authorization")); ok {
if user, err := service.Authenticate(c.Request.Context(), rawToken); err == nil {
attachAuthUser(c, user)
}
}
c.Next()
return
}
rule := accessRuleFor(c.Request.Method, c.Request.URL.Path, service.RequiresSubmissionAuth())
if !rule.requiresAuth {
c.Next()
return
}
rawToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
abortUnauthenticated(c)
return
}
user, err := service.Authenticate(c.Request.Context(), rawToken)
if err != nil {
abortUnauthenticated(c)
return
}
if rule.permission == permissionAdmin && !user.CanAdmin {
abortForbidden(c)
return
}
if rule.permission == permissionView && !user.CanView {
abortForbidden(c)
return
}
attachAuthUser(c, user)
c.Next()
}
}
func attachAuthUser(c *gin.Context, user auth.User) {
c.Set("auth.user", user)
ctx := context.WithValue(c.Request.Context(), authUserContextKey{}, user)
c.Request = c.Request.WithContext(ctx)
}
// CurrentUser returns the authenticated user attached by authMiddleware.
func CurrentUser(c *gin.Context) (auth.User, bool) {
value, exists := c.Get("auth.user")
if !exists {
return auth.User{}, false
}
user, ok := value.(auth.User)
return user, ok
}
func accessRuleFor(method, requestPath string, requireSubmissionAuth bool) accessRule {
if method == http.MethodOptions {
return accessRule{}
}
path := normalizeAPIPath(requestPath)
if path == "/health" || path == "/auth" || strings.HasPrefix(path, "/auth/") {
return accessRule{}
}
segments := splitAPIPath(path)
if len(segments) == 0 {
return accessRule{}
}
switch segments[0] {
case "forms":
switch {
case len(segments) == 1:
if method == http.MethodPost {
return adminRule()
}
if method == http.MethodGet && requireSubmissionAuth {
return authenticatedRule()
}
case len(segments) == 2:
switch method {
case http.MethodPut, http.MethodDelete:
return adminRule()
case http.MethodGet:
if requireSubmissionAuth {
return authenticatedRule()
}
}
case len(segments) == 3:
switch segments[2] {
case "activate", "inactivate", "versions", "access-codes":
return adminRule()
case "submissions":
if method == http.MethodPost {
if requireSubmissionAuth {
return authenticatedRule()
}
return accessRule{}
}
if method == http.MethodGet {
return viewRule()
}
case "pdf":
if method == http.MethodGet && requireSubmissionAuth {
return authenticatedRule()
}
}
}
case "submissions":
if len(segments) == 2 && method == http.MethodGet {
return viewRule()
}
if len(segments) == 3 && segments[2] == "offline-pdf" && method == http.MethodGet {
return viewRule()
}
}
return accessRule{}
}
func adminRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionAdmin}
}
func viewRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionView}
}
func authenticatedRule() accessRule {
return accessRule{requiresAuth: true, permission: permissionAuthenticated}
}
func normalizeAPIPath(requestPath string) string {
path := requestPath
if path == "/api" {
return "/"
}
if strings.HasPrefix(path, "/api/") {
path = strings.TrimPrefix(path, "/api")
}
return path
}
func splitAPIPath(path string) []string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func bearerToken(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
return parts[1], parts[1] != ""
}
func abortUnauthenticated(c *gin.Context) {
c.Header("WWW-Authenticate", "Bearer")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authentication required",
"code": "unauthenticated",
})
}
func abortForbidden(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "insufficient permissions",
"code": "forbidden",
})
}

View File

@ -0,0 +1,205 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/zerolog"
"kereru.forms/internal/auth"
"kereru.forms/internal/config"
"kereru.forms/internal/database"
)
const authTestSecret = "api-auth-test-secret"
func newAuthTestRouter(t *testing.T, requireSubmissionAuth bool) (*gin.Engine, database.Store) {
t.Helper()
path := filepath.Join(t.TempDir(), "auth-api-test.db")
store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
authCfg := config.Default().Auth
authCfg.LocalPassword = "test-password"
authCfg.LocalTokenSecret = authTestSecret
authCfg.RequireSubmissionAuth = requireSubmissionAuth
service, err := auth.New(t.Context(), authCfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
server := NewServer(store, zerolog.Nop(), "test-version", service)
router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}})
return router, store
}
func doAuthedRequest(t *testing.T, router *gin.Engine, method, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func testLocalToken(t *testing.T, roles ...string) string {
t.Helper()
now := time.Now()
claims := jwt.MapClaims{
"iss": "kereru-forms",
"sub": "test-user",
"name": "Test User",
"roles": roles,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(authTestSecret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
func createAuthTestForm(t *testing.T, router *gin.Engine, token string) Form {
t.Helper()
body := CreateFormRequest{
Title: "Auth test form",
Questions: []QuestionInput{{
Key: "name",
Label: "Your name",
Type: Text,
Required: true,
}},
}
w := doAuthedRequest(t, router, http.MethodPost, "/forms", body, token)
if w.Code != http.StatusCreated {
t.Fatalf("create form: status %d body %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func TestLoginAndCurrentUser(t *testing.T) {
router, _ := newAuthTestRouter(t, false)
configResponse := doAuthedRequest(t, router, http.MethodGet, "/auth/config", nil, "")
if configResponse.Code != http.StatusOK {
t.Fatalf("auth config: status %d body %s", configResponse.Code, configResponse.Body.String())
}
authConfig := decode[AuthConfig](t, configResponse)
if authConfig.Mode != AuthConfigMode("password") {
t.Fatalf("unexpected auth mode %q", authConfig.Mode)
}
badLogin := doAuthedRequest(t, router, http.MethodPost, "/auth/login", LoginRequest{Password: "wrong"}, "")
if badLogin.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for bad password, got %d %s", badLogin.Code, badLogin.Body.String())
}
login := doAuthedRequest(t, router, http.MethodPost, "/auth/login", LoginRequest{Password: "test-password"}, "")
if login.Code != http.StatusOK {
t.Fatalf("login: status %d body %s", login.Code, login.Body.String())
}
loginBody := decode[LoginResponse](t, login)
if loginBody.Token == "" || !loginBody.User.CanAdmin || !loginBody.User.CanView {
t.Fatalf("unexpected login response: %+v", loginBody)
}
me := doAuthedRequest(t, router, http.MethodGet, "/auth/me", nil, loginBody.Token)
if me.Code != http.StatusOK {
t.Fatalf("me: status %d body %s", me.Code, me.Body.String())
}
meBody := decode[AuthMeResponse](t, me)
if !meBody.Authenticated || meBody.User == nil || meBody.User.Id != "local" {
t.Fatalf("unexpected me response: %+v", meBody)
}
anonymousMe := doAuthedRequest(t, router, http.MethodGet, "/auth/me", nil, "")
if anonymousMe.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous me, got %d", anonymousMe.Code)
}
}
func TestManagementRoleEnforcement(t *testing.T) {
router, _ := newAuthTestRouter(t, false)
adminToken := testLocalToken(t, "admin")
viewToken := testLocalToken(t, "view")
if w := doAuthedRequest(t, router, http.MethodPost, "/forms", CreateFormRequest{Title: "Anonymous"}, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodPost, "/forms", CreateFormRequest{Title: "View only"}, viewToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for view-only create, got %d %s", w.Code, w.Body.String())
}
form := createAuthTestForm(t, router, adminToken)
submissionsPath := "/forms/" + form.Id.String() + "/submissions"
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous submissions, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, adminToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for admin-only submissions, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodGet, submissionsPath, nil, viewToken); w.Code != http.StatusOK {
t.Fatalf("expected 200 for view submissions, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), CreateFormRequest{Title: "Updated"}, viewToken); w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for view-only update, got %d %s", w.Code, w.Body.String())
}
}
func TestOptionalSubmissionAuthentication(t *testing.T) {
router, _ := newAuthTestRouter(t, true)
adminToken := testLocalToken(t, "admin")
if w := doAuthedRequest(t, router, http.MethodGet, "/forms", nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 listing forms without auth, got %d", w.Code)
}
if w := doAuthedRequest(t, router, http.MethodGet, "/forms", nil, adminToken); w.Code != http.StatusOK {
t.Fatalf("expected 200 listing forms with auth, got %d %s", w.Code, w.Body.String())
}
form := createAuthTestForm(t, router, adminToken)
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{
QuestionId: form.Questions[0].Id,
Value: "Ada",
}},
}
submitPath := "/forms/" + form.Id.String() + "/submissions"
if w := doAuthedRequest(t, router, http.MethodPost, submitPath, submit, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 submitting without auth, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodPost, submitPath, submit, adminToken); w.Code != http.StatusCreated {
t.Fatalf("expected admin-only user to submit as any authenticated user, got %d %s", w.Code, w.Body.String())
}
if w := doAuthedRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 downloading form PDF without auth, got %d", w.Code)
}
}

View File

@ -24,6 +24,16 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
) )
const (
BearerAuthScopes = "bearerAuth.Scopes"
)
// Defines values for AuthConfigMode.
const (
Oidc AuthConfigMode = "oidc"
Password AuthConfigMode = "password"
)
// Defines values for QuestionType. // Defines values for QuestionType.
const ( const (
Boolean QuestionType = "boolean" Boolean QuestionType = "boolean"
@ -74,6 +84,32 @@ type AnswerRecord struct {
Value interface{} `json:"value"` Value interface{} `json:"value"`
} }
// AuthConfig defines model for AuthConfig.
type AuthConfig struct {
Mode AuthConfigMode `json:"mode"`
Oidc *OIDCClientConfig `json:"oidc,omitempty"`
RequireSubmissionAuth bool `json:"requireSubmissionAuth"`
}
// AuthConfigMode defines model for AuthConfig.Mode.
type AuthConfigMode string
// AuthMeResponse defines model for AuthMeResponse.
type AuthMeResponse struct {
Authenticated bool `json:"authenticated"`
User *AuthUser `json:"user,omitempty"`
}
// AuthUser defines model for AuthUser.
type AuthUser struct {
CanAdmin bool `json:"canAdmin"`
CanView bool `json:"canView"`
Email *openapi_types.Email `json:"email,omitempty"`
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Roles []string `json:"roles"`
}
// ClientTracking Client supplied tracking details. IP and user agent are captured server-side. // ClientTracking Client supplied tracking details. IP and user agent are captured server-side.
type ClientTracking struct { type ClientTracking struct {
Locale *string `json:"locale,omitempty"` Locale *string `json:"locale,omitempty"`
@ -178,6 +214,26 @@ type Health struct {
Version string `json:"version"` Version string `json:"version"`
} }
// LoginRequest defines model for LoginRequest.
type LoginRequest struct {
Password string `json:"password"`
}
// LoginResponse defines model for LoginResponse.
type LoginResponse struct {
Token string `json:"token"`
User AuthUser `json:"user"`
}
// OIDCClientConfig defines model for OIDCClientConfig.
type OIDCClientConfig struct {
AuthorizationEndpoint string `json:"authorizationEndpoint"`
ClientId string `json:"clientId"`
IssuerUrl string `json:"issuerUrl"`
Scopes []string `json:"scopes"`
TokenEndpoint string `json:"tokenEndpoint"`
}
// Question defines model for Question. // Question defines model for Question.
type Question struct { type Question struct {
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
@ -371,6 +427,9 @@ type ListFormsParams struct {
ActiveOnly *bool `form:"activeOnly,omitempty" json:"activeOnly,omitempty"` ActiveOnly *bool `form:"activeOnly,omitempty" json:"activeOnly,omitempty"`
} }
// LoginJSONRequestBody defines body for Login for application/json ContentType.
type LoginJSONRequestBody = LoginRequest
// CreateFormJSONRequestBody defines body for CreateForm for application/json ContentType. // CreateFormJSONRequestBody defines body for CreateForm for application/json ContentType.
type CreateFormJSONRequestBody = CreateFormRequest type CreateFormJSONRequestBody = CreateFormRequest
@ -385,6 +444,18 @@ type SubmitFormJSONRequestBody = SubmitFormRequest
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// Get browser authentication configuration
// (GET /auth/config)
GetAuthConfig(c *gin.Context)
// Log in with the local password fallback
// (POST /auth/login)
Login(c *gin.Context)
// Log out
// (POST /auth/logout)
Logout(c *gin.Context)
// Get the current authenticated user
// (GET /auth/me)
GetCurrentUser(c *gin.Context)
// List forms // List forms
// (GET /forms) // (GET /forms)
ListForms(c *gin.Context, params ListFormsParams) ListForms(c *gin.Context, params ListFormsParams)
@ -444,6 +515,62 @@ type ServerInterfaceWrapper struct {
type MiddlewareFunc func(c *gin.Context) type MiddlewareFunc func(c *gin.Context)
// GetAuthConfig operation middleware
func (siw *ServerInterfaceWrapper) GetAuthConfig(c *gin.Context) {
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.GetAuthConfig(c)
}
// Login operation middleware
func (siw *ServerInterfaceWrapper) Login(c *gin.Context) {
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.Login(c)
}
// Logout operation middleware
func (siw *ServerInterfaceWrapper) Logout(c *gin.Context) {
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.Logout(c)
}
// GetCurrentUser operation middleware
func (siw *ServerInterfaceWrapper) GetCurrentUser(c *gin.Context) {
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.GetCurrentUser(c)
}
// ListForms operation middleware // ListForms operation middleware
func (siw *ServerInterfaceWrapper) ListForms(c *gin.Context) { func (siw *ServerInterfaceWrapper) ListForms(c *gin.Context) {
@ -481,6 +608,8 @@ func (siw *ServerInterfaceWrapper) ListForms(c *gin.Context) {
// CreateForm operation middleware // CreateForm operation middleware
func (siw *ServerInterfaceWrapper) CreateForm(c *gin.Context) { func (siw *ServerInterfaceWrapper) CreateForm(c *gin.Context) {
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -505,6 +634,8 @@ func (siw *ServerInterfaceWrapper) DeleteForm(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -553,6 +684,8 @@ func (siw *ServerInterfaceWrapper) UpdateForm(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -577,6 +710,8 @@ func (siw *ServerInterfaceWrapper) ListAccessCodes(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -601,6 +736,8 @@ func (siw *ServerInterfaceWrapper) CreateAccessCodes(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -625,6 +762,8 @@ func (siw *ServerInterfaceWrapper) ActivateForm(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -649,6 +788,8 @@ func (siw *ServerInterfaceWrapper) InactivateForm(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -697,6 +838,8 @@ func (siw *ServerInterfaceWrapper) ListFormSubmissions(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -745,6 +888,8 @@ func (siw *ServerInterfaceWrapper) CreateFormVersion(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -782,6 +927,8 @@ func (siw *ServerInterfaceWrapper) GetSubmission(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -806,6 +953,8 @@ func (siw *ServerInterfaceWrapper) GetSubmissionOfflinePdf(c *gin.Context) {
return return
} }
c.Set(BearerAuthScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares { for _, middleware := range siw.HandlerMiddlewares {
middleware(c) middleware(c)
if c.IsAborted() { if c.IsAborted() {
@ -843,6 +992,10 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
ErrorHandler: errorHandler, ErrorHandler: errorHandler,
} }
router.GET(options.BaseURL+"/auth/config", wrapper.GetAuthConfig)
router.POST(options.BaseURL+"/auth/login", wrapper.Login)
router.POST(options.BaseURL+"/auth/logout", wrapper.Logout)
router.GET(options.BaseURL+"/auth/me", wrapper.GetCurrentUser)
router.GET(options.BaseURL+"/forms", wrapper.ListForms) router.GET(options.BaseURL+"/forms", wrapper.ListForms)
router.POST(options.BaseURL+"/forms", wrapper.CreateForm) router.POST(options.BaseURL+"/forms", wrapper.CreateForm)
router.DELETE(options.BaseURL+"/forms/:formId", wrapper.DeleteForm) router.DELETE(options.BaseURL+"/forms/:formId", wrapper.DeleteForm)
@ -863,6 +1016,97 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
type ErrorJSONResponse Error type ErrorJSONResponse Error
type GetAuthConfigRequestObject struct {
}
type GetAuthConfigResponseObject interface {
VisitGetAuthConfigResponse(w http.ResponseWriter) error
}
type GetAuthConfig200JSONResponse AuthConfig
func (response GetAuthConfig200JSONResponse) VisitGetAuthConfigResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type LoginRequestObject struct {
Body *LoginJSONRequestBody
}
type LoginResponseObject interface {
VisitLoginResponse(w http.ResponseWriter) error
}
type Login200JSONResponse LoginResponse
func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type Login400JSONResponse struct{ ErrorJSONResponse }
func (response Login400JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
return json.NewEncoder(w).Encode(response)
}
type Login401JSONResponse Error
func (response Login401JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type LogoutRequestObject struct {
}
type LogoutResponseObject interface {
VisitLogoutResponse(w http.ResponseWriter) error
}
type Logout204Response struct {
}
func (response Logout204Response) VisitLogoutResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type GetCurrentUserRequestObject struct {
}
type GetCurrentUserResponseObject interface {
VisitGetCurrentUserResponse(w http.ResponseWriter) error
}
type GetCurrentUser200JSONResponse AuthMeResponse
func (response GetCurrentUser200JSONResponse) VisitGetCurrentUserResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetCurrentUser401JSONResponse struct{ ErrorJSONResponse }
func (response GetCurrentUser401JSONResponse) VisitGetCurrentUserResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type ListFormsRequestObject struct { type ListFormsRequestObject struct {
Params ListFormsParams Params ListFormsParams
} }
@ -1464,6 +1708,18 @@ func (response GetSubmissionOfflinePdf500JSONResponse) VisitGetSubmissionOffline
// StrictServerInterface represents all server handlers. // StrictServerInterface represents all server handlers.
type StrictServerInterface interface { type StrictServerInterface interface {
// Get browser authentication configuration
// (GET /auth/config)
GetAuthConfig(ctx context.Context, request GetAuthConfigRequestObject) (GetAuthConfigResponseObject, error)
// Log in with the local password fallback
// (POST /auth/login)
Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error)
// Log out
// (POST /auth/logout)
Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error)
// Get the current authenticated user
// (GET /auth/me)
GetCurrentUser(ctx context.Context, request GetCurrentUserRequestObject) (GetCurrentUserResponseObject, error)
// List forms // List forms
// (GET /forms) // (GET /forms)
ListForms(ctx context.Context, request ListFormsRequestObject) (ListFormsResponseObject, error) ListForms(ctx context.Context, request ListFormsRequestObject) (ListFormsResponseObject, error)
@ -1526,6 +1782,114 @@ type strictHandler struct {
middlewares []StrictMiddlewareFunc middlewares []StrictMiddlewareFunc
} }
// GetAuthConfig operation middleware
func (sh *strictHandler) GetAuthConfig(ctx *gin.Context) {
var request GetAuthConfigRequestObject
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetAuthConfig(ctx, request.(GetAuthConfigRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetAuthConfig")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetAuthConfigResponseObject); ok {
if err := validResponse.VisitGetAuthConfigResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// Login operation middleware
func (sh *strictHandler) Login(ctx *gin.Context) {
var request LoginRequestObject
var body LoginJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.Login(ctx, request.(LoginRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "Login")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(LoginResponseObject); ok {
if err := validResponse.VisitLoginResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// Logout operation middleware
func (sh *strictHandler) Logout(ctx *gin.Context) {
var request LogoutRequestObject
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.Logout(ctx, request.(LogoutRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "Logout")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(LogoutResponseObject); ok {
if err := validResponse.VisitLogoutResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// GetCurrentUser operation middleware
func (sh *strictHandler) GetCurrentUser(ctx *gin.Context) {
var request GetCurrentUserRequestObject
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetCurrentUser(ctx, request.(GetCurrentUserRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetCurrentUser")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetCurrentUserResponseObject); ok {
if err := validResponse.VisitGetCurrentUserResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// ListForms operation middleware // ListForms operation middleware
func (sh *strictHandler) ListForms(ctx *gin.Context, params ListFormsParams) { func (sh *strictHandler) ListForms(ctx *gin.Context, params ListFormsParams) {
var request ListFormsRequestObject var request ListFormsRequestObject
@ -1989,65 +2353,77 @@ func (sh *strictHandler) GetSubmissionOfflinePdf(ctx *gin.Context, submissionId
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file

View File

@ -43,6 +43,9 @@ func NewRouter(server *Server, logger zerolog.Logger, opts RouterOptions) *gin.E
r.Use(withRequestMetadata()) r.Use(withRequestMetadata())
r.Use(logging.GinMiddleware(logger)) r.Use(logging.GinMiddleware(logger))
r.Use(corsMiddleware(opts.CORSOrigins)) r.Use(corsMiddleware(opts.CORSOrigins))
if server.auth != nil {
r.Use(authMiddleware(server.auth))
}
strict := NewStrictHandler(server, nil) strict := NewStrictHandler(server, nil)
// The API is exposed at the root (for probes and direct clients) and under // The API is exposed at the root (for probes and direct clients) and under

View File

@ -22,6 +22,7 @@ import (
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"kereru.forms/internal/auth"
"kereru.forms/internal/database" "kereru.forms/internal/database"
"kereru.forms/internal/pdf" "kereru.forms/internal/pdf"
) )
@ -33,17 +34,23 @@ type Server struct {
version string version string
startedAt time.Time startedAt time.Time
tracer trace.Tracer tracer trace.Tracer
auth *auth.Service
} }
// NewServer constructs the API implementation. // NewServer constructs the API implementation. Authentication is optional so
func NewServer(store database.Store, logger zerolog.Logger, version string) *Server { // tests can exercise handlers directly; the application always supplies it.
return &Server{ func NewServer(store database.Store, logger zerolog.Logger, version string, authService ...*auth.Service) *Server {
server := &Server{
store: store, store: store,
logger: logger, logger: logger,
version: version, version: version,
startedAt: time.Now(), startedAt: time.Now(),
tracer: otel.Tracer("kereru.forms/api"), tracer: otel.Tracer("kereru.forms/api"),
} }
if len(authService) > 0 {
server.auth = authService[0]
}
return server
} }
func errJSON(message, code string) ErrorJSONResponse { func errJSON(message, code string) ErrorJSONResponse {

View File

@ -0,0 +1,327 @@
// Package auth implements password fallback and OAuth/OIDC bearer-token
// authentication for Kereru Forms.
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"kereru.forms/internal/config"
)
const localIssuer = "kereru-forms"
var (
// ErrInvalidCredentials is returned when a local password is incorrect.
ErrInvalidCredentials = errors.New("invalid credentials")
// ErrInvalidToken is returned when a bearer token cannot be verified.
ErrInvalidToken = errors.New("invalid or expired token")
// ErrLocalLoginDisabled is returned when password login is used with OIDC.
ErrLocalLoginDisabled = errors.New("local password login is disabled")
)
// User is the authenticated principal exposed to the API and frontend.
type User struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Roles []string `json:"roles"`
CanAdmin bool `json:"canAdmin"`
CanView bool `json:"canView"`
}
// OIDCClientConfig is the browser-safe subset of OIDC configuration.
type OIDCClientConfig struct {
IssuerURL string `json:"issuerUrl"`
AuthorizationEndpoint string `json:"authorizationEndpoint"`
TokenEndpoint string `json:"tokenEndpoint"`
ClientID string `json:"clientId"`
Scopes []string `json:"scopes"`
}
// PublicConfig describes how the frontend should authenticate.
type PublicConfig struct {
Mode string `json:"mode"`
RequireSubmissionAuth bool `json:"requireSubmissionAuth"`
OIDC *OIDCClientConfig `json:"oidc,omitempty"`
}
// Service verifies bearer tokens and issues local password-mode tokens.
type Service struct {
cfg config.AuthConfig
localSecret []byte
localTTL time.Duration
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
}
// New builds an authentication service. When an OIDC issuer is configured,
// provider discovery is performed immediately so configuration errors fail
// fast at startup.
func New(ctx context.Context, cfg config.AuthConfig) (*Service, error) {
service := &Service{
cfg: cfg,
localTTL: time.Duration(cfg.LocalTokenTTLHours) * time.Hour,
}
if secret := strings.TrimSpace(cfg.LocalTokenSecret); secret != "" {
service.localSecret = []byte(secret)
} else {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate local token secret: %w", err)
}
service.localSecret = secret
}
if cfg.OIDCEnabled() {
discoveryCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
provider, err := oidc.NewProvider(discoveryCtx, strings.TrimSpace(cfg.OIDC.IssuerURL))
if err != nil {
return nil, fmt.Errorf("discover OIDC provider: %w", err)
}
service.oidcProvider = provider
service.oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: strings.TrimSpace(cfg.OIDC.ClientID),
})
}
return service, nil
}
// PublicConfig returns browser-safe authentication configuration.
func (s *Service) PublicConfig() PublicConfig {
out := PublicConfig{
Mode: "password",
RequireSubmissionAuth: s.cfg.RequireSubmissionAuth,
}
if s.oidcProvider != nil {
endpoint := s.oidcProvider.Endpoint()
out.Mode = "oidc"
out.OIDC = &OIDCClientConfig{
IssuerURL: s.cfg.OIDC.IssuerURL,
AuthorizationEndpoint: endpoint.AuthURL,
TokenEndpoint: endpoint.TokenURL,
ClientID: s.cfg.OIDC.ClientID,
Scopes: append([]string(nil), s.cfg.OIDC.Scopes...),
}
}
return out
}
// LocalLoginEnabled reports whether password fallback is active.
func (s *Service) LocalLoginEnabled() bool {
return s.oidcProvider == nil
}
// RequiresSubmissionAuth reports whether filling in forms requires login.
func (s *Service) RequiresSubmissionAuth() bool {
return s.cfg.RequireSubmissionAuth
}
// Login validates the local password and returns a signed JWT and user.
func (s *Service) Login(password string) (string, User, error) {
if s.oidcProvider != nil {
return "", User{}, ErrLocalLoginDisabled
}
if !passwordMatches(s.cfg.LocalPassword, password) {
return "", User{}, ErrInvalidCredentials
}
roles := s.localRoles()
user := s.user("local", "Local administrator", "", roles)
token, err := s.issueLocalToken(user)
if err != nil {
return "", User{}, err
}
return token, user, nil
}
// Authenticate verifies a bearer token and returns its roles.
func (s *Service) Authenticate(ctx context.Context, rawToken string) (User, error) {
rawToken = strings.TrimSpace(rawToken)
if rawToken == "" {
return User{}, ErrInvalidToken
}
if s.oidcVerifier != nil {
return s.authenticateOIDC(ctx, rawToken)
}
return s.authenticateLocal(rawToken)
}
func (s *Service) authenticateLocal(rawToken string) (User, error) {
parsed, err := jwt.Parse(rawToken, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method %q", token.Method.Alg())
}
return s.localSecret, nil
},
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(localIssuer),
jwt.WithExpirationRequired(),
)
if err != nil || !parsed.Valid {
return User{}, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return User{}, ErrInvalidToken
}
sub, _ := claims["sub"].(string)
if strings.TrimSpace(sub) == "" {
return User{}, ErrInvalidToken
}
name, _ := claims["name"].(string)
email, _ := claims["email"].(string)
roles := claimStrings(claims["roles"])
return s.user(sub, name, email, roles), nil
}
func (s *Service) authenticateOIDC(ctx context.Context, rawToken string) (User, error) {
idToken, err := s.oidcVerifier.Verify(ctx, rawToken)
if err != nil {
return User{}, ErrInvalidToken
}
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return User{}, ErrInvalidToken
}
sub := strings.TrimSpace(idToken.Subject)
if sub == "" {
sub, _ = claims["sub"].(string)
}
if strings.TrimSpace(sub) == "" {
return User{}, ErrInvalidToken
}
name := firstString(claims, "name", "preferred_username")
email, _ := claims["email"].(string)
roles := claimValues(claims, s.cfg.OIDC.RoleClaim)
return s.user(sub, name, email, roles), nil
}
func (s *Service) user(id, name, email string, roles []string) User {
roles = normalizeRoles(roles)
return User{
ID: id,
Name: name,
Email: email,
Roles: roles,
CanAdmin: containsRole(roles, s.cfg.OIDC.AdminRole),
CanView: containsRole(roles, s.cfg.OIDC.ViewRole),
}
}
func (s *Service) localRoles() []string {
return normalizeRoles([]string{s.cfg.OIDC.AdminRole, s.cfg.OIDC.ViewRole})
}
func (s *Service) issueLocalToken(user User) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"iss": localIssuer,
"sub": user.ID,
"name": user.Name,
"roles": user.Roles,
"iat": now.Unix(),
"exp": now.Add(s.localTTL).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(s.localSecret)
if err != nil {
return "", fmt.Errorf("sign local token: %w", err)
}
return signed, nil
}
func passwordMatches(configured, supplied string) bool {
if strings.HasPrefix(configured, "$2") {
return bcrypt.CompareHashAndPassword([]byte(configured), []byte(supplied)) == nil
}
return subtle.ConstantTimeCompare([]byte(configured), []byte(supplied)) == 1
}
func claimValues(claims map[string]any, path string) []string {
var current any = claims
for _, segment := range strings.Split(path, ".") {
segment = strings.TrimSpace(segment)
if segment == "" {
return nil
}
next, ok := current.(map[string]any)
if !ok {
return nil
}
current, ok = next[segment]
if !ok {
return nil
}
}
return claimStrings(current)
}
func claimStrings(value any) []string {
switch typed := value.(type) {
case string:
return normalizeRoles([]string{typed})
case []string:
return normalizeRoles(typed)
case []any:
roles := make([]string, 0, len(typed))
for _, item := range typed {
if role, ok := item.(string); ok {
roles = append(roles, role)
}
}
return normalizeRoles(roles)
default:
return nil
}
}
func normalizeRoles(roles []string) []string {
seen := make(map[string]struct{}, len(roles))
out := make([]string, 0, len(roles))
for _, role := range roles {
role = strings.TrimSpace(role)
if role == "" {
continue
}
if _, exists := seen[role]; exists {
continue
}
seen[role] = struct{}{}
out = append(out, role)
}
return out
}
func containsRole(roles []string, wanted string) bool {
wanted = strings.TrimSpace(wanted)
for _, role := range roles {
if role == wanted {
return true
}
}
return false
}
func firstString(claims map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := claims[key].(string); ok && strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,82 @@
package auth
import (
"context"
"errors"
"testing"
"golang.org/x/crypto/bcrypt"
"kereru.forms/internal/config"
)
func TestLocalPasswordLoginAndAuthenticate(t *testing.T) {
cfg := config.Default().Auth
cfg.LocalPassword = "secret"
cfg.LocalTokenSecret = "test-secret"
service, err := New(context.Background(), cfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
token, user, err := service.Login("secret")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("expected a token")
}
if !user.CanAdmin || !user.CanView {
t.Fatalf("expected admin and view roles, got %+v", user)
}
authenticated, err := service.Authenticate(context.Background(), token)
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if authenticated.ID != "local" || !authenticated.CanAdmin || !authenticated.CanView {
t.Fatalf("unexpected authenticated user: %+v", authenticated)
}
if _, _, err := service.Login("wrong"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
if _, err := service.Authenticate(context.Background(), token+"x"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("expected invalid token, got %v", err)
}
}
func TestLocalPasswordSupportsBcrypt(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("hashed-secret"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := config.Default().Auth
cfg.LocalPassword = string(hash)
cfg.LocalTokenSecret = "test-secret"
service, err := New(context.Background(), cfg)
if err != nil {
t.Fatalf("new auth service: %v", err)
}
if _, _, err := service.Login("hashed-secret"); err != nil {
t.Fatalf("login with bcrypt password: %v", err)
}
}
func TestClaimValuesSupportsNestedPathsAndArrays(t *testing.T) {
claims := map[string]any{
"roles": []any{"admin", "view", "admin"},
"realm_access": map[string]any{
"roles": []any{"viewer", "auditor"},
},
}
if got := claimValues(claims, "roles"); len(got) != 2 || got[0] != "admin" || got[1] != "view" {
t.Fatalf("unexpected flat roles: %#v", got)
}
if got := claimValues(claims, "realm_access.roles"); len(got) != 2 || got[0] != "viewer" || got[1] != "auditor" {
t.Fatalf("unexpected nested roles: %#v", got)
}
if got := claimValues(claims, "missing.roles"); got != nil {
t.Fatalf("expected nil for missing path, got %#v", got)
}
}

View File

@ -52,6 +52,43 @@ type Config struct {
// StaticDir optionally serves a built frontend from this directory. // StaticDir optionally serves a built frontend from this directory.
StaticDir string `yaml:"staticDir" json:"staticDir"` StaticDir string `yaml:"staticDir" json:"staticDir"`
// Auth configures management/submission authentication.
Auth AuthConfig `yaml:"auth" json:"auth"`
}
// AuthConfig controls password fallback and OAuth/OIDC authentication.
type AuthConfig struct {
// LocalPassword is used when no OIDC issuer is configured. A bcrypt hash is
// also accepted. It is ignored when OIDC is enabled.
LocalPassword string `yaml:"localPassword" json:"localPassword"`
// RequireSubmissionAuth requires any authenticated user to list/open forms
// and submit responses. Management endpoints are always protected.
RequireSubmissionAuth bool `yaml:"requireSubmissionAuth" json:"requireSubmissionAuth"`
// LocalTokenSecret signs local password-mode JWTs. Empty generates a random
// secret at startup (tokens then do not survive a restart or multiple replicas).
LocalTokenSecret string `yaml:"localTokenSecret" json:"localTokenSecret"`
// LocalTokenTTLHours is the lifetime of local password-mode tokens.
LocalTokenTTLHours int `yaml:"localTokenTtlHours" json:"localTokenTtlHours"`
// OIDC configures an external OpenID Connect provider.
OIDC OIDCConfig `yaml:"oidc" json:"oidc"`
}
// OIDCConfig describes the external OpenID Connect provider used by the SPA.
type OIDCConfig struct {
IssuerURL string `yaml:"issuerUrl" json:"issuerUrl"`
ClientID string `yaml:"clientId" json:"clientId"`
Scopes []string `yaml:"scopes" json:"scopes"`
// RoleClaim is a dot-separated claim path, e.g. "roles" or
// "realm_access.roles". The value may be a string or an array of strings.
RoleClaim string `yaml:"roleClaim" json:"roleClaim"`
AdminRole string `yaml:"adminRole" json:"adminRole"`
ViewRole string `yaml:"viewRole" json:"viewRole"`
}
// OIDCEnabled reports whether external OIDC authentication is configured.
func (c AuthConfig) OIDCEnabled() bool {
return strings.TrimSpace(c.OIDC.IssuerURL) != ""
} }
// Default returns the built-in configuration. // Default returns the built-in configuration.
@ -69,6 +106,16 @@ func Default() *Config {
OTLPEndpoint: "localhost:4318", OTLPEndpoint: "localhost:4318",
CORSOrigins: []string{"http://localhost:5173"}, CORSOrigins: []string{"http://localhost:5173"},
StaticDir: "", StaticDir: "",
Auth: AuthConfig{
LocalPassword: "admin",
LocalTokenTTLHours: 8,
OIDC: OIDCConfig{
Scopes: []string{"openid", "profile", "email"},
RoleClaim: "roles",
AdminRole: "admin",
ViewRole: "view",
},
},
} }
} }
@ -144,6 +191,35 @@ func (c *Config) Validate() error {
if strings.TrimSpace(c.DBDSN) == "" { if strings.TrimSpace(c.DBDSN) == "" {
return errors.New("dbDsn must not be empty") return errors.New("dbDsn must not be empty")
} }
if c.Auth.LocalTokenTTLHours <= 0 {
return errors.New("auth.localTokenTtlHours must be greater than zero")
}
if strings.TrimSpace(c.Auth.OIDC.AdminRole) == "" {
return errors.New("auth.oidc.adminRole must not be empty")
}
if strings.TrimSpace(c.Auth.OIDC.ViewRole) == "" {
return errors.New("auth.oidc.viewRole must not be empty")
}
if strings.TrimSpace(c.Auth.OIDC.RoleClaim) == "" {
return errors.New("auth.oidc.roleClaim must not be empty")
}
if c.Auth.OIDCEnabled() {
if strings.TrimSpace(c.Auth.OIDC.ClientID) == "" {
return errors.New("auth.oidc.clientId is required when auth.oidc.issuerUrl is set")
}
hasOpenID := false
for _, scope := range c.Auth.OIDC.Scopes {
if strings.TrimSpace(scope) == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
return errors.New("auth.oidc.scopes must include openid")
}
} else if strings.TrimSpace(c.Auth.LocalPassword) == "" {
return errors.New("auth.localPassword must not be empty when OIDC is not configured")
}
return nil return nil
} }
@ -211,6 +287,34 @@ func applyEnv(cfg *Config) {
setString(&cfg.DBDriver, "KERERU_DB_DRIVER") setString(&cfg.DBDriver, "KERERU_DB_DRIVER")
setString(&cfg.DBDSN, "KERERU_DB_DSN") setString(&cfg.DBDSN, "KERERU_DB_DSN")
setString(&cfg.StaticDir, "KERERU_STATIC_DIR") setString(&cfg.StaticDir, "KERERU_STATIC_DIR")
setString(&cfg.Auth.LocalPassword, "KERERU_AUTH_LOCAL_PASSWORD")
setString(&cfg.Auth.LocalTokenSecret, "KERERU_AUTH_LOCAL_TOKEN_SECRET")
if v, ok := os.LookupEnv("KERERU_AUTH_REQUIRE_SUBMISSION_AUTH"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Auth.RequireSubmissionAuth = b
}
}
if v, ok := os.LookupEnv("KERERU_AUTH_LOCAL_TOKEN_TTL_HOURS"); ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.Auth.LocalTokenTTLHours = n
}
}
setString(&cfg.Auth.OIDC.IssuerURL, "KERERU_OIDC_ISSUER_URL")
setString(&cfg.Auth.OIDC.ClientID, "KERERU_OIDC_CLIENT_ID")
setString(&cfg.Auth.OIDC.RoleClaim, "KERERU_OIDC_ROLE_CLAIM")
setString(&cfg.Auth.OIDC.AdminRole, "KERERU_OIDC_ADMIN_ROLE")
setString(&cfg.Auth.OIDC.ViewRole, "KERERU_OIDC_VIEW_ROLE")
if v, ok := os.LookupEnv("KERERU_OIDC_SCOPES"); ok {
var scopes []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
scopes = append(scopes, p)
}
}
if len(scopes) > 0 {
cfg.Auth.OIDC.Scopes = scopes
}
}
if v, ok := os.LookupEnv("KERERU_TELEMETRY_ENABLED"); ok { if v, ok := os.LookupEnv("KERERU_TELEMETRY_ENABLED"); ok {
if b, err := strconv.ParseBool(v); err == nil { if b, err := strconv.ParseBool(v); err == nil {
cfg.TelemetryEnabled = b cfg.TelemetryEnabled = b

View File

@ -28,6 +28,8 @@ tags:
description: Form submissions and management of captured data description: Form submissions and management of captured data
- name: access-codes - name: access-codes
description: Single-use access codes description: Single-use access codes
- name: auth
description: Password fallback and OIDC-backed bearer authentication
paths: paths:
/health: /health:
get: get:
@ -75,6 +77,8 @@ paths:
tags: [forms] tags: [forms]
operationId: createForm operationId: createForm
summary: Create a form summary: Create a form
security:
- bearerAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -114,6 +118,8 @@ paths:
tags: [forms] tags: [forms]
operationId: updateForm operationId: updateForm
summary: Update a form definition summary: Update a form definition
security:
- bearerAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -137,6 +143,8 @@ paths:
tags: [forms] tags: [forms]
operationId: deleteForm operationId: deleteForm
summary: Delete a form definition summary: Delete a form definition
security:
- bearerAuth: []
responses: responses:
'204': '204':
description: Deleted description: Deleted
@ -151,6 +159,8 @@ paths:
tags: [forms] tags: [forms]
operationId: inactivateForm operationId: inactivateForm
summary: Inactivate a form summary: Inactivate a form
security:
- bearerAuth: []
description: Inactivated forms remain visible in management but cannot accept new submissions. description: Inactivated forms remain visible in management but cannot accept new submissions.
responses: responses:
'200': '200':
@ -170,6 +180,8 @@ paths:
tags: [forms] tags: [forms]
operationId: activateForm operationId: activateForm
summary: Activate a form summary: Activate a form
security:
- bearerAuth: []
responses: responses:
'200': '200':
description: Activated form description: Activated form
@ -188,6 +200,8 @@ paths:
tags: [forms] tags: [forms]
operationId: createFormVersion operationId: createFormVersion
summary: Create a new version of a form summary: Create a new version of a form
security:
- bearerAuth: []
description: Clones the form and its questions into a new version with the version number incremented. The new version is active; the previous version is inactivated. description: Clones the form and its questions into a new version with the version number incremented. The new version is active; the previous version is inactivated.
responses: responses:
'201': '201':
@ -226,6 +240,8 @@ paths:
tags: [submissions] tags: [submissions]
operationId: listFormSubmissions operationId: listFormSubmissions
summary: List submissions for a form (management) summary: List submissions for a form (management)
security:
- bearerAuth: []
responses: responses:
'200': '200':
description: Submissions description: Submissions
@ -282,6 +298,8 @@ paths:
tags: [submissions] tags: [submissions]
operationId: getSubmission operationId: getSubmission
summary: Get a submission by id (management) summary: Get a submission by id (management)
security:
- bearerAuth: []
responses: responses:
'200': '200':
description: Submission description: Submission
@ -305,6 +323,8 @@ paths:
tags: [submissions] tags: [submissions]
operationId: getSubmissionOfflinePdf operationId: getSubmissionOfflinePdf
summary: Download the offline PDF uploaded with a submission summary: Download the offline PDF uploaded with a submission
security:
- bearerAuth: []
responses: responses:
'200': '200':
description: The uploaded PDF description: The uploaded PDF
@ -324,6 +344,8 @@ paths:
tags: [access-codes] tags: [access-codes]
operationId: listAccessCodes operationId: listAccessCodes
summary: List access codes for a form summary: List access codes for a form
security:
- bearerAuth: []
responses: responses:
'200': '200':
description: Access codes description: Access codes
@ -341,6 +363,8 @@ paths:
tags: [access-codes] tags: [access-codes]
operationId: createAccessCodes operationId: createAccessCodes
summary: Create one or more access codes for a form summary: Create one or more access codes for a form
security:
- bearerAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@ -362,7 +386,82 @@ paths:
$ref: '#/components/responses/Error' $ref: '#/components/responses/Error'
'500': '500':
$ref: '#/components/responses/Error' $ref: '#/components/responses/Error'
/auth/config:
get:
tags: [auth]
operationId: getAuthConfig
summary: Get browser authentication configuration
description: |
When `mode` is `oidc`, the SPA performs an authorization-code flow with
PKCE against the configured provider and then presents the returned ID
token as a bearer token to this API. When `mode` is `password`, the SPA
uses `/auth/login`.
responses:
'200':
description: Authentication configuration
content:
application/json:
schema:
$ref: '#/components/schemas/AuthConfig'
/auth/login:
post:
tags: [auth]
operationId: login
summary: Log in with the local password fallback
description: Only available when no OIDC issuer is configured.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: Signed local JWT
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'400':
$ref: '#/components/responses/Error'
'401':
$ref: '#/components/responses/Error'
/auth/me:
get:
tags: [auth]
operationId: getCurrentUser
summary: Get the current authenticated user
security:
- bearerAuth: []
responses:
'200':
description: Current authentication state
content:
application/json:
schema:
$ref: '#/components/schemas/AuthMeResponse'
'401':
$ref: '#/components/responses/Error'
/auth/logout:
post:
tags: [auth]
operationId: logout
summary: Log out
description: Stateless tokens are discarded by the client; this endpoint exists for symmetry.
security:
- bearerAuth: []
responses:
'204':
description: Logged out
components: components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
Local password-mode JWT, or an OIDC ID token obtained by the SPA using
the authorization-code flow with PKCE.
parameters: parameters:
FormId: FormId:
name: formId name: formId
@ -379,6 +478,78 @@ components:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
schemas: schemas:
AuthConfig:
type: object
required: [mode, requireSubmissionAuth]
properties:
mode:
type: string
enum: [password, oidc]
requireSubmissionAuth:
type: boolean
oidc:
$ref: '#/components/schemas/OIDCClientConfig'
OIDCClientConfig:
type: object
required: [issuerUrl, authorizationEndpoint, tokenEndpoint, clientId, scopes]
properties:
issuerUrl:
type: string
format: uri
authorizationEndpoint:
type: string
format: uri
tokenEndpoint:
type: string
format: uri
clientId:
type: string
scopes:
type: array
items:
type: string
LoginRequest:
type: object
required: [password]
properties:
password:
type: string
format: password
LoginResponse:
type: object
required: [token, user]
properties:
token:
type: string
user:
$ref: '#/components/schemas/AuthUser'
AuthUser:
type: object
required: [id, roles, canAdmin, canView]
properties:
id:
type: string
name:
type: string
email:
type: string
format: email
roles:
type: array
items:
type: string
canAdmin:
type: boolean
canView:
type: boolean
AuthMeResponse:
type: object
required: [authenticated]
properties:
authenticated:
type: boolean
user:
$ref: '#/components/schemas/AuthUser'
Health: Health:
type: object type: object
required: [status, version] required: [status, version]

View File

@ -12,5 +12,23 @@
"otlpEndpoint": "localhost:4318", "otlpEndpoint": "localhost:4318",
"corsOrigins": [ "corsOrigins": [
"http://localhost:5173" "http://localhost:5173"
] ],
"auth": {
"localPassword": "admin",
"requireSubmissionAuth": false,
"localTokenSecret": "",
"localTokenTtlHours": 8,
"oidc": {
"issuerUrl": "",
"clientId": "",
"scopes": [
"openid",
"profile",
"email"
],
"roleClaim": "roles",
"adminRole": "admin",
"viewRole": "view"
}
}
} }

View File

@ -20,3 +20,26 @@ otlpEndpoint: "localhost:4318"
corsOrigins: corsOrigins:
- "http://localhost:5173" - "http://localhost:5173"
# Authentication. If oidc.issuerUrl is empty, the local password is used and
# grants both admin and view roles. If an OIDC issuer is configured, local
# password login is disabled and the SPA uses authorization-code flow + PKCE.
auth:
localPassword: "admin"
requireSubmissionAuth: false
# Empty generates a random secret per process. Set a stable value to keep
# local-password tokens valid across restarts or multiple replicas.
localTokenSecret: ""
localTokenTtlHours: 8
oidc:
issuerUrl: ""
clientId: ""
scopes:
- openid
- profile
- email
# Dot-separated claim path. Examples: "roles", "groups",
# "realm_access.roles" (Keycloak).
roleClaim: "roles"
adminRole: "admin"
viewRole: "view"

View File

@ -30,7 +30,8 @@ backend/
├── oapi-codegen.yaml Code generation settings ├── oapi-codegen.yaml Code generation settings
├── cmd/kereru-forms/main.go Wiring, listeners, graceful shutdown ├── cmd/kereru-forms/main.go Wiring, listeners, graceful shutdown
└── internal/ └── internal/
├── api/ Generated server + strict handlers + router ├── api/ Generated server + strict handlers + router + auth middleware
├── auth/ Local password and OIDC token verification, roles
├── config/ YAML/JSON + .env + env + flags, precedence ├── config/ YAML/JSON + .env + env + flags, precedence
├── database/ Store interface, SQLite implementation, migrations ├── database/ Store interface, SQLite implementation, migrations
├── logging/ zerolog setup + Gin access logging ├── logging/ zerolog setup + Gin access logging
@ -40,15 +41,36 @@ backend/
### Request flow ### Request flow
1. Gin middleware: telemetry span (`otelgin`), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS. 1. Gin middleware: telemetry span (`otelgin`), panic recovery, request id, request metadata (client IP/user agent), structured access log, CORS, then authentication/authorisation.
2. `api.RegisterHandlers` routes to the **generated strict handler**, which performs OpenAPI request decoding/validation. 2. `api.RegisterHandlers` routes to the **generated strict handler**, which performs OpenAPI request decoding/validation.
3. `api.Server` methods validate business rules and call the `database.Store`. 3. `api.Server` methods validate business rules and call the `database.Store`.
4. Responses are mapped back to generated types; errors use the `Error` schema (`{error, code}`). 4. Responses are mapped back to generated types; errors use the `Error` schema (`{error, code}`).
### Authentication
The API uses bearer JWTs with no server-side session state. `internal/auth` validates two token types:
- **OIDC**: discovery is performed at startup against the configured issuer. ID tokens are verified by `go-oidc` (signature, issuer, audience, expiry), and roles are read from a configurable claim path (for example `roles`, `groups` or `realm_access.roles`).
- **Local password fallback**: used only when no OIDC issuer is configured. The password is compared with bcrypt (or constant-time plain comparison) and the server issues a short-lived HS256 JWT. This mode grants both admin and view roles for local testing.
The SPA performs the authorization-code flow with PKCE and sends the ID token as `Authorization: Bearer …`. It stores the token in `sessionStorage` and clears it on a 401. Because the SPA is a public client, no client secret is supported.
Authorisation is enforced by `api/auth_middleware.go` before the strict handlers:
| Capability | Required role |
| --- | --- |
| Form CRUD, activation, versioning, access-code management | admin |
| List/read submissions and download offline PDFs | view |
| List/open/submit forms and download form PDFs when `requireSubmissionAuth` is enabled | any authenticated user |
Admin-only users cannot view submissions by design. `/auth/config`, `/auth/login`, `/auth/me`, `/auth/logout` and `/health` remain public; `/auth/me` optionally authenticates so the SPA can distinguish signed-out from signed-in.
### Configuration ### Configuration
`internal/config` implements a deterministic precedence chain: defaults → YAML/JSON file (`--conf`) → `.env` (next to executable or cwd; never overrides existing env) → environment variables → CLI flags. The `--conf` flag is pre-scanned so the file is available as defaults for the remaining flags. Both IPv4 and IPv6 listeners are supported and started concurrently. `internal/config` implements a deterministic precedence chain: defaults → YAML/JSON file (`--conf`) → `.env` (next to executable or cwd; never overrides existing env) → environment variables → CLI flags. The `--conf` flag is pre-scanned so the file is available as defaults for the remaining flags. Both IPv4 and IPv6 listeners are supported and started concurrently.
The `auth` block selects local-password or OIDC mode (`oidc.issuerUrl` non-empty), maps role claims, optionally requires authentication for submissions, and controls local token secret/TTL.
### Data model ### Data model
`forms` represents one concrete version. `form_group_id` groups all versions of the same logical form; `(form_group_id, version)` is unique. Creating a new version clones the current definition, increments `version`, activates the new row and inactivates the previous one. Historical submissions keep `form_version` and denormalised `question_key` values, so they remain readable after questions change. `forms` represents one concrete version. `form_group_id` groups all versions of the same logical form; `(form_group_id, version)` is unique. Creating a new version clones the current definition, increments `version`, activates the new row and inactivates the previous one. Historical submissions keep `form_version` and denormalised `question_key` values, so they remain readable after questions change.
@ -87,14 +109,19 @@ Access codes are consumed in the same SQL transaction as the submission insert.
``` ```
frontend/src/ frontend/src/
├── api/… (reserved) ├── api/… (reserved)
├── components/ Layout, ThemeToggle, QuestionField, StatusBadge ├── auth/ AuthProvider, useAuth, ManagementGate, AccessDenied
├── components/ Layout, ThemeToggle, QuestionField, StatusBadge, SignInPrompt
├── hooks/useTheme light/dark/system with localStorage + matchMedia ├── hooks/useTheme light/dark/system with localStorage + matchMedia
├── lib/api.ts Typed fetch client, ApiError, PDF download ├── lib/api.ts Typed fetch client, bearer auth, ApiError, PDF download
├── lib/oidc.ts Authorization code + PKCE callback handling
├── lib/session.ts Token storage and auth change events
├── lib/types.ts Mirrors the OpenAPI schemas ├── lib/types.ts Mirrors the OpenAPI schemas
├── pages/ Home, FillForm, Manage, FormBuilder, FormDetail, SubmissionDetail ├── pages/ Home, FillForm, Login, OidcCallback, Manage, FormBuilder, FormDetail, SubmissionDetail
└── test/ Vitest + Testing Library └── test/ Vitest + Testing Library
``` ```
`AuthProvider` loads `/auth/config` and `/auth/me` on start, exposes password/OIDC login and logout, and refreshes on auth-change events. Management routes are wrapped in `ManagementGate`; the home and fill pages show a sign-in prompt when submission authentication is required.
Theming uses Tailwind's `class` dark mode. An inline script in `index.html` applies the stored preference before paint to avoid a flash. The `useTheme` hook keeps `documentElement.dark`, `localStorage` and the OS preference in sync. Theming uses Tailwind's `class` dark mode. An inline script in `index.html` applies the stored preference before paint to avoid a flash. The `useTheme` hook keeps `documentElement.dark`, `localStorage` and the OS preference in sync.
Forms may carry a company name, contact details and a logo URL; the UI renders the logo on the home, fill and detail views. Remote logos are not embedded in the generated PDF. Forms may carry a company name, contact details and a logo URL; the UI renders the logo on the home, fill and detail views. Remote logos are not embedded in the generated PDF.
@ -111,7 +138,7 @@ The fill flow asks for the respondent identifier and (optionally) an access code
## Known limitations / next steps ## Known limitations / next steps
- Management endpoints have no authentication or authorisation yet; front them with an auth proxy or add an auth layer. - OIDC assumes a public SPA client (no client secret) and an IdP that allows token-endpoint CORS; confidential-client flows are not implemented.
- SQLite is a single-writer store; for horizontal scaling use a network database adapter and remove the single-connection pool limit. - SQLite is a single-writer store; for horizontal scaling use a network database adapter and remove the single-connection pool limit.
- Access codes are generated with 8 characters from a 32-symbol alphabet (~40 bits) — sufficient for single-use gating, not for secrets. - Access codes are generated with 8 characters from a 32-symbol alphabet (~40 bits) — sufficient for single-use gating, not for secrets.
- PDF output uses built-in fonts and does not embed form logos (see above). - PDF output uses built-in fonts and does not embed form logos (see above).

View File

@ -143,6 +143,9 @@ Typical production settings:
- `KERERU_TELEMETRY_ENABLED=true` and `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318` - `KERERU_TELEMETRY_ENABLED=true` and `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318`
- `KERERU_STATIC_DIR=/app/static` - `KERERU_STATIC_DIR=/app/static`
- SQLite DSN on a persistent volume: `file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)` - SQLite DSN on a persistent volume: `file:/data/kereru.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)`
- OIDC: `KERERU_OIDC_ISSUER_URL`, `KERERU_OIDC_CLIENT_ID`, `KERERU_OIDC_ROLE_CLAIM`, `KERERU_OIDC_ADMIN_ROLE`, `KERERU_OIDC_VIEW_ROLE`
- Optional submission protection: `KERERU_AUTH_REQUIRE_SUBMISSION_AUTH=true`
- Local-password mode (only when no OIDC issuer is set): `KERERU_AUTH_LOCAL_PASSWORD` and a stable `KERERU_AUTH_LOCAL_TOKEN_SECRET`
### Backups ### Backups
@ -151,6 +154,8 @@ For SQLite, back up the database file while the service is stopped, or use the S
### Security ### Security
- Terminate TLS at the ingress/load balancer. - Terminate TLS at the ingress/load balancer.
- Management routes (`/forms`, `/submissions`, `/forms/{id}/pdf`) are not authenticated; restrict them with an auth proxy or network policy. - Management routes require a JWT. Configure OIDC for production and enforce MFA/role assignment in the identity provider.
- If using the local password fallback, change `KERERU_AUTH_LOCAL_PASSWORD` from `admin` and set a stable `KERERU_AUTH_LOCAL_TOKEN_SECRET`.
- Register the frontend `/auth/callback` URL with the OIDC provider and allow the frontend origin as a web/CORS origin in Okta/Keycloak.
- Set `KERERU_CORS_ORIGINS` to the exact frontend origin in production. - Set `KERERU_CORS_ORIGINS` to the exact frontend origin in production.
- Do not expose the SQLite file over a network share. - Do not expose the SQLite file over a network share.

View File

@ -1,11 +1,15 @@
import { Suspense, lazy } from 'react' import { Suspense, lazy } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './auth/AuthContext'
import { ManagementGate } from './auth/gates'
import { Layout } from './components/Layout' import { Layout } from './components/Layout'
import { FillFormPage } from './pages/FillFormPage' import { FillFormPage } from './pages/FillFormPage'
import { FormBuilderPage } from './pages/FormBuilderPage' import { FormBuilderPage } from './pages/FormBuilderPage'
import { FormDetailPage } from './pages/FormDetailPage' import { FormDetailPage } from './pages/FormDetailPage'
import { HomePage } from './pages/HomePage' import { HomePage } from './pages/HomePage'
import { LoginPage } from './pages/LoginPage'
import { ManagePage } from './pages/ManagePage' import { ManagePage } from './pages/ManagePage'
import { OidcCallbackPage } from './pages/OidcCallbackPage'
import { SubmissionDetailPage } from './pages/SubmissionDetailPage' import { SubmissionDetailPage } from './pages/SubmissionDetailPage'
const DocsPage = lazy(() => const DocsPage = lazy(() =>
@ -15,25 +19,64 @@ const DocsPage = lazy(() =>
export function App() { export function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <AuthProvider>
<Route element={<Layout />}> <Routes>
<Route index element={<HomePage />} /> <Route element={<Layout />}>
<Route path="forms/:formId" element={<FillFormPage />} /> <Route index element={<HomePage />} />
<Route path="manage" element={<ManagePage />} /> <Route path="forms/:formId" element={<FillFormPage />} />
<Route path="manage/new" element={<FormBuilderPage />} /> <Route path="login" element={<LoginPage />} />
<Route path="manage/forms/:formId" element={<FormDetailPage />} /> <Route path="auth/callback" element={<OidcCallbackPage />} />
<Route path="manage/forms/:formId/edit" element={<FormBuilderPage />} /> <Route
<Route path="submissions/:submissionId" element={<SubmissionDetailPage />} /> path="manage"
<Route element={
path="docs" <ManagementGate>
element={ <ManagePage />
<Suspense fallback={<p className="text-slate-500 dark:text-slate-400">Loading API docs…</p>}> </ManagementGate>
<DocsPage /> }
</Suspense> />
} <Route
/> path="manage/new"
</Route> element={
</Routes> <ManagementGate role="admin">
<FormBuilderPage />
</ManagementGate>
}
/>
<Route
path="manage/forms/:formId"
element={
<ManagementGate>
<FormDetailPage />
</ManagementGate>
}
/>
<Route
path="manage/forms/:formId/edit"
element={
<ManagementGate role="admin">
<FormBuilderPage />
</ManagementGate>
}
/>
<Route
path="submissions/:submissionId"
element={
<ManagementGate role="view">
<SubmissionDetailPage />
</ManagementGate>
}
/>
<Route
path="docs"
element={
<Suspense fallback={<p className="text-slate-500 dark:text-slate-400">Loading API docs…</p>}>
<DocsPage />
</Suspense>
}
/>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
) )
} }

View File

@ -0,0 +1,130 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { api, ApiError } from '../lib/api'
import { beginOidcLogin } from '../lib/oidc'
import {
AUTH_CHANGED_EVENT,
clearAuthToken,
getAuthToken,
setAuthToken,
} from '../lib/session'
import type { AuthConfig, AuthUser } from '../lib/types'
interface AuthContextValue {
loading: boolean
config: AuthConfig | null
user: AuthUser | null
error: string | null
loginWithPassword: (password: string) => Promise<void>
loginWithOidc: (returnTo: string) => Promise<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [config, setConfig] = useState<AuthConfig | null>(null)
const [user, setUser] = useState<AuthUser | null>(null)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!getAuthToken()) {
setUser(null)
return
}
try {
const result = await api.me()
setUser(result.authenticated ? (result.user ?? null) : null)
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
clearAuthToken()
}
setUser(null)
}
}, [])
useEffect(() => {
let cancelled = false
async function initialise() {
try {
const result = await api.getAuthConfig()
if (cancelled) return
setConfig(result)
await refresh()
} catch (err) {
if (!cancelled) {
setError(err instanceof ApiError ? err.message : 'Could not load authentication configuration')
}
} finally {
if (!cancelled) setLoading(false)
}
}
void initialise()
const onAuthChanged = () => {
if (getAuthToken()) {
void refresh()
} else {
setUser(null)
}
}
window.addEventListener(AUTH_CHANGED_EVENT, onAuthChanged)
return () => {
cancelled = true
window.removeEventListener(AUTH_CHANGED_EVENT, onAuthChanged)
}
}, [refresh])
const loginWithPassword = useCallback(async (password: string) => {
const result = await api.login(password)
setAuthToken(result.token)
setUser(result.user)
setError(null)
}, [])
const loginWithOidc = useCallback(
async (returnTo: string) => {
if (!config?.oidc) {
throw new Error('OIDC is not configured')
}
await beginOidcLogin(config.oidc, returnTo)
},
[config],
)
const logout = useCallback(async () => {
try {
await api.logout()
} catch {
// Tokens are stateless; local cleanup is still sufficient.
}
clearAuthToken()
setUser(null)
}, [])
const value = useMemo(
() => ({
loading,
config,
user,
error,
loginWithPassword,
loginWithOidc,
logout,
refresh,
}),
[loading, config, user, error, loginWithPassword, loginWithOidc, logout, refresh],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used inside AuthProvider')
}
return context
}

View File

@ -0,0 +1,49 @@
import type { ReactNode } from 'react'
import { Link, Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
import type { AuthUser } from '../lib/types'
type RequiredRole = 'admin' | 'view' | 'management'
export function ManagementGate({
children,
role = 'management',
}: {
children: ReactNode
role?: RequiredRole
}) {
const { loading, user } = useAuth()
const location = useLocation()
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
}
if (!hasRole(user, role)) {
return <AccessDenied role={role} />
}
return <>{children}</>
}
function hasRole(user: AuthUser, role: RequiredRole): boolean {
if (role === 'admin') return user.canAdmin
if (role === 'view') return user.canView
return user.canAdmin || user.canView
}
function AccessDenied({ role }: { role: RequiredRole }) {
const roleName = role === 'management' ? 'management' : `${role} role`
return (
<div className="card mx-auto max-w-xl space-y-3 text-center">
<h1 className="text-xl font-bold">Access denied</h1>
<p className="text-slate-500 dark:text-slate-400">
Your account does not have the {roleName} permission required for this page.
</p>
<Link to="/" className="btn-primary mx-auto w-fit">
Back to forms
</Link>
</div>
)
}

View File

@ -1,4 +1,5 @@
import { Link, NavLink, Outlet } from 'react-router-dom' import { Link, NavLink, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ThemeToggle } from './ThemeToggle' import { ThemeToggle } from './ThemeToggle'
const navLinkClass = ({ isActive }: { isActive: boolean }) => const navLinkClass = ({ isActive }: { isActive: boolean }) =>
@ -9,6 +10,9 @@ const navLinkClass = ({ isActive }: { isActive: boolean }) =>
}` }`
export function Layout() { export function Layout() {
const { user, logout } = useAuth()
const canManage = Boolean(user?.canAdmin || user?.canView)
return ( return (
<div className="flex min-h-full flex-col"> <div className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80"> <header className="border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
@ -29,15 +33,35 @@ export function Layout() {
<NavLink to="/" end className={navLinkClass}> <NavLink to="/" end className={navLinkClass}>
Forms Forms
</NavLink> </NavLink>
<NavLink to="/manage" className={navLinkClass}> {canManage && (
Management <NavLink to="/manage" className={navLinkClass}>
</NavLink> Management
</NavLink>
)}
<NavLink to="/docs" className={navLinkClass}> <NavLink to="/docs" className={navLinkClass}>
API docs API docs
</NavLink> </NavLink>
</nav> </nav>
<div className="order-2 ml-auto sm:order-3"> <div className="order-2 ml-auto flex items-center gap-2 sm:order-3">
{user ? (
<div className="flex items-center gap-2">
<span className="hidden max-w-40 truncate text-sm text-slate-500 sm:inline dark:text-slate-400">
{user.name ?? user.email ?? user.id}
</span>
<button
type="button"
className="btn-secondary py-1 text-xs"
onClick={() => void logout()}
>
Sign out
</button>
</div>
) : (
<Link to="/login" className="btn-secondary py-1 text-xs">
Sign in
</Link>
)}
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>

View File

@ -0,0 +1,18 @@
import { Link, useLocation } from 'react-router-dom'
export function SignInPrompt({ message }: { message: string }) {
const location = useLocation()
return (
<div className="card space-y-3 text-center">
<h2 className="text-xl font-bold">Sign in required</h2>
<p className="text-slate-500 dark:text-slate-400">{message}</p>
<Link
to="/login"
state={{ from: location.pathname + location.search }}
className="btn-primary mx-auto w-fit"
>
Sign in
</Link>
</div>
)
}

View File

@ -1,16 +1,20 @@
import type { import type {
AccessCode, AccessCode,
ApiErrorBody, ApiErrorBody,
AuthConfig,
AuthMeResponse,
CreateAccessCodesRequest, CreateAccessCodesRequest,
CreateFormRequest, CreateFormRequest,
Form, Form,
FormSummary, FormSummary,
Health, Health,
LoginResponse,
Submission, Submission,
SubmissionSummary, SubmissionSummary,
SubmitFormRequest, SubmitFormRequest,
UpdateFormRequest, UpdateFormRequest,
} from './types' } from './types'
import { clearAuthToken, getAuthToken, notifyAuthChanged } from './session'
export const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api' export const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api'
@ -26,11 +30,18 @@ export class ApiError extends Error {
} }
} }
function authHeaders(): Record<string, string> {
const token = getAuthToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken()
const response = await fetch(`${BASE_URL}${path}`, { const response = await fetch(`${BASE_URL}${path}`, {
...init, ...init,
headers: { headers: {
...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...authHeaders(),
...init?.headers, ...init?.headers,
}, },
}) })
@ -45,6 +56,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch { } catch {
// Response was not JSON; keep the generic message. // Response was not JSON; keep the generic message.
} }
if (response.status === 401 && token) {
clearAuthToken()
notifyAuthChanged()
}
throw new ApiError(response.status, message, code) throw new ApiError(response.status, message, code)
} }
@ -57,6 +72,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = { export const api = {
health: () => request<Health>('/health'), health: () => request<Health>('/health'),
getAuthConfig: () => request<AuthConfig>('/auth/config'),
login: (password: string) =>
request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
}),
me: () => request<AuthMeResponse>('/auth/me'),
logout: () => request<void>('/auth/logout', { method: 'POST' }),
listForms: (includeInactive = false) => listForms: (includeInactive = false) =>
request<FormSummary[]>(`/forms${includeInactive ? '?includeInactive=true' : ''}`), request<FormSummary[]>(`/forms${includeInactive ? '?includeInactive=true' : ''}`),
@ -96,7 +123,7 @@ export const api = {
link.remove() link.remove()
return return
} }
const response = await fetch(api.formPdfUrl(id)) const response = await fetch(api.formPdfUrl(id), { headers: authHeaders() })
if (!response.ok) { if (!response.ok) {
throw new ApiError(response.status, 'Could not download the PDF') throw new ApiError(response.status, 'Could not download the PDF')
} }
@ -114,6 +141,7 @@ export const api = {
downloadSubmissionOfflinePdf: async (id: string, filename?: string) => { downloadSubmissionOfflinePdf: async (id: string, filename?: string) => {
const response = await fetch( const response = await fetch(
`${BASE_URL}/submissions/${encodeURIComponent(id)}/offline-pdf`, `${BASE_URL}/submissions/${encodeURIComponent(id)}/offline-pdf`,
{ headers: authHeaders() },
) )
if (!response.ok) { if (!response.ok) {
throw new ApiError(response.status, 'Could not download the offline PDF') throw new ApiError(response.status, 'Could not download the offline PDF')

View File

@ -0,0 +1,164 @@
import type { OIDCClientConfig } from './types'
const PENDING_KEY = 'kereru.oidc.pending'
const PENDING_MAX_AGE_MS = 10 * 60 * 1000
interface PendingLogin {
state: string
nonce: string
codeVerifier: string
returnTo: string
createdAt: number
}
interface TokenResponse {
id_token?: string
error?: string
error_description?: string
}
export function safeReturnTo(value: string | null | undefined, fallback = '/manage'): string {
if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback
return value
}
export async function beginOidcLogin(
config: OIDCClientConfig,
returnTo: string,
): Promise<void> {
const state = randomBase64Url(32)
const nonce = randomBase64Url(32)
const codeVerifier = randomBase64Url(64)
const codeChallenge = base64Url(await sha256(codeVerifier))
const pending: PendingLogin = {
state,
nonce,
codeVerifier,
returnTo: safeReturnTo(returnTo),
createdAt: Date.now(),
}
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
const url = new URL(config.authorizationEndpoint)
url.searchParams.set('client_id', config.clientId)
url.searchParams.set('redirect_uri', oidcRedirectUri())
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', config.scopes.join(' '))
url.searchParams.set('state', state)
url.searchParams.set('nonce', nonce)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
window.location.assign(url.toString())
}
export async function completeOidcLogin(
config: OIDCClientConfig,
params: URLSearchParams,
): Promise<{ token: string; returnTo: string }> {
const pending = readPending()
if (params.get('error')) {
throw new Error(
params.get('error_description') ?? params.get('error') ?? 'The identity provider rejected sign-in',
)
}
const state = params.get('state')
if (!state || state !== pending.state) {
throw new Error('The sign-in response could not be verified. Please try again.')
}
const code = params.get('code')
if (!code) {
throw new Error('The identity provider did not return an authorization code.')
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: oidcRedirectUri(),
client_id: config.clientId,
code_verifier: pending.codeVerifier,
})
const response = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const text = await response.text()
let tokens: TokenResponse = {}
try {
tokens = JSON.parse(text) as TokenResponse
} catch {
// Keep the generic error below.
}
if (!response.ok) {
throw new Error(
tokens.error_description ?? tokens.error ?? `Token exchange failed (${response.status})`,
)
}
if (!tokens.id_token) {
throw new Error('The identity provider did not return an ID token.')
}
const claims = decodeJwtPayload(tokens.id_token)
if (claims.nonce !== pending.nonce) {
throw new Error('The sign-in response nonce did not match. Please try again.')
}
sessionStorage.removeItem(PENDING_KEY)
return { token: tokens.id_token, returnTo: pending.returnTo }
}
export function oidcRedirectUri(): string {
return `${window.location.origin}/auth/callback`
}
function readPending(): PendingLogin {
const raw = sessionStorage.getItem(PENDING_KEY)
if (!raw) {
throw new Error('This sign-in session has expired. Please start again.')
}
let pending: PendingLogin
try {
pending = JSON.parse(raw) as PendingLogin
} catch {
sessionStorage.removeItem(PENDING_KEY)
throw new Error('This sign-in session is invalid. Please start again.')
}
if (
typeof pending.state !== 'string' ||
typeof pending.nonce !== 'string' ||
typeof pending.codeVerifier !== 'string' ||
typeof pending.returnTo !== 'string' ||
Date.now() - pending.createdAt > PENDING_MAX_AGE_MS
) {
sessionStorage.removeItem(PENDING_KEY)
throw new Error('This sign-in session has expired. Please start again.')
}
return pending
}
function randomBase64Url(byteLength: number): string {
const bytes = new Uint8Array(byteLength)
crypto.getRandomValues(bytes)
return base64Url(bytes)
}
function base64Url(value: ArrayBuffer | Uint8Array): string {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function sha256(value: string): Promise<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
}
function decodeJwtPayload(token: string): Record<string, unknown> {
const part = token.split('.')[1]
if (!part) throw new Error('The identity provider returned an invalid ID token.')
const normalized = part.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
}

View File

@ -0,0 +1,27 @@
const TOKEN_KEY = 'kereru.auth.token'
export const AUTH_CHANGED_EVENT = 'kereru:auth-changed'
export function getAuthToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
export function setAuthToken(token: string): void {
sessionStorage.setItem(TOKEN_KEY, token)
}
export function clearAuthToken(): void {
try {
sessionStorage.removeItem(TOKEN_KEY)
} catch {
// Storage can be unavailable in privacy modes; there is nothing else to clear.
}
}
export function notifyAuthChanged(): void {
window.dispatchEvent(new Event(AUTH_CHANGED_EVENT))
}

View File

@ -182,6 +182,45 @@ export interface CreateAccessCodesRequest {
prefix?: string prefix?: string
} }
export type AuthMode = 'password' | 'oidc'
export interface OIDCClientConfig {
issuerUrl: string
authorizationEndpoint: string
tokenEndpoint: string
clientId: string
scopes: string[]
}
export interface AuthConfig {
mode: AuthMode
requireSubmissionAuth: boolean
oidc?: OIDCClientConfig
}
export interface AuthUser {
id: string
name?: string
email?: string
roles: string[]
canAdmin: boolean
canView: boolean
}
export interface AuthMeResponse {
authenticated: boolean
user?: AuthUser
}
export interface LoginRequest {
password: string
}
export interface LoginResponse {
token: string
user: AuthUser
}
export interface Health { export interface Health {
status: string status: string
version: string version: string

View File

@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { SignInPrompt } from '../components/SignInPrompt'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { Answer, Form, Submission, UserIdType } from '../lib/types' import type { Answer, Form, Submission, UserIdType } from '../lib/types'
import { QuestionField } from '../components/QuestionField' import { QuestionField } from '../components/QuestionField'
@ -39,6 +41,8 @@ function isBlank(value: unknown): boolean {
export function FillFormPage() { export function FillFormPage() {
const { formId = '' } = useParams<{ formId: string }>() const { formId = '' } = useParams<{ formId: string }>()
const { user, config, loading: authLoading } = useAuth()
const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@ -62,8 +66,15 @@ export function FillFormPage() {
const [pdfError, setPdfError] = useState<string | null>(null) const [pdfError, setPdfError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (authLoading) return
if (submissionLocked) {
setForm(null)
setLoading(false)
return
}
let cancelled = false let cancelled = false
setLoading(true) setLoading(true)
setLoadError(null)
api api
.getForm(formId) .getForm(formId)
.then((result) => { .then((result) => {
@ -80,7 +91,7 @@ export function FillFormPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [formId]) }, [formId, authLoading, submissionLocked])
const answeredCount = useMemo( const answeredCount = useMemo(
() => Object.values(answers).filter((value) => !isBlank(value)).length, () => Object.values(answers).filter((value) => !isBlank(value)).length,
@ -101,6 +112,16 @@ export function FillFormPage() {
} }
} }
if (authLoading) {
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
}
if (submissionLocked) {
return (
<SignInPrompt message="Sign in with your organisation account to open and complete this form." />
)
}
if (loading) { if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p> return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { AccessCode, Form, SubmissionSummary } from '../lib/types' import type { AccessCode, Form, SubmissionSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge' import { StatusBadge } from '../components/StatusBadge'
@ -7,6 +8,9 @@ import { StatusBadge } from '../components/StatusBadge'
export function FormDetailPage() { export function FormDetailPage() {
const { formId = '' } = useParams<{ formId: string }>() const { formId = '' } = useParams<{ formId: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth()
const canAdmin = Boolean(user?.canAdmin)
const canView = Boolean(user?.canView)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const [submissions, setSubmissions] = useState<SubmissionSummary[]>([]) const [submissions, setSubmissions] = useState<SubmissionSummary[]>([])
@ -23,8 +27,8 @@ export function FormDetailPage() {
try { try {
const [f, subs, accessCodes] = await Promise.all([ const [f, subs, accessCodes] = await Promise.all([
api.getForm(formId), api.getForm(formId),
api.listSubmissions(formId).catch(() => [] as SubmissionSummary[]), canView ? api.listSubmissions(formId) : Promise.resolve([] as SubmissionSummary[]),
api.listAccessCodes(formId).catch(() => [] as AccessCode[]), canAdmin ? api.listAccessCodes(formId) : Promise.resolve([] as AccessCode[]),
]) ])
setForm(f) setForm(f)
setSubmissions(subs) setSubmissions(subs)
@ -38,7 +42,7 @@ export function FormDetailPage() {
useEffect(() => { useEffect(() => {
void load() void load()
}, [formId]) }, [formId, canAdmin, canView])
async function toggleActive() { async function toggleActive() {
if (!form) return if (!form) return
@ -161,18 +165,22 @@ export function FormDetailPage() {
{form.description && <p className="text-slate-600 dark:text-slate-300">{form.description}</p>} {form.description && <p className="text-slate-600 dark:text-slate-300">{form.description}</p>}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button type="button" className="btn-secondary" onClick={() => navigate(`/manage/forms/${form.id}/edit`)}> {canAdmin && (
Edit <>
</button> <button type="button" className="btn-secondary" onClick={() => navigate(`/manage/forms/${form.id}/edit`)}>
Edit
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={createVersion}>
New version
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={toggleActive}>
{form.isActive ? 'Inactivate' : 'Activate'}
</button>
</>
)}
<button type="button" className="btn-secondary" onClick={downloadPdf}> <button type="button" className="btn-secondary" onClick={downloadPdf}>
Download PDF Download PDF
</button> </button>
<button type="button" className="btn-secondary" disabled={busy} onClick={createVersion}>
New version
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={toggleActive}>
{form.isActive ? 'Inactivate' : 'Activate'}
</button>
<Link to={`/forms/${form.id}`} className="btn-primary"> <Link to={`/forms/${form.id}`} className="btn-primary">
Preview form Preview form
</Link> </Link>
@ -223,7 +231,7 @@ export function FormDetailPage() {
</section> </section>
<div className="space-y-6"> <div className="space-y-6">
{form.requiresAccessCode && ( {form.requiresAccessCode && canAdmin && (
<section className="card space-y-4"> <section className="card space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Access codes</h2> <h2 className="text-lg font-semibold">Access codes</h2>
@ -302,9 +310,17 @@ export function FormDetailPage() {
<section className="card space-y-4"> <section className="card space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Submissions</h2> <h2 className="text-lg font-semibold">Submissions</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">{submissions.length} total</span> {canView && (
<span className="text-sm text-slate-500 dark:text-slate-400">
{submissions.length} total
</span>
)}
</div> </div>
{submissions.length === 0 ? ( {!canView ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
A view role is required to see submitted responses.
</p>
) : submissions.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No submissions yet.</p> <p className="text-sm text-slate-500 dark:text-slate-400">No submissions yet.</p>
) : ( ) : (
<div className="table-wrap"> <div className="table-wrap">

View File

@ -1,16 +1,27 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { SignInPrompt } from '../components/SignInPrompt'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types' import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge' import { StatusBadge } from '../components/StatusBadge'
export function HomePage() { export function HomePage() {
const { user, config, loading: authLoading } = useAuth()
const [forms, setForms] = useState<FormSummary[]>([]) const [forms, setForms] = useState<FormSummary[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
useEffect(() => { useEffect(() => {
if (authLoading) return
if (submissionLocked) {
setForms([])
setLoading(false)
return
}
let cancelled = false let cancelled = false
setLoading(true)
api api
.listForms(false) .listForms(false)
.then((result) => { .then((result) => {
@ -25,7 +36,7 @@ export function HomePage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [authLoading, submissionLocked])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -36,19 +47,26 @@ export function HomePage() {
Pick a form below to share your feedback. Forms are versioned, so your previous answers are Pick a form below to share your feedback. Forms are versioned, so your previous answers are
never shown again. never shown again.
</p> </p>
<div className="mt-5 flex flex-wrap gap-3"> {(user?.canAdmin || user?.canView) && (
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50"> <div className="mt-5 flex flex-wrap gap-3">
Manage forms <Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
</Link> Manage forms
<Link </Link>
to="/manage/new" {user?.canAdmin && (
className="btn border border-white/40 text-white hover:bg-white/10" <Link
> to="/manage/new"
Create a form className="btn border border-white/40 text-white hover:bg-white/10"
</Link> >
</div> Create a form
</Link>
)}
</div>
)}
</section> </section>
{submissionLocked ? (
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
) : (
<section> <section>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Available forms</h2> <h2 className="text-xl font-bold">Available forms</h2>
@ -108,6 +126,7 @@ export function HomePage() {
))} ))}
</div> </div>
</section> </section>
)}
</div> </div>
) )
} }

View File

@ -0,0 +1,127 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../lib/api'
import { safeReturnTo } from '../lib/oidc'
export function LoginPage() {
const { config, user, error: configError, loginWithPassword, loginWithOidc } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const state = location.state as { from?: string } | null
const query = new URLSearchParams(location.search)
const returnTo = safeReturnTo(state?.from ?? query.get('returnTo'))
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function signInWithPassword(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError(null)
try {
await loginWithPassword(password)
navigate(returnTo, { replace: true })
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not sign in')
} finally {
setBusy(false)
}
}
async function signInWithOidc() {
setBusy(true)
setError(null)
try {
await loginWithOidc(returnTo)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not start sign-in')
setBusy(false)
}
}
if (user) {
return (
<div className="card mx-auto max-w-lg space-y-3 text-center">
<h1 className="text-2xl font-bold">You are signed in</h1>
<p className="text-slate-500 dark:text-slate-400">
Signed in as {user.name ?? user.email ?? user.id}.
</p>
<div className="flex flex-wrap justify-center gap-3">
{(user.canAdmin || user.canView) && (
<Link to="/manage" className="btn-primary">
Go to management
</Link>
)}
<Link to="/" className="btn-secondary">
Back to forms
</Link>
</div>
</div>
)
}
return (
<div className="card mx-auto max-w-lg space-y-5">
<div>
<h1 className="text-2xl font-bold">Sign in</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Management access requires an admin or view role.
</p>
</div>
{(error || configError) && (
<div className="rounded-lg border border-red-300 p-3 text-sm text-red-700 dark:border-red-900 dark:text-red-300">
{error ?? configError}
</div>
)}
{!config && <p className="text-slate-500 dark:text-slate-400">Loading sign-in options…</p>}
{config?.mode === 'oidc' && config.oidc && (
<div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-300">
Continue with your organisation account.
</p>
<button type="button" className="btn-primary w-full" onClick={signInWithOidc} disabled={busy}>
{busy ? 'Redirecting…' : 'Sign in with OAuth'}
</button>
</div>
)}
{config?.mode === 'password' && (
<form onSubmit={signInWithPassword} className="space-y-3">
<div>
<label htmlFor="password" className="label">
Local password
</label>
<input
id="password"
type="password"
className="input"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<button type="submit" className="btn-primary w-full" disabled={busy || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<p className="text-xs text-slate-500 dark:text-slate-400">
Local sign-in grants both admin and view roles and is intended for development or
testing when no OIDC provider is configured.
</p>
</form>
)}
{config?.mode === 'oidc' && !config.oidc && (
<p className="text-sm text-red-600 dark:text-red-400">
OIDC is enabled but the provider configuration is incomplete.
</p>
)}
</div>
)
}

View File

@ -1,11 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types' import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge' import { StatusBadge } from '../components/StatusBadge'
export function ManagePage() { export function ManagePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth()
const canAdmin = Boolean(user?.canAdmin)
const canView = Boolean(user?.canView)
const [forms, setForms] = useState<FormSummary[]>([]) const [forms, setForms] = useState<FormSummary[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -90,12 +94,16 @@ export function ManagePage() {
<div> <div>
<h1 className="text-2xl font-bold">Management</h1> <h1 className="text-2xl font-bold">Management</h1>
<p className="text-slate-500 dark:text-slate-400"> <p className="text-slate-500 dark:text-slate-400">
Create, version, activate and review forms and submissions. {canAdmin
? 'Create, version, activate and review forms and submissions.'
: 'View forms and review submissions.'}
</p> </p>
</div> </div>
<Link to="/manage/new" className="btn-primary"> {canAdmin && (
Create form <Link to="/manage/new" className="btn-primary">
</Link> Create form
</Link>
)}
</header> </header>
{error && ( {error && (
@ -110,11 +118,15 @@ export function ManagePage() {
<div className="card text-center"> <div className="card text-center">
<p className="text-lg font-semibold">No forms yet</p> <p className="text-lg font-semibold">No forms yet</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400"> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Create your first form to start collecting responses. {canAdmin
? 'Create your first form to start collecting responses.'
: 'No forms have been created yet.'}
</p> </p>
<Link to="/manage/new" className="btn-primary mt-4"> {canAdmin && (
Create form <Link to="/manage/new" className="btn-primary mt-4">
</Link> Create form
</Link>
)}
</div> </div>
) : ( ) : (
<div className="table-wrap"> <div className="table-wrap">
@ -161,7 +173,7 @@ export function ManagePage() {
Preview Preview
</Link> </Link>
<Link to={`/manage/forms/${form.id}`} className="btn-secondary py-1 text-xs"> <Link to={`/manage/forms/${form.id}`} className="btn-secondary py-1 text-xs">
View / submissions {canView ? 'View / submissions' : 'Manage'}
</Link> </Link>
<button <button
type="button" type="button"
@ -170,30 +182,34 @@ export function ManagePage() {
> >
PDF PDF
</button> </button>
<button {canAdmin && (
type="button" <>
className="btn-secondary py-1 text-xs" <button
disabled={busy === form.id} type="button"
onClick={() => createVersion(form)} className="btn-secondary py-1 text-xs"
> disabled={busy === form.id}
New version onClick={() => createVersion(form)}
</button> >
<button New version
type="button" </button>
className="btn-secondary py-1 text-xs" <button
disabled={busy === form.id} type="button"
onClick={() => toggleActive(form)} className="btn-secondary py-1 text-xs"
> disabled={busy === form.id}
{form.isActive ? 'Inactivate' : 'Activate'} onClick={() => toggleActive(form)}
</button> >
<button {form.isActive ? 'Inactivate' : 'Activate'}
type="button" </button>
className="btn-danger py-1 text-xs" <button
disabled={busy === form.id} type="button"
onClick={() => remove(form)} className="btn-danger py-1 text-xs"
> disabled={busy === form.id}
Delete onClick={() => remove(form)}
</button> >
Delete
</button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { completeOidcLogin } from '../lib/oidc'
import { setAuthToken } from '../lib/session'
export function OidcCallbackPage() {
const { loading, config, refresh } = useAuth()
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
const started = useRef(false)
useEffect(() => {
if (loading || started.current) return
started.current = true
if (!config?.oidc) {
setError('OIDC is not configured.')
return
}
void completeOidcLogin(config.oidc, new URLSearchParams(window.location.search))
.then(async ({ token, returnTo }) => {
setAuthToken(token)
await refresh()
navigate(returnTo, { replace: true })
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Could not complete sign-in')
})
}, [loading, config, refresh, navigate])
if (error) {
return (
<div className="card mx-auto max-w-lg space-y-3 text-center">
<h1 className="text-xl font-bold">Sign-in failed</h1>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<Link to="/login" className="btn-primary mx-auto w-fit">
Try again
</Link>
</div>
)
}
return (
<div className="card mx-auto max-w-lg text-center">
<p className="text-slate-500 dark:text-slate-400">Completing sign-in…</p>
</div>
)
}

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { safeReturnTo } from '../lib/oidc'
describe('safeReturnTo', () => {
it('accepts local application paths', () => {
expect(safeReturnTo('/manage')).toBe('/manage')
expect(safeReturnTo('/manage/forms/123?tab=submissions')).toBe(
'/manage/forms/123?tab=submissions',
)
})
it('rejects external and protocol-relative redirects', () => {
expect(safeReturnTo('https://evil.example')).toBe('/manage')
expect(safeReturnTo('//evil.example/path')).toBe('/manage')
expect(safeReturnTo(undefined)).toBe('/manage')
})
})

View File

@ -1,8 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Layout } from '../components/Layout' import { Layout } from '../components/Layout'
vi.mock('../auth/AuthContext', () => ({
useAuth: () => ({ user: null, logout: vi.fn() }),
}))
describe('Layout', () => { describe('Layout', () => {
it('renders navigation and theme toggle', () => { it('renders navigation and theme toggle', () => {
render( render(