Skip to content

Commit c0570be

Browse files
authored
Cleanup app tokens to get rid of lingering email requirement (#244)
1 parent 428402e commit c0570be

46 files changed

Lines changed: 378 additions & 1860 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This repo started as a fork of [DoneTick](https://github.com/donetick/donetick)
1212

1313
Task Wizard's primary goal is to allow users to own and protect their data and the following principles are ways to accomplish that:
1414

15+
* **Zero PII storage** — the server never stores names, emails, or any personally identifiable information. Authentication is handled entirely by Microsoft Entra ID; the backend only persists an opaque directory/object ID pair to associate tasks with a user
1516
* All the user data sent by this frontend only ever goes to a single backend
1617
* 🔜 When data is stored, it is encrypted with a user key
1718
* The code is continuously scanned by a CI that runs CodeQL
@@ -30,9 +31,7 @@ Task Wizard's primary goal is to allow users to own and protect their data and t
3031

3132
📧 Notifications for important deadlines you don't want to miss
3233

33-
🗝️ Fine-grained access tokens for endless integration possibilities
34-
35-
🌐 Authenticated CalDAV endpoint at `/dav/tasks` with app token as the password
34+
🌐 CalDAV endpoint at `/dav/tasks` with OAuth 2.0 Bearer token authentication
3635

3736
## ⌨️ Keyboard Shortcuts
3837

@@ -54,8 +53,6 @@ To set up authentication:
5453

5554
For development without Azure AD, set `entra.enabled` to `false` to enable dev bypass mode (all requests are treated as authenticated).
5655

57-
App tokens (used for CalDAV and API integrations) are independently signed with `jwt.secret` and remain functional regardless of Entra configuration.
58-
5956
## 🚀 Installation
6057

6158
### 🚢 Using Docker Compose (recommended)
@@ -96,7 +93,7 @@ Make sure to replace `/path/to/host` with your preferred root directory for conf
9693

9794
In the [config](./apiserver/config/) directory are a couple of starter configuration files for prod and dev environments. The server expects a config.yaml in the config directory and will load settings from it when started.
9895

99-
**Note:** You can set `email.host`, `email.port`, `email.email`, `email.password`, `jwt.secret`, Entra ID settings, and database credentials using environment variables for improved security and flexibility. The server will fail to start if `jwt.secret` is left as `"secret"`, so be sure to set `TW_JWT_SECRET` or edit `config.yaml`.
96+
**Note:** You can set Entra ID settings and database credentials using environment variables for improved security and flexibility.
10097

10198
### Database Configuration
10299

@@ -145,7 +142,6 @@ Configure Entra ID authentication with environment variables or `config.yaml`:
145142
- `TW_ENTRA_TENANT_ID` - Azure AD tenant ID
146143
- `TW_ENTRA_CLIENT_ID` - Azure AD application (client) ID
147144
- `TW_ENTRA_AUDIENCE` - Expected token audience
148-
- `TW_JWT_SECRET` - Secret for signing app tokens (must be changed from default)
149145

150146
### Configuration Reference
151147

@@ -166,7 +162,6 @@ The configuration files are yaml mappings with the following values:
166162
| `entra.tenant_id` | (empty) | The Azure AD tenant ID for authentication. |
167163
| `entra.client_id` | (empty) | The Azure AD application (client) ID. |
168164
| `entra.audience` | (empty) | The expected audience for Entra ID tokens. |
169-
| `jwt.secret` | `"secret"` | The secret key used for signing app tokens. **Must be changed from default or set `TW_JWT_SECRET`.** |
170165
| `server.host_name` | `localhost` | The hostname to use for external links. |
171166
| `server.port` | `2021` | The port on which the server listens. |
172167
| `server.read_timeout` | `2s` | The maximum duration for reading the entire request. |
@@ -181,12 +176,7 @@ The configuration files are yaml mappings with the following values:
181176
| `scheduler_jobs.due_frequency` | `5m` | The interval for sending regular notifications. |
182177
| `scheduler_jobs.overdue_frequency` | `24h` | The interval for sending overdue notifications. |
183178
| `scheduler_jobs.notification_cleanup` | `10m` | The interval for cleaning up sent notifications. |
184-
| `scheduler_jobs.token_expiration_cleanup`| `24h` | The interval for cleaning up expired tokens. |
185-
|`scheduler_jobs.token_expiration_reminder`| `72h` | How long before an app token expiration to send a reminder for it. |
186-
| `email.host` | (empty) | The email server host. |
187-
| `email.port` | (empty) | The email server port. |
188-
| `email.email` | (empty) | The email address used for sending emails. |
189-
| `email.password` | (empty) | The password for authenticating with the email server. |
179+
190180

191181
## 🛠️ Development
192182

apiserver/config/config.dev.yaml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ database:
22
type: sqlite
33
migration: true
44
path: task-wizard.db
5-
jwt:
6-
secret: "s3cret"
7-
session_time: 168h
8-
max_refresh: 168h
95
server:
106
host_name: localhost
117
port: 2021
@@ -22,12 +18,4 @@ server:
2218
scheduler_jobs:
2319
due_frequency: 1m
2420
overdue_frequency: 1m
25-
password_reset_validity: 1m
26-
token_expiration_reminder: 1m
2721
notification_cleanup: 10s
28-
token_expiration_cleanup: 1h
29-
email:
30-
host:
31-
port:
32-
email:
33-
password:

apiserver/config/config.go

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ import (
1010
type Config struct {
1111
Database DatabaseConfig `mapstructure:"database" yaml:"database"`
1212
Entra EntraConfig `mapstructure:"entra" yaml:"entra"`
13-
Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"`
1413
Server ServerConfig `mapstructure:"server" yaml:"server"`
1514
SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"`
16-
EmailConfig EmailConfig `mapstructure:"email" yaml:"email"`
1715
}
1816

1917
type DatabaseConfig struct {
@@ -35,10 +33,6 @@ type EntraConfig struct {
3533
Issuer string `mapstructure:"issuer" yaml:"issuer"`
3634
}
3735

38-
type JwtConfig struct {
39-
Secret string `mapstructure:"secret" yaml:"secret"`
40-
}
41-
4236
type ServerConfig struct {
4337
HostName string `mapstructure:"host_name" yaml:"host_name"`
4438
Port int `mapstructure:"port" yaml:"port"`
@@ -54,18 +48,9 @@ type ServerConfig struct {
5448
}
5549

5650
type SchedulerConfig struct {
57-
DueFrequency time.Duration `mapstructure:"due_frequency" yaml:"due_frequency" default:"5m"`
58-
OverdueFrequency time.Duration `mapstructure:"overdue_frequency" yaml:"overdue_frequency" default:"1d"`
59-
TokenExpirationReminder time.Duration `mapstructure:"token_expiration_reminder" yaml:"token_expiration_reminder" default:"72h"`
60-
NotificationCleanup time.Duration `mapstructure:"notification_cleanup" yaml:"notification_cleanup" default:"10m"`
61-
TokenExpirationCleanup time.Duration `mapstructure:"token_expiration_cleanup" yaml:"token_expiration_cleanup" default:"24h"`
62-
}
63-
64-
type EmailConfig struct {
65-
Host string `mapstructure:"host"`
66-
Port int `mapstructure:"port"`
67-
Email string `mapstructure:"email"`
68-
Password string `mapstructure:"password"`
51+
DueFrequency time.Duration `mapstructure:"due_frequency" yaml:"due_frequency" default:"5m"`
52+
OverdueFrequency time.Duration `mapstructure:"overdue_frequency" yaml:"overdue_frequency" default:"1d"`
53+
NotificationCleanup time.Duration `mapstructure:"notification_cleanup" yaml:"notification_cleanup" default:"10m"`
6954
}
7055

7156
func LoadConfig(configFile string) *Config {
@@ -90,11 +75,6 @@ func LoadConfig(configFile string) *Config {
9075
_ = viper.BindEnv("entra.client_id", "TW_ENTRA_CLIENT_ID")
9176
_ = viper.BindEnv("entra.audience", "TW_ENTRA_AUDIENCE")
9277
_ = viper.BindEnv("entra.issuer", "TW_ENTRA_ISSUER")
93-
_ = viper.BindEnv("jwt.secret", "TW_JWT_SECRET")
94-
_ = viper.BindEnv("email.host", "TW_EMAIL_HOST")
95-
_ = viper.BindEnv("email.port", "TW_EMAIL_PORT")
96-
_ = viper.BindEnv("email.email", "TW_EMAIL_SENDER")
97-
_ = viper.BindEnv("email.password", "TW_EMAIL_PASSWORD")
9878
_ = viper.BindEnv("database.type", "TW_DATABASE_TYPE")
9979
_ = viper.BindEnv("database.host", "TW_DATABASE_HOST")
10080
_ = viper.BindEnv("database.port", "TW_DATABASE_PORT")
@@ -113,9 +93,5 @@ func LoadConfig(configFile string) *Config {
11393
panic(err)
11494
}
11595

116-
if config.Jwt.Secret == "secret" {
117-
panic("JWT secret must be changed from the default 'secret'. Set TW_JWT_SECRET or update config.yaml")
118-
}
119-
12096
return &config
12197
}

apiserver/config/config.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ entra:
88
client_id: ""
99
audience: ""
1010
issuer: ""
11-
jwt:
12-
secret: "secret"
1311
server:
1412
host_name: example.com
1513
port: 2021
@@ -23,6 +21,4 @@ server:
2321
scheduler_jobs:
2422
due_frequency: 5m
2523
overdue_frequency: 24h
26-
token_expiration_reminder: 72h
2724
notification_cleanup: 10m
28-
token_expiration_cleanup: 24h

apiserver/config/config_test.go

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,9 @@ entra:
3232
tenant_id: test-tenant
3333
client_id: test-client
3434
audience: api://test-client
35-
jwt:
36-
secret: test-app-secret
3735
scheduler_jobs:
3836
due_frequency: 5m
3937
overdue_frequency: 24h
40-
token_expiration_reminder: 72h
4138
`)
4239

4340
assert.NoError(t, err)
@@ -60,11 +57,8 @@ scheduler_jobs:
6057
assert.Equal(t, "test-client", cfg.Entra.ClientID)
6158
assert.Equal(t, "api://test-client", cfg.Entra.Audience)
6259

63-
assert.Equal(t, "test-app-secret", cfg.Jwt.Secret)
64-
6560
assert.Equal(t, 5*time.Minute, cfg.SchedulerJobs.DueFrequency)
6661
assert.Equal(t, 24*time.Hour, cfg.SchedulerJobs.OverdueFrequency)
67-
assert.Equal(t, 72*time.Hour, cfg.SchedulerJobs.TokenExpirationReminder)
6862
}
6963

7064
func TestLoadConfig_PanicOnMissingFile(t *testing.T) {
@@ -90,22 +84,6 @@ func TestLoadConfig_PanicOnMissingFile(t *testing.T) {
9084
})
9185
}
9286

93-
func TestLoadConfig_PanicOnDefaultSecret(t *testing.T) {
94-
_ = os.MkdirAll("./config", 0755)
95-
f, err := os.Create("./config/config.yaml")
96-
assert.NoError(t, err)
97-
defer os.Remove("./config/config.yaml")
98-
defer f.Close()
99-
100-
_, err = f.WriteString("server:\n port: 1234\njwt:\n secret: secret\n")
101-
assert.NoError(t, err)
102-
103-
viper.Reset()
104-
assert.Panics(t, func() {
105-
_ = LoadConfig("./config/config.yaml")
106-
})
107-
}
108-
10987
func TestLoadConfig_EntraEnvOverride(t *testing.T) {
11088
_ = os.MkdirAll("./config", 0755)
11189
f, err := os.Create("./config/config.yaml")
@@ -118,8 +96,6 @@ func TestLoadConfig_EntraEnvOverride(t *testing.T) {
11896
tenant_id: file-tenant
11997
client_id: file-client
12098
audience: api://file-client
121-
jwt:
122-
secret: file-secret
12399
server:
124100
port: 1234
125101
`)
@@ -129,7 +105,6 @@ server:
129105
os.Setenv("TW_ENTRA_TENANT_ID", "env-tenant")
130106
os.Setenv("TW_ENTRA_CLIENT_ID", "env-client")
131107
os.Setenv("TW_ENTRA_AUDIENCE", "api://env-client")
132-
os.Setenv("TW_JWT_SECRET", "env-secret")
133108

134109
viper.Reset()
135110
cfg := LoadConfig("./config/config.yaml")
@@ -138,13 +113,11 @@ server:
138113
assert.Equal(t, "env-tenant", cfg.Entra.TenantID)
139114
assert.Equal(t, "env-client", cfg.Entra.ClientID)
140115
assert.Equal(t, "api://env-client", cfg.Entra.Audience)
141-
assert.Equal(t, "env-secret", cfg.Jwt.Secret)
142116

143117
os.Unsetenv("TW_ENTRA_ENABLED")
144118
os.Unsetenv("TW_ENTRA_TENANT_ID")
145119
os.Unsetenv("TW_ENTRA_CLIENT_ID")
146120
os.Unsetenv("TW_ENTRA_AUDIENCE")
147-
os.Unsetenv("TW_JWT_SECRET")
148121
}
149122

150123
func TestLoadConfig_EnvFile(t *testing.T) {
@@ -153,7 +126,7 @@ func TestLoadConfig_EnvFile(t *testing.T) {
153126
defer os.Remove("envconfig.yaml")
154127
defer f.Close()
155128

156-
_, err = f.WriteString("server:\n port: 4444\njwt:\n secret: test-secret\n")
129+
_, err = f.WriteString("server:\n port: 4444\n")
157130
assert.NoError(t, err)
158131

159132
os.Setenv("TW_CONFIG_FILE", "envconfig.yaml")
@@ -168,14 +141,14 @@ func TestLoadConfig_CLIOverridesEnv(t *testing.T) {
168141
assert.NoError(t, err)
169142
defer os.Remove("env.yaml")
170143
defer f1.Close()
171-
_, err = f1.WriteString("server:\n port: 3333\njwt:\n secret: test-secret\n")
144+
_, err = f1.WriteString("server:\n port: 3333\n")
172145
assert.NoError(t, err)
173146

174147
f2, err := os.Create("cli.yaml")
175148
assert.NoError(t, err)
176149
defer os.Remove("cli.yaml")
177150
defer f2.Close()
178-
_, err = f2.WriteString("server:\n port: 2222\njwt:\n secret: test-secret\n")
151+
_, err = f2.WriteString("server:\n port: 2222\n")
179152
assert.NoError(t, err)
180153

181154
os.Setenv("TW_CONFIG_FILE", "env.yaml")
@@ -197,8 +170,6 @@ func TestLoadConfig_DatabaseEnvOverride(t *testing.T) {
197170
path: /config/task-wizard.db
198171
server:
199172
port: 1234
200-
jwt:
201-
secret: test-secret
202173
`)
203174
assert.NoError(t, err)
204175

@@ -244,8 +215,6 @@ func TestLoadConfig_MySQLConfig(t *testing.T) {
244215
migration: true
245216
server:
246217
port: 1234
247-
jwt:
248-
secret: test-secret
249218
`)
250219
assert.NoError(t, err)
251220

apiserver/go.mod

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ require (
1818
require (
1919
github.com/coreos/go-oidc/v3 v3.17.0
2020
github.com/stretchr/testify v1.10.0
21-
github.com/wneessen/go-mail v0.7.2
2221
gorm.io/driver/mysql v1.6.0
2322
)
2423

@@ -47,7 +46,6 @@ require (
4746
github.com/go-playground/universal-translator v0.18.1 // indirect
4847
github.com/go-playground/validator/v10 v10.20.0 // indirect
4948
github.com/goccy/go-json v0.10.3 // indirect
50-
github.com/golang-jwt/jwt/v4 v4.5.2
5149
github.com/google/uuid v1.6.0 // indirect
5250
github.com/hashicorp/hcl v1.0.0 // indirect
5351
github.com/jinzhu/inflection v1.0.0 // indirect

apiserver/go.sum

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
4646
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
4747
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
4848
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
49-
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
50-
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
5149
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
5250
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5351
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -134,8 +132,6 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
134132
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
135133
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
136134
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
137-
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
138-
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
139135
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
140136
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
141137
go.uber.org/fx v1.22.0 h1:pApUK7yL0OUHMd8vkunWSlLxZVFFk70jR2nKde8X2NM=

apiserver/internal/apis/caldav.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strconv"
1111
"strings"
1212

13+
"dkhalife.com/tasks/core/config"
1314
authMW "dkhalife.com/tasks/core/internal/middleware/auth"
1415
models "dkhalife.com/tasks/core/internal/models"
1516
cRepo "dkhalife.com/tasks/core/internal/repos/caldav"
@@ -18,7 +19,6 @@ import (
1819
"dkhalife.com/tasks/core/internal/services/logging"
1920
"dkhalife.com/tasks/core/internal/utils/auth"
2021
"dkhalife.com/tasks/core/internal/utils/caldav"
21-
middleware "dkhalife.com/tasks/core/internal/utils/middleware"
2222
"dkhalife.com/tasks/core/internal/ws"
2323
"github.com/gin-gonic/gin"
2424
"go.uber.org/zap"
@@ -29,14 +29,16 @@ type CalDAVAPIHandler struct {
2929
cRepo *cRepo.CalDavRepository
3030
nRepo *nRepo.NotificationRepository
3131
ws *ws.WSServer
32+
cfg *config.Config
3233
}
3334

34-
func CalDAVAPI(tRepo *tRepo.TaskRepository, cRepo *cRepo.CalDavRepository, nRepo *nRepo.NotificationRepository, wsServer *ws.WSServer) *CalDAVAPIHandler {
35+
func CalDAVAPI(tRepo *tRepo.TaskRepository, cRepo *cRepo.CalDavRepository, nRepo *nRepo.NotificationRepository, wsServer *ws.WSServer, cfg *config.Config) *CalDAVAPIHandler {
3536
return &CalDAVAPIHandler{
3637
tRepo: tRepo,
3738
cRepo: cRepo,
3839
nRepo: nRepo,
3940
ws: wsServer,
41+
cfg: cfg,
4042
}
4143
}
4244

@@ -321,9 +323,31 @@ func (h *CalDAVAPIHandler) handleRootRedirect(c *gin.Context) {
321323
}
322324
}
323325

326+
func (h *CalDAVAPIHandler) handleOAuthDiscovery(c *gin.Context) {
327+
if !h.cfg.Entra.Enabled {
328+
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth is not configured"})
329+
return
330+
}
331+
332+
base := "https://login.microsoftonline.com/" + h.cfg.Entra.TenantID + "/oauth2/v2.0"
333+
c.JSON(http.StatusOK, gin.H{
334+
"authorization_endpoint": base + "/authorize",
335+
"token_endpoint": base + "/token",
336+
"client_id": h.cfg.Entra.ClientID,
337+
"scopes": []string{
338+
string(models.ApiTokenScopeDavRead),
339+
string(models.ApiTokenScopeDavWrite),
340+
},
341+
})
342+
}
343+
324344
func CalDAVRoutes(router *gin.Engine, h *CalDAVAPIHandler, auth *authMW.AuthMiddleware) {
345+
if !h.cfg.Entra.Enabled {
346+
return
347+
}
348+
325349
davRoutes := router.Group("dav")
326-
davRoutes.Use(middleware.BasicAuthToJWTAdapter())
350+
davRoutes.GET("/.well-known/oauth", h.handleOAuthDiscovery)
327351
davRoutes.Use(auth.MiddlewareFunc())
328352
{
329353
davRoutes.HEAD("/tasks/*path", authMW.ScopeMiddleware(models.ApiTokenScopeDavRead), h.handleHead)

0 commit comments

Comments
 (0)