Added OAuth and login
parent
5c9a7a0f64
commit
be2cfc8bfd
40
README.md
40
README.md
|
|
@ -28,6 +28,8 @@ npm run dev
|
|||
|
||||
Open <http://localhost:5173>. Vite proxies `/api/*` to `http://localhost:8080/api/*`. The API is also available without the `/api` prefix for probes and direct clients.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="forms/:formId" element={<FillFormPage />} />
|
||||
<Route path="manage" element={<ManagePage />} />
|
||||
<Route path="manage/new" element={<FormBuilderPage />} />
|
||||
<Route path="manage/forms/:formId" element={<FormDetailPage />} />
|
||||
<Route path="manage/forms/:formId/edit" element={<FormBuilderPage />} />
|
||||
<Route path="submissions/:submissionId" element={<SubmissionDetailPage />} />
|
||||
<Route
|
||||
path="docs"
|
||||
element={
|
||||
<Suspense fallback={<p className="text-slate-500 dark:text-slate-400">Loading API docs…</p>}>
|
||||
<DocsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="forms/:formId" element={<FillFormPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="auth/callback" element={<OidcCallbackPage />} />
|
||||
<Route
|
||||
path="manage"
|
||||
element={
|
||||
<ManagementGate>
|
||||
<ManagePage />
|
||||
</ManagementGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/new"
|
||||
element={
|
||||
<ManagementGate role="admin">
|
||||
<FormBuilderPage />
|
||||
</ManagementGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/forms/:formId"
|
||||
element={
|
||||
<ManagementGate>
|
||||
<FormDetailPage />
|
||||
</ManagementGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/forms/:formId/edit"
|
||||
element={
|
||||
<ManagementGate role="admin">
|
||||
<FormBuilderPage />
|
||||
</ManagementGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="submissions/:submissionId"
|
||||
element={
|
||||
<ManagementGate role="view">
|
||||
<SubmissionDetailPage />
|
||||
</ManagementGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="docs"
|
||||
element={
|
||||
<Suspense fallback={<p className="text-slate-500 dark:text-slate-400">Loading API docs…</p>}>
|
||||
<DocsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { beginOidcLogin } from '../lib/oidc'
|
||||
import {
|
||||
AUTH_CHANGED_EVENT,
|
||||
clearAuthToken,
|
||||
getAuthToken,
|
||||
setAuthToken,
|
||||
} from '../lib/session'
|
||||
import type { AuthConfig, AuthUser } from '../lib/types'
|
||||
|
||||
interface AuthContextValue {
|
||||
loading: boolean
|
||||
config: AuthConfig | null
|
||||
user: AuthUser | null
|
||||
error: string | null
|
||||
loginWithPassword: (password: string) => Promise<void>
|
||||
loginWithOidc: (returnTo: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [config, setConfig] = useState<AuthConfig | null>(null)
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!getAuthToken()) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api.me()
|
||||
setUser(result.authenticated ? (result.user ?? null) : null)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearAuthToken()
|
||||
}
|
||||
setUser(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function initialise() {
|
||||
try {
|
||||
const result = await api.getAuthConfig()
|
||||
if (cancelled) return
|
||||
setConfig(result)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof ApiError ? err.message : 'Could not load authentication configuration')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void initialise()
|
||||
|
||||
const onAuthChanged = () => {
|
||||
if (getAuthToken()) {
|
||||
void refresh()
|
||||
} else {
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener(AUTH_CHANGED_EVENT, onAuthChanged)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener(AUTH_CHANGED_EVENT, onAuthChanged)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const loginWithPassword = useCallback(async (password: string) => {
|
||||
const result = await api.login(password)
|
||||
setAuthToken(result.token)
|
||||
setUser(result.user)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const loginWithOidc = useCallback(
|
||||
async (returnTo: string) => {
|
||||
if (!config?.oidc) {
|
||||
throw new Error('OIDC is not configured')
|
||||
}
|
||||
await beginOidcLogin(config.oidc, returnTo)
|
||||
},
|
||||
[config],
|
||||
)
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {
|
||||
// Tokens are stateless; local cleanup is still sufficient.
|
||||
}
|
||||
clearAuthToken()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
loading,
|
||||
config,
|
||||
user,
|
||||
error,
|
||||
loginWithPassword,
|
||||
loginWithOidc,
|
||||
logout,
|
||||
refresh,
|
||||
}),
|
||||
[loading, config, user, error, loginWithPassword, loginWithOidc, logout, refresh],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used inside AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { Link, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from './AuthContext'
|
||||
import type { AuthUser } from '../lib/types'
|
||||
|
||||
type RequiredRole = 'admin' | 'view' | 'management'
|
||||
|
||||
export function ManagementGate({
|
||||
children,
|
||||
role = 'management',
|
||||
}: {
|
||||
children: ReactNode
|
||||
role?: RequiredRole
|
||||
}) {
|
||||
const { loading, user } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
|
||||
}
|
||||
if (!hasRole(user, role)) {
|
||||
return <AccessDenied role={role} />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function hasRole(user: AuthUser, role: RequiredRole): boolean {
|
||||
if (role === 'admin') return user.canAdmin
|
||||
if (role === 'view') return user.canView
|
||||
return user.canAdmin || user.canView
|
||||
}
|
||||
|
||||
function AccessDenied({ role }: { role: RequiredRole }) {
|
||||
const roleName = role === 'management' ? 'management' : `${role} role`
|
||||
return (
|
||||
<div className="card mx-auto max-w-xl space-y-3 text-center">
|
||||
<h1 className="text-xl font-bold">Access denied</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
Your account does not have the {roleName} permission required for this page.
|
||||
</p>
|
||||
<Link to="/" className="btn-primary mx-auto w-fit">
|
||||
Back to forms
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="border-b border-slate-200 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
||||
|
|
@ -29,15 +33,35 @@ export function Layout() {
|
|||
<NavLink to="/" end className={navLinkClass}>
|
||||
Forms
|
||||
</NavLink>
|
||||
<NavLink to="/manage" className={navLinkClass}>
|
||||
Management
|
||||
</NavLink>
|
||||
{canManage && (
|
||||
<NavLink to="/manage" className={navLinkClass}>
|
||||
Management
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink to="/docs" className={navLinkClass}>
|
||||
API docs
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="order-2 ml-auto sm:order-3">
|
||||
<div className="order-2 ml-auto flex items-center gap-2 sm:order-3">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden max-w-40 truncate text-sm text-slate-500 sm:inline dark:text-slate-400">
|
||||
{user.name ?? user.email ?? user.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary py-1 text-xs"
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="btn-secondary py-1 text-xs">
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
export function SignInPrompt({ message }: { message: string }) {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<div className="card space-y-3 text-center">
|
||||
<h2 className="text-xl font-bold">Sign in required</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400">{message}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
state={{ from: location.pathname + location.search }}
|
||||
className="btn-primary mx-auto w-fit"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string, string> {
|
||||
const token = getAuthToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
} 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<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
export const api = {
|
||||
health: () => request<Health>('/health'),
|
||||
|
||||
getAuthConfig: () => request<AuthConfig>('/auth/config'),
|
||||
|
||||
login: (password: string) =>
|
||||
request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
|
||||
me: () => request<AuthMeResponse>('/auth/me'),
|
||||
|
||||
logout: () => request<void>('/auth/logout', { method: 'POST' }),
|
||||
|
||||
listForms: (includeInactive = false) =>
|
||||
request<FormSummary[]>(`/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')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import type { OIDCClientConfig } from './types'
|
||||
|
||||
const PENDING_KEY = 'kereru.oidc.pending'
|
||||
const PENDING_MAX_AGE_MS = 10 * 60 * 1000
|
||||
|
||||
interface PendingLogin {
|
||||
state: string
|
||||
nonce: string
|
||||
codeVerifier: string
|
||||
returnTo: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
id_token?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
export function safeReturnTo(value: string | null | undefined, fallback = '/manage'): string {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
export async function beginOidcLogin(
|
||||
config: OIDCClientConfig,
|
||||
returnTo: string,
|
||||
): Promise<void> {
|
||||
const state = randomBase64Url(32)
|
||||
const nonce = randomBase64Url(32)
|
||||
const codeVerifier = randomBase64Url(64)
|
||||
const codeChallenge = base64Url(await sha256(codeVerifier))
|
||||
const pending: PendingLogin = {
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
returnTo: safeReturnTo(returnTo),
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
|
||||
|
||||
const url = new URL(config.authorizationEndpoint)
|
||||
url.searchParams.set('client_id', config.clientId)
|
||||
url.searchParams.set('redirect_uri', oidcRedirectUri())
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', config.scopes.join(' '))
|
||||
url.searchParams.set('state', state)
|
||||
url.searchParams.set('nonce', nonce)
|
||||
url.searchParams.set('code_challenge', codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', 'S256')
|
||||
window.location.assign(url.toString())
|
||||
}
|
||||
|
||||
export async function completeOidcLogin(
|
||||
config: OIDCClientConfig,
|
||||
params: URLSearchParams,
|
||||
): Promise<{ token: string; returnTo: string }> {
|
||||
const pending = readPending()
|
||||
if (params.get('error')) {
|
||||
throw new Error(
|
||||
params.get('error_description') ?? params.get('error') ?? 'The identity provider rejected sign-in',
|
||||
)
|
||||
}
|
||||
const state = params.get('state')
|
||||
if (!state || state !== pending.state) {
|
||||
throw new Error('The sign-in response could not be verified. Please try again.')
|
||||
}
|
||||
const code = params.get('code')
|
||||
if (!code) {
|
||||
throw new Error('The identity provider did not return an authorization code.')
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: oidcRedirectUri(),
|
||||
client_id: config.clientId,
|
||||
code_verifier: pending.codeVerifier,
|
||||
})
|
||||
const response = await fetch(config.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const text = await response.text()
|
||||
let tokens: TokenResponse = {}
|
||||
try {
|
||||
tokens = JSON.parse(text) as TokenResponse
|
||||
} catch {
|
||||
// Keep the generic error below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
tokens.error_description ?? tokens.error ?? `Token exchange failed (${response.status})`,
|
||||
)
|
||||
}
|
||||
if (!tokens.id_token) {
|
||||
throw new Error('The identity provider did not return an ID token.')
|
||||
}
|
||||
|
||||
const claims = decodeJwtPayload(tokens.id_token)
|
||||
if (claims.nonce !== pending.nonce) {
|
||||
throw new Error('The sign-in response nonce did not match. Please try again.')
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
return { token: tokens.id_token, returnTo: pending.returnTo }
|
||||
}
|
||||
|
||||
export function oidcRedirectUri(): string {
|
||||
return `${window.location.origin}/auth/callback`
|
||||
}
|
||||
|
||||
function readPending(): PendingLogin {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||
if (!raw) {
|
||||
throw new Error('This sign-in session has expired. Please start again.')
|
||||
}
|
||||
let pending: PendingLogin
|
||||
try {
|
||||
pending = JSON.parse(raw) as PendingLogin
|
||||
} catch {
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
throw new Error('This sign-in session is invalid. Please start again.')
|
||||
}
|
||||
if (
|
||||
typeof pending.state !== 'string' ||
|
||||
typeof pending.nonce !== 'string' ||
|
||||
typeof pending.codeVerifier !== 'string' ||
|
||||
typeof pending.returnTo !== 'string' ||
|
||||
Date.now() - pending.createdAt > PENDING_MAX_AGE_MS
|
||||
) {
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
throw new Error('This sign-in session has expired. Please start again.')
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function randomBase64Url(byteLength: number): string {
|
||||
const bytes = new Uint8Array(byteLength)
|
||||
crypto.getRandomValues(bytes)
|
||||
return base64Url(bytes)
|
||||
}
|
||||
|
||||
function base64Url(value: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function sha256(value: string): Promise<ArrayBuffer> {
|
||||
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const part = token.split('.')[1]
|
||||
if (!part) throw new Error('The identity provider returned an invalid ID token.')
|
||||
const normalized = part.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Form | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
|
@ -62,8 +66,15 @@ export function FillFormPage() {
|
|||
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
if (submissionLocked) {
|
||||
setForm(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
api
|
||||
.getForm(formId)
|
||||
.then((result) => {
|
||||
|
|
@ -80,7 +91,7 @@ export function FillFormPage() {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [formId])
|
||||
}, [formId, authLoading, submissionLocked])
|
||||
|
||||
const answeredCount = useMemo(
|
||||
() => Object.values(answers).filter((value) => !isBlank(value)).length,
|
||||
|
|
@ -101,6 +112,16 @@ export function FillFormPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return <p className="text-slate-500 dark:text-slate-400">Checking sign-in…</p>
|
||||
}
|
||||
|
||||
if (submissionLocked) {
|
||||
return (
|
||||
<SignInPrompt message="Sign in with your organisation account to open and complete this form." />
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import type { AccessCode, Form, SubmissionSummary } from '../lib/types'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
|
|
@ -7,6 +8,9 @@ import { StatusBadge } from '../components/StatusBadge'
|
|||
export function FormDetailPage() {
|
||||
const { formId = '' } = useParams<{ formId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canAdmin = Boolean(user?.canAdmin)
|
||||
const canView = Boolean(user?.canView)
|
||||
|
||||
const [form, setForm] = useState<Form | null>(null)
|
||||
const [submissions, setSubmissions] = useState<SubmissionSummary[]>([])
|
||||
|
|
@ -23,8 +27,8 @@ export function FormDetailPage() {
|
|||
try {
|
||||
const [f, subs, accessCodes] = await Promise.all([
|
||||
api.getForm(formId),
|
||||
api.listSubmissions(formId).catch(() => [] as SubmissionSummary[]),
|
||||
api.listAccessCodes(formId).catch(() => [] as AccessCode[]),
|
||||
canView ? api.listSubmissions(formId) : Promise.resolve([] as SubmissionSummary[]),
|
||||
canAdmin ? api.listAccessCodes(formId) : Promise.resolve([] as AccessCode[]),
|
||||
])
|
||||
setForm(f)
|
||||
setSubmissions(subs)
|
||||
|
|
@ -38,7 +42,7 @@ export function FormDetailPage() {
|
|||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [formId])
|
||||
}, [formId, canAdmin, canView])
|
||||
|
||||
async function toggleActive() {
|
||||
if (!form) return
|
||||
|
|
@ -161,18 +165,22 @@ export function FormDetailPage() {
|
|||
{form.description && <p className="text-slate-600 dark:text-slate-300">{form.description}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="btn-secondary" onClick={() => navigate(`/manage/forms/${form.id}/edit`)}>
|
||||
Edit
|
||||
</button>
|
||||
{canAdmin && (
|
||||
<>
|
||||
<button type="button" className="btn-secondary" onClick={() => navigate(`/manage/forms/${form.id}/edit`)}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={createVersion}>
|
||||
New version
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={toggleActive}>
|
||||
{form.isActive ? 'Inactivate' : 'Activate'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn-secondary" onClick={downloadPdf}>
|
||||
Download PDF
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={createVersion}>
|
||||
New version
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={toggleActive}>
|
||||
{form.isActive ? 'Inactivate' : 'Activate'}
|
||||
</button>
|
||||
<Link to={`/forms/${form.id}`} className="btn-primary">
|
||||
Preview form
|
||||
</Link>
|
||||
|
|
@ -223,7 +231,7 @@ export function FormDetailPage() {
|
|||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
{form.requiresAccessCode && (
|
||||
{form.requiresAccessCode && canAdmin && (
|
||||
<section className="card space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Access codes</h2>
|
||||
|
|
@ -302,9 +310,17 @@ export function FormDetailPage() {
|
|||
<section className="card space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Submissions</h2>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{submissions.length} total</span>
|
||||
{canView && (
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{submissions.length} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{submissions.length === 0 ? (
|
||||
{!canView ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
A view role is required to see submitted responses.
|
||||
</p>
|
||||
) : submissions.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No submissions yet.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
|
|
|
|||
|
|
@ -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<FormSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-8">
|
||||
|
|
@ -36,19 +47,26 @@ export function HomePage() {
|
|||
Pick a form below to share your feedback. Forms are versioned, so your previous answers are
|
||||
never shown again.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
|
||||
Manage forms
|
||||
</Link>
|
||||
<Link
|
||||
to="/manage/new"
|
||||
className="btn border border-white/40 text-white hover:bg-white/10"
|
||||
>
|
||||
Create a form
|
||||
</Link>
|
||||
</div>
|
||||
{(user?.canAdmin || user?.canView) && (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
|
||||
Manage forms
|
||||
</Link>
|
||||
{user?.canAdmin && (
|
||||
<Link
|
||||
to="/manage/new"
|
||||
className="btn border border-white/40 text-white hover:bg-white/10"
|
||||
>
|
||||
Create a form
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{submissionLocked ? (
|
||||
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
|
||||
) : (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Available forms</h2>
|
||||
|
|
@ -108,6 +126,7 @@ export function HomePage() {
|
|||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { safeReturnTo } from '../lib/oidc'
|
||||
|
||||
export function LoginPage() {
|
||||
const { config, user, error: configError, loginWithPassword, loginWithOidc } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const state = location.state as { from?: string } | null
|
||||
const query = new URLSearchParams(location.search)
|
||||
const returnTo = safeReturnTo(state?.from ?? query.get('returnTo'))
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function signInWithPassword(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await loginWithPassword(password)
|
||||
navigate(returnTo, { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Could not sign in')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithOidc() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await loginWithOidc(returnTo)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not start sign-in')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="card mx-auto max-w-lg space-y-3 text-center">
|
||||
<h1 className="text-2xl font-bold">You are signed in</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
Signed in as {user.name ?? user.email ?? user.id}.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(user.canAdmin || user.canView) && (
|
||||
<Link to="/manage" className="btn-primary">
|
||||
Go to management
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/" className="btn-secondary">
|
||||
Back to forms
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card mx-auto max-w-lg space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Management access requires an admin or view role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(error || configError) && (
|
||||
<div className="rounded-lg border border-red-300 p-3 text-sm text-red-700 dark:border-red-900 dark:text-red-300">
|
||||
{error ?? configError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!config && <p className="text-slate-500 dark:text-slate-400">Loading sign-in options…</p>}
|
||||
|
||||
{config?.mode === 'oidc' && config.oidc && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Continue with your organisation account.
|
||||
</p>
|
||||
<button type="button" className="btn-primary w-full" onClick={signInWithOidc} disabled={busy}>
|
||||
{busy ? 'Redirecting…' : 'Sign in with OAuth'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.mode === 'password' && (
|
||||
<form onSubmit={signInWithPassword} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Local password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={busy || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Local sign-in grants both admin and view roles and is intended for development or
|
||||
testing when no OIDC provider is configured.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{config?.mode === 'oidc' && !config.oidc && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
OIDC is enabled but the provider configuration is incomplete.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<FormSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -90,12 +94,16 @@ export function ManagePage() {
|
|||
<div>
|
||||
<h1 className="text-2xl font-bold">Management</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
Create, version, activate and review forms and submissions.
|
||||
{canAdmin
|
||||
? 'Create, version, activate and review forms and submissions.'
|
||||
: 'View forms and review submissions.'}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/manage/new" className="btn-primary">
|
||||
Create form
|
||||
</Link>
|
||||
{canAdmin && (
|
||||
<Link to="/manage/new" className="btn-primary">
|
||||
Create form
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
|
|
@ -110,11 +118,15 @@ export function ManagePage() {
|
|||
<div className="card text-center">
|
||||
<p className="text-lg font-semibold">No forms yet</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Create your first form to start collecting responses.
|
||||
{canAdmin
|
||||
? 'Create your first form to start collecting responses.'
|
||||
: 'No forms have been created yet.'}
|
||||
</p>
|
||||
<Link to="/manage/new" className="btn-primary mt-4">
|
||||
Create form
|
||||
</Link>
|
||||
{canAdmin && (
|
||||
<Link to="/manage/new" className="btn-primary mt-4">
|
||||
Create form
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
|
|
@ -161,7 +173,7 @@ export function ManagePage() {
|
|||
Preview
|
||||
</Link>
|
||||
<Link to={`/manage/forms/${form.id}`} className="btn-secondary py-1 text-xs">
|
||||
View / submissions
|
||||
{canView ? 'View / submissions' : 'Manage'}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -170,30 +182,34 @@ export function ManagePage() {
|
|||
>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary py-1 text-xs"
|
||||
disabled={busy === form.id}
|
||||
onClick={() => createVersion(form)}
|
||||
>
|
||||
New version
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary py-1 text-xs"
|
||||
disabled={busy === form.id}
|
||||
onClick={() => toggleActive(form)}
|
||||
>
|
||||
{form.isActive ? 'Inactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger py-1 text-xs"
|
||||
disabled={busy === form.id}
|
||||
onClick={() => remove(form)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{canAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary py-1 text-xs"
|
||||
disabled={busy === form.id}
|
||||
onClick={() => createVersion(form)}
|
||||
>
|
||||
New version
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary py-1 text-xs"
|
||||
disabled={busy === form.id}
|
||||
onClick={() => toggleActive(form)}
|
||||
>
|
||||
{form.isActive ? 'Inactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger py-1 text-xs"
|
||||
disabled={busy === form.id}
|
||||
onClick={() => remove(form)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { completeOidcLogin } from '../lib/oidc'
|
||||
import { setAuthToken } from '../lib/session'
|
||||
|
||||
export function OidcCallbackPage() {
|
||||
const { loading, config, refresh } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || started.current) return
|
||||
started.current = true
|
||||
|
||||
if (!config?.oidc) {
|
||||
setError('OIDC is not configured.')
|
||||
return
|
||||
}
|
||||
|
||||
void completeOidcLogin(config.oidc, new URLSearchParams(window.location.search))
|
||||
.then(async ({ token, returnTo }) => {
|
||||
setAuthToken(token)
|
||||
await refresh()
|
||||
navigate(returnTo, { replace: true })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Could not complete sign-in')
|
||||
})
|
||||
}, [loading, config, refresh, navigate])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card mx-auto max-w-lg space-y-3 text-center">
|
||||
<h1 className="text-xl font-bold">Sign-in failed</h1>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<Link to="/login" className="btn-primary mx-auto w-fit">
|
||||
Try again
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card mx-auto max-w-lg text-center">
|
||||
<p className="text-slate-500 dark:text-slate-400">Completing sign-in…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue