diff --git a/README.md b/README.md
index 3f7a853..4ac5c41 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,8 @@ npm run dev
Open . 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
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` |
| – | `KERERU_CORS_ORIGINS` | `http://localhost:5173` |
| – | `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.
@@ -64,6 +76,23 @@ Frontend build-time setting: `VITE_API_BASE_URL` (defaults to `/api`; set to `ht
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
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 |
| --- | --- | --- |
| `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 |
| `POST` | `/forms/{formId}/activate` | Activate 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
-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.
diff --git a/backend/cmd/kereru-forms/main.go b/backend/cmd/kereru-forms/main.go
index 06b51be..f85f117 100644
--- a/backend/cmd/kereru-forms/main.go
+++ b/backend/cmd/kereru-forms/main.go
@@ -12,6 +12,7 @@ import (
"time"
"kereru.forms/internal/api"
+ "kereru.forms/internal/auth"
"kereru.forms/internal/config"
"kereru.forms/internal/database"
"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{
CORSOrigins: cfg.CORSOrigins,
ServiceName: "kereru-forms",
diff --git a/backend/go.mod b/backend/go.mod
index 2410147..1adc835 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -28,9 +28,11 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/gabriel-vasile/mimetype v1.4.15 // 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/stdr v1.2.2 // 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/goccy/go-json v0.11.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/json-iterator/go v1.1.12 // 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/crypto v0.57.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/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 2d91da9..3997fc8 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -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/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
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.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
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-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
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/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
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/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
diff --git a/backend/internal/api/auth_handler.go b/backend/internal/api/auth_handler.go
new file mode 100644
index 0000000..b00ab17
--- /dev/null
+++ b/backend/internal/api/auth_handler.go
@@ -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
+}
diff --git a/backend/internal/api/auth_middleware.go b/backend/internal/api/auth_middleware.go
new file mode 100644
index 0000000..1794fee
--- /dev/null
+++ b/backend/internal/api/auth_middleware.go
@@ -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",
+ })
+}
diff --git a/backend/internal/api/auth_test.go b/backend/internal/api/auth_test.go
new file mode 100644
index 0000000..60433a7
--- /dev/null
+++ b/backend/internal/api/auth_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/api/generated.go b/backend/internal/api/generated.go
index cc8f5f0..0c6af86 100644
--- a/backend/internal/api/generated.go
+++ b/backend/internal/api/generated.go
@@ -24,6 +24,16 @@ import (
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.
const (
Boolean QuestionType = "boolean"
@@ -74,6 +84,32 @@ type AnswerRecord struct {
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.
type ClientTracking struct {
Locale *string `json:"locale,omitempty"`
@@ -178,6 +214,26 @@ type Health struct {
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.
type Question struct {
Description *string `json:"description,omitempty"`
@@ -371,6 +427,9 @@ type ListFormsParams struct {
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.
type CreateFormJSONRequestBody = CreateFormRequest
@@ -385,6 +444,18 @@ type SubmitFormJSONRequestBody = SubmitFormRequest
// ServerInterface represents all server handlers.
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
// (GET /forms)
ListForms(c *gin.Context, params ListFormsParams)
@@ -444,6 +515,62 @@ type ServerInterfaceWrapper struct {
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
func (siw *ServerInterfaceWrapper) ListForms(c *gin.Context) {
@@ -481,6 +608,8 @@ func (siw *ServerInterfaceWrapper) ListForms(c *gin.Context) {
// CreateForm operation middleware
func (siw *ServerInterfaceWrapper) CreateForm(c *gin.Context) {
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -505,6 +634,8 @@ func (siw *ServerInterfaceWrapper) DeleteForm(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -553,6 +684,8 @@ func (siw *ServerInterfaceWrapper) UpdateForm(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -577,6 +710,8 @@ func (siw *ServerInterfaceWrapper) ListAccessCodes(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -601,6 +736,8 @@ func (siw *ServerInterfaceWrapper) CreateAccessCodes(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -625,6 +762,8 @@ func (siw *ServerInterfaceWrapper) ActivateForm(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -649,6 +788,8 @@ func (siw *ServerInterfaceWrapper) InactivateForm(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -697,6 +838,8 @@ func (siw *ServerInterfaceWrapper) ListFormSubmissions(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -745,6 +888,8 @@ func (siw *ServerInterfaceWrapper) CreateFormVersion(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -782,6 +927,8 @@ func (siw *ServerInterfaceWrapper) GetSubmission(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -806,6 +953,8 @@ func (siw *ServerInterfaceWrapper) GetSubmissionOfflinePdf(c *gin.Context) {
return
}
+ c.Set(BearerAuthScopes, []string{})
+
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
@@ -843,6 +992,10 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
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.POST(options.BaseURL+"/forms", wrapper.CreateForm)
router.DELETE(options.BaseURL+"/forms/:formId", wrapper.DeleteForm)
@@ -863,6 +1016,97 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
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 {
Params ListFormsParams
}
@@ -1464,6 +1708,18 @@ func (response GetSubmissionOfflinePdf500JSONResponse) VisitGetSubmissionOffline
// StrictServerInterface represents all server handlers.
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
// (GET /forms)
ListForms(ctx context.Context, request ListFormsRequestObject) (ListFormsResponseObject, error)
@@ -1526,6 +1782,114 @@ type strictHandler struct {
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
func (sh *strictHandler) ListForms(ctx *gin.Context, params ListFormsParams) {
var request ListFormsRequestObject
@@ -1989,65 +2353,77 @@ func (sh *strictHandler) GetSubmissionOfflinePdf(ctx *gin.Context, submissionId
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
- "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",
+ "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",
}
// GetSwagger returns the content of the embedded swagger specification file
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index 8ee986c..1afad43 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -43,6 +43,9 @@ func NewRouter(server *Server, logger zerolog.Logger, opts RouterOptions) *gin.E
r.Use(withRequestMetadata())
r.Use(logging.GinMiddleware(logger))
r.Use(corsMiddleware(opts.CORSOrigins))
+ if server.auth != nil {
+ r.Use(authMiddleware(server.auth))
+ }
strict := NewStrictHandler(server, nil)
// The API is exposed at the root (for probes and direct clients) and under
diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go
index c4721d8..a55eec6 100644
--- a/backend/internal/api/server.go
+++ b/backend/internal/api/server.go
@@ -22,6 +22,7 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
+ "kereru.forms/internal/auth"
"kereru.forms/internal/database"
"kereru.forms/internal/pdf"
)
@@ -33,17 +34,23 @@ type Server struct {
version string
startedAt time.Time
tracer trace.Tracer
+ auth *auth.Service
}
-// NewServer constructs the API implementation.
-func NewServer(store database.Store, logger zerolog.Logger, version string) *Server {
- return &Server{
+// NewServer constructs the API implementation. Authentication is optional so
+// tests can exercise handlers directly; the application always supplies it.
+func NewServer(store database.Store, logger zerolog.Logger, version string, authService ...*auth.Service) *Server {
+ server := &Server{
store: store,
logger: logger,
version: version,
startedAt: time.Now(),
tracer: otel.Tracer("kereru.forms/api"),
}
+ if len(authService) > 0 {
+ server.auth = authService[0]
+ }
+ return server
}
func errJSON(message, code string) ErrorJSONResponse {
diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go
new file mode 100644
index 0000000..ee9a372
--- /dev/null
+++ b/backend/internal/auth/auth.go
@@ -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 ""
+}
diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go
new file mode 100644
index 0000000..a5dc2b5
--- /dev/null
+++ b/backend/internal/auth/auth_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index cd34550..6005fbe 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -52,6 +52,43 @@ type Config struct {
// StaticDir optionally serves a built frontend from this directory.
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.
@@ -69,6 +106,16 @@ func Default() *Config {
OTLPEndpoint: "localhost:4318",
CORSOrigins: []string{"http://localhost:5173"},
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) == "" {
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
}
@@ -211,6 +287,34 @@ func applyEnv(cfg *Config) {
setString(&cfg.DBDriver, "KERERU_DB_DRIVER")
setString(&cfg.DBDSN, "KERERU_DB_DSN")
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 b, err := strconv.ParseBool(v); err == nil {
cfg.TelemetryEnabled = b
diff --git a/backend/openapi/openapi.yaml b/backend/openapi/openapi.yaml
index be9c0ba..b585f7b 100644
--- a/backend/openapi/openapi.yaml
+++ b/backend/openapi/openapi.yaml
@@ -28,6 +28,8 @@ tags:
description: Form submissions and management of captured data
- name: access-codes
description: Single-use access codes
+ - name: auth
+ description: Password fallback and OIDC-backed bearer authentication
paths:
/health:
get:
@@ -75,6 +77,8 @@ paths:
tags: [forms]
operationId: createForm
summary: Create a form
+ security:
+ - bearerAuth: []
requestBody:
required: true
content:
@@ -114,6 +118,8 @@ paths:
tags: [forms]
operationId: updateForm
summary: Update a form definition
+ security:
+ - bearerAuth: []
requestBody:
required: true
content:
@@ -137,6 +143,8 @@ paths:
tags: [forms]
operationId: deleteForm
summary: Delete a form definition
+ security:
+ - bearerAuth: []
responses:
'204':
description: Deleted
@@ -151,6 +159,8 @@ paths:
tags: [forms]
operationId: inactivateForm
summary: Inactivate a form
+ security:
+ - bearerAuth: []
description: Inactivated forms remain visible in management but cannot accept new submissions.
responses:
'200':
@@ -170,6 +180,8 @@ paths:
tags: [forms]
operationId: activateForm
summary: Activate a form
+ security:
+ - bearerAuth: []
responses:
'200':
description: Activated form
@@ -188,6 +200,8 @@ paths:
tags: [forms]
operationId: createFormVersion
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.
responses:
'201':
@@ -226,6 +240,8 @@ paths:
tags: [submissions]
operationId: listFormSubmissions
summary: List submissions for a form (management)
+ security:
+ - bearerAuth: []
responses:
'200':
description: Submissions
@@ -282,6 +298,8 @@ paths:
tags: [submissions]
operationId: getSubmission
summary: Get a submission by id (management)
+ security:
+ - bearerAuth: []
responses:
'200':
description: Submission
@@ -305,6 +323,8 @@ paths:
tags: [submissions]
operationId: getSubmissionOfflinePdf
summary: Download the offline PDF uploaded with a submission
+ security:
+ - bearerAuth: []
responses:
'200':
description: The uploaded PDF
@@ -324,6 +344,8 @@ paths:
tags: [access-codes]
operationId: listAccessCodes
summary: List access codes for a form
+ security:
+ - bearerAuth: []
responses:
'200':
description: Access codes
@@ -341,6 +363,8 @@ paths:
tags: [access-codes]
operationId: createAccessCodes
summary: Create one or more access codes for a form
+ security:
+ - bearerAuth: []
requestBody:
required: true
content:
@@ -362,7 +386,82 @@ paths:
$ref: '#/components/responses/Error'
'500':
$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:
+ 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:
FormId:
name: formId
@@ -379,6 +478,78 @@ components:
schema:
$ref: '#/components/schemas/Error'
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:
type: object
required: [status, version]
diff --git a/config/config.example.json b/config/config.example.json
index 7964e93..3b85d6b 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -12,5 +12,23 @@
"otlpEndpoint": "localhost:4318",
"corsOrigins": [
"http://localhost:5173"
- ]
+ ],
+ "auth": {
+ "localPassword": "admin",
+ "requireSubmissionAuth": false,
+ "localTokenSecret": "",
+ "localTokenTtlHours": 8,
+ "oidc": {
+ "issuerUrl": "",
+ "clientId": "",
+ "scopes": [
+ "openid",
+ "profile",
+ "email"
+ ],
+ "roleClaim": "roles",
+ "adminRole": "admin",
+ "viewRole": "view"
+ }
+ }
}
diff --git a/config/config.example.yaml b/config/config.example.yaml
index 21423c5..06d7287 100644
--- a/config/config.example.yaml
+++ b/config/config.example.yaml
@@ -20,3 +20,26 @@ otlpEndpoint: "localhost:4318"
corsOrigins:
- "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"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 62883b3..7ad203c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -30,7 +30,8 @@ backend/
├── oapi-codegen.yaml Code generation settings
├── cmd/kereru-forms/main.go Wiring, listeners, graceful shutdown
└── 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
├── database/ Store interface, SQLite implementation, migrations
├── logging/ zerolog setup + Gin access logging
@@ -40,15 +41,36 @@ backend/
### 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.
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}`).
+### 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
`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
`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/
├── 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
-├── 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
-├── pages/ Home, FillForm, Manage, FormBuilder, FormDetail, SubmissionDetail
+├── pages/ Home, FillForm, Login, OidcCallback, Manage, FormBuilder, FormDetail, SubmissionDetail
└── 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.
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
-- 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.
- 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).
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index d46bc24..10a76b2 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -143,6 +143,9 @@ Typical production settings:
- `KERERU_TELEMETRY_ENABLED=true` and `OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318`
- `KERERU_STATIC_DIR=/app/static`
- 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
@@ -151,6 +154,8 @@ For SQLite, back up the database file while the service is stopped, or use the S
### Security
- 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.
- Do not expose the SQLite file over a network share.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8e35fa4..3959698 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,11 +1,15 @@
import { Suspense, lazy } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
+import { AuthProvider } from './auth/AuthContext'
+import { ManagementGate } from './auth/gates'
import { Layout } from './components/Layout'
import { FillFormPage } from './pages/FillFormPage'
import { FormBuilderPage } from './pages/FormBuilderPage'
import { FormDetailPage } from './pages/FormDetailPage'
import { HomePage } from './pages/HomePage'
+import { LoginPage } from './pages/LoginPage'
import { ManagePage } from './pages/ManagePage'
+import { OidcCallbackPage } from './pages/OidcCallbackPage'
import { SubmissionDetailPage } from './pages/SubmissionDetailPage'
const DocsPage = lazy(() =>
@@ -15,25 +19,64 @@ const DocsPage = lazy(() =>
export function App() {
return (
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- Loading API docs…
}>
-
-
- }
- />
-
-
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ Loading API docs…}>
+
+
+ }
+ />
+
+
+
)
}
diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx
new file mode 100644
index 0000000..e85b96b
--- /dev/null
+++ b/frontend/src/auth/AuthContext.tsx
@@ -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
+ loginWithOidc: (returnTo: string) => Promise
+ logout: () => Promise
+ refresh: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [loading, setLoading] = useState(true)
+ const [config, setConfig] = useState(null)
+ const [user, setUser] = useState(null)
+ const [error, setError] = useState(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 {children}
+}
+
+export function useAuth(): AuthContextValue {
+ const context = useContext(AuthContext)
+ if (!context) {
+ throw new Error('useAuth must be used inside AuthProvider')
+ }
+ return context
+}
diff --git a/frontend/src/auth/gates.tsx b/frontend/src/auth/gates.tsx
new file mode 100644
index 0000000..c322511
--- /dev/null
+++ b/frontend/src/auth/gates.tsx
@@ -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 Checking sign-in…
+ }
+ if (!user) {
+ return
+ }
+ if (!hasRole(user, role)) {
+ return
+ }
+ 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 (
+
+
Access denied
+
+ Your account does not have the {roleName} permission required for this page.
+
+
+ Back to forms
+
+
+ )
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index ab0d7c7..5018145 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,4 +1,5 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
+import { useAuth } from '../auth/AuthContext'
import { ThemeToggle } from './ThemeToggle'
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
@@ -9,6 +10,9 @@ const navLinkClass = ({ isActive }: { isActive: boolean }) =>
}`
export function Layout() {
+ const { user, logout } = useAuth()
+ const canManage = Boolean(user?.canAdmin || user?.canView)
+
return (
@@ -29,15 +33,35 @@ export function Layout() {
Forms
-
- Management
-
+ {canManage && (
+
+ Management
+
+ )}
API docs
-
+
+ {user ? (
+
+
+ {user.name ?? user.email ?? user.id}
+
+ void logout()}
+ >
+ Sign out
+
+
+ ) : (
+
+ Sign in
+
+ )}
diff --git a/frontend/src/components/SignInPrompt.tsx b/frontend/src/components/SignInPrompt.tsx
new file mode 100644
index 0000000..6515175
--- /dev/null
+++ b/frontend/src/components/SignInPrompt.tsx
@@ -0,0 +1,18 @@
+import { Link, useLocation } from 'react-router-dom'
+
+export function SignInPrompt({ message }: { message: string }) {
+ const location = useLocation()
+ return (
+
+
Sign in required
+
{message}
+
+ Sign in
+
+
+ )
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index de4576f..1f708bc 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,16 +1,20 @@
import type {
AccessCode,
ApiErrorBody,
+ AuthConfig,
+ AuthMeResponse,
CreateAccessCodesRequest,
CreateFormRequest,
Form,
FormSummary,
Health,
+ LoginResponse,
Submission,
SubmissionSummary,
SubmitFormRequest,
UpdateFormRequest,
} from './types'
+import { clearAuthToken, getAuthToken, notifyAuthChanged } from './session'
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 {
+ const token = getAuthToken()
+ return token ? { Authorization: `Bearer ${token}` } : {}
+}
+
async function request(path: string, init?: RequestInit): Promise {
+ const token = getAuthToken()
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
+ ...authHeaders(),
...init?.headers,
},
})
@@ -45,6 +56,10 @@ async function request(path: string, init?: RequestInit): Promise {
} catch {
// Response was not JSON; keep the generic message.
}
+ if (response.status === 401 && token) {
+ clearAuthToken()
+ notifyAuthChanged()
+ }
throw new ApiError(response.status, message, code)
}
@@ -57,6 +72,18 @@ async function request(path: string, init?: RequestInit): Promise {
export const api = {
health: () => request('/health'),
+ getAuthConfig: () => request('/auth/config'),
+
+ login: (password: string) =>
+ request('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ password }),
+ }),
+
+ me: () => request('/auth/me'),
+
+ logout: () => request('/auth/logout', { method: 'POST' }),
+
listForms: (includeInactive = false) =>
request(`/forms${includeInactive ? '?includeInactive=true' : ''}`),
@@ -96,7 +123,7 @@ export const api = {
link.remove()
return
}
- const response = await fetch(api.formPdfUrl(id))
+ const response = await fetch(api.formPdfUrl(id), { headers: authHeaders() })
if (!response.ok) {
throw new ApiError(response.status, 'Could not download the PDF')
}
@@ -114,6 +141,7 @@ export const api = {
downloadSubmissionOfflinePdf: async (id: string, filename?: string) => {
const response = await fetch(
`${BASE_URL}/submissions/${encodeURIComponent(id)}/offline-pdf`,
+ { headers: authHeaders() },
)
if (!response.ok) {
throw new ApiError(response.status, 'Could not download the offline PDF')
diff --git a/frontend/src/lib/oidc.ts b/frontend/src/lib/oidc.ts
new file mode 100644
index 0000000..b8a39a2
--- /dev/null
+++ b/frontend/src/lib/oidc.ts
@@ -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 {
+ 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 {
+ return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
+}
+
+function decodeJwtPayload(token: string): Record {
+ 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
+}
diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts
new file mode 100644
index 0000000..b619563
--- /dev/null
+++ b/frontend/src/lib/session.ts
@@ -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))
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index e29319e..9812176 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -182,6 +182,45 @@ export interface CreateAccessCodesRequest {
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 {
status: string
version: string
diff --git a/frontend/src/pages/FillFormPage.tsx b/frontend/src/pages/FillFormPage.tsx
index a647839..aa38fd7 100644
--- a/frontend/src/pages/FillFormPage.tsx
+++ b/frontend/src/pages/FillFormPage.tsx
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
+import { useAuth } from '../auth/AuthContext'
+import { SignInPrompt } from '../components/SignInPrompt'
import { api, ApiError } from '../lib/api'
import type { Answer, Form, Submission, UserIdType } from '../lib/types'
import { QuestionField } from '../components/QuestionField'
@@ -39,6 +41,8 @@ function isBlank(value: unknown): boolean {
export function FillFormPage() {
const { formId = '' } = useParams<{ formId: string }>()
+ const { user, config, loading: authLoading } = useAuth()
+ const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
const [form, setForm] = useState
-
navigate(`/manage/forms/${form.id}/edit`)}>
- Edit
-
+ {canAdmin && (
+ <>
+
navigate(`/manage/forms/${form.id}/edit`)}>
+ Edit
+
+
+ New version
+
+
+ {form.isActive ? 'Inactivate' : 'Activate'}
+
+ >
+ )}
Download PDF
-
- New version
-
-
- {form.isActive ? 'Inactivate' : 'Activate'}
-
Preview form
@@ -223,7 +231,7 @@ export function FormDetailPage() {
- {form.requiresAccessCode && (
+ {form.requiresAccessCode && canAdmin && (
Access codes
@@ -302,9 +310,17 @@ export function FormDetailPage() {
Submissions
- {submissions.length} total
+ {canView && (
+
+ {submissions.length} total
+
+ )}
- {submissions.length === 0 ? (
+ {!canView ? (
+
+ A view role is required to see submitted responses.
+
+ ) : submissions.length === 0 ? (
No submissions yet.
) : (
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 8509ec9..cc9d2bf 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -1,16 +1,27 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
+import { useAuth } from '../auth/AuthContext'
+import { SignInPrompt } from '../components/SignInPrompt'
import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function HomePage() {
+ const { user, config, loading: authLoading } = useAuth()
const [forms, setForms] = useState
([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ const submissionLocked = Boolean(config?.requireSubmissionAuth && !user)
useEffect(() => {
+ if (authLoading) return
+ if (submissionLocked) {
+ setForms([])
+ setLoading(false)
+ return
+ }
let cancelled = false
+ setLoading(true)
api
.listForms(false)
.then((result) => {
@@ -25,7 +36,7 @@ export function HomePage() {
return () => {
cancelled = true
}
- }, [])
+ }, [authLoading, submissionLocked])
return (
@@ -36,19 +47,26 @@ export function HomePage() {
Pick a form below to share your feedback. Forms are versioned, so your previous answers are
never shown again.
-
-
- Manage forms
-
-
- Create a form
-
-
+ {(user?.canAdmin || user?.canView) && (
+
+
+ Manage forms
+
+ {user?.canAdmin && (
+
+ Create a form
+
+ )}
+
+ )}
+ {submissionLocked ? (
+
+ ) : (
Available forms
@@ -108,6 +126,7 @@ export function HomePage() {
))}
+ )}
)
}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..663faa6
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -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(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 (
+
+
You are signed in
+
+ Signed in as {user.name ?? user.email ?? user.id}.
+
+
+ {(user.canAdmin || user.canView) && (
+
+ Go to management
+
+ )}
+
+ Back to forms
+
+
+
+ )
+ }
+
+ return (
+
+
+
Sign in
+
+ Management access requires an admin or view role.
+
+
+
+ {(error || configError) && (
+
+ {error ?? configError}
+
+ )}
+
+ {!config &&
Loading sign-in options…
}
+
+ {config?.mode === 'oidc' && config.oidc && (
+
+
+ Continue with your organisation account.
+
+
+ {busy ? 'Redirecting…' : 'Sign in with OAuth'}
+
+
+ )}
+
+ {config?.mode === 'password' && (
+
+
+
+ Local password
+
+ setPassword(event.target.value)}
+ autoComplete="current-password"
+ required
+ />
+
+
+ {busy ? 'Signing in…' : 'Sign in'}
+
+
+ Local sign-in grants both admin and view roles and is intended for development or
+ testing when no OIDC provider is configured.
+
+
+ )}
+
+ {config?.mode === 'oidc' && !config.oidc && (
+
+ OIDC is enabled but the provider configuration is incomplete.
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/ManagePage.tsx b/frontend/src/pages/ManagePage.tsx
index 7f36e0a..5f0e6ef 100644
--- a/frontend/src/pages/ManagePage.tsx
+++ b/frontend/src/pages/ManagePage.tsx
@@ -1,11 +1,15 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
+import { useAuth } from '../auth/AuthContext'
import { api, ApiError } from '../lib/api'
import type { FormSummary } from '../lib/types'
import { StatusBadge } from '../components/StatusBadge'
export function ManagePage() {
const navigate = useNavigate()
+ const { user } = useAuth()
+ const canAdmin = Boolean(user?.canAdmin)
+ const canView = Boolean(user?.canView)
const [forms, setForms] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -90,12 +94,16 @@ export function ManagePage() {
Management
- Create, version, activate and review forms and submissions.
+ {canAdmin
+ ? 'Create, version, activate and review forms and submissions.'
+ : 'View forms and review submissions.'}
-
- Create form
-
+ {canAdmin && (
+
+ Create form
+
+ )}
{error && (
@@ -110,11 +118,15 @@ export function ManagePage() {
No forms yet
- Create your first form to start collecting responses.
+ {canAdmin
+ ? 'Create your first form to start collecting responses.'
+ : 'No forms have been created yet.'}
-
- Create form
-
+ {canAdmin && (
+
+ Create form
+
+ )}
) : (
@@ -161,7 +173,7 @@ export function ManagePage() {
Preview
- View / submissions
+ {canView ? 'View / submissions' : 'Manage'}
PDF
- createVersion(form)}
- >
- New version
-
- toggleActive(form)}
- >
- {form.isActive ? 'Inactivate' : 'Activate'}
-
- remove(form)}
- >
- Delete
-
+ {canAdmin && (
+ <>
+ createVersion(form)}
+ >
+ New version
+
+ toggleActive(form)}
+ >
+ {form.isActive ? 'Inactivate' : 'Activate'}
+
+ remove(form)}
+ >
+ Delete
+
+ >
+ )}
diff --git a/frontend/src/pages/OidcCallbackPage.tsx b/frontend/src/pages/OidcCallbackPage.tsx
new file mode 100644
index 0000000..1701bce
--- /dev/null
+++ b/frontend/src/pages/OidcCallbackPage.tsx
@@ -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(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 (
+
+
Sign-in failed
+
{error}
+
+ Try again
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/test/auth.test.ts b/frontend/src/test/auth.test.ts
new file mode 100644
index 0000000..9669fed
--- /dev/null
+++ b/frontend/src/test/auth.test.ts
@@ -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')
+ })
+})
diff --git a/frontend/src/test/layout.test.tsx b/frontend/src/test/layout.test.tsx
index 0ab5f48..837c756 100644
--- a/frontend/src/test/layout.test.tsx
+++ b/frontend/src/test/layout.test.tsx
@@ -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 { MemoryRouter } from 'react-router-dom'
import { Layout } from '../components/Layout'
+vi.mock('../auth/AuthContext', () => ({
+ useAuth: () => ({ user: null, logout: vi.fn() }),
+}))
+
describe('Layout', () => {
it('renders navigation and theme toggle', () => {
render(