Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Make sure to replace `/path/to/host` with your preferred root directory for conf

In the [config](./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.

**Note:** You can set `email.host`, `email.port`, `email.email`, `email.password` and `jwt.secret` using environment variables `TW_EMAIL_HOST`, `TW_EMAIL_PORT`, `TW_EMAIL_SENDER`, `TW_EMAIL_PASSWORD` and `TW_JWT_SECRET` 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`.
**Note:** You can set `email.host`, `email.port`, `email.email`, `email.password`, `jwt.secret`, and OAuth configuration 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`.

The configuration files are yaml mappings with the following values:

Expand All @@ -94,6 +94,15 @@ The configuration files are yaml mappings with the following values:
| `jwt.secret` | `"secret"` | The secret key used for signing JWT tokens. **Make sure to change that or set `TW_JWT_SECRET`.** |
| `jwt.session_time` | `168h` | The duration for which a JWT session is valid. |
| `jwt.max_refresh` | `168h` | The maximum duration for refreshing a JWT session. |
| `oauth.enabled` | `false` | Enable OAuth2 authentication. Can be set via `TW_OAUTH_ENABLED`. |
| `oauth.client_id` | (empty) | OAuth2 client ID. Can be set via `TW_OAUTH_CLIENT_ID`. |
| `oauth.client_secret` | (empty) | OAuth2 client secret. Can be set via `TW_OAUTH_CLIENT_SECRET`. |
| `oauth.tenant_id` | (empty) | OAuth2 tenant ID (for Azure Entra). Can be set via `TW_OAUTH_TENANT_ID`. |
| `oauth.authorize_url` | (empty) | OAuth2 authorization endpoint URL. Can be set via `TW_OAUTH_AUTHORIZE_URL`.|
| `oauth.token_url` | (empty) | OAuth2 token endpoint URL. Can be set via `TW_OAUTH_TOKEN_URL`. |
| `oauth.redirect_url` | (empty) | OAuth2 redirect URI. Can be set via `TW_OAUTH_REDIRECT_URL`. |
| `oauth.scope` | (empty) | OAuth2 scope (e.g., `Tasks.ReadWrite`). Can be set via `TW_OAUTH_SCOPE`. |
| `oauth.jwks_url` | (empty) | OAuth2 JWKS URL for token validation. Can be set via `TW_OAUTH_JWKS_URL`. |
| `server.host_name` | `localhost` | The hostname to use for external links. |
| `server.port` | `2021` | The port on which the server listens. |
| `server.read_timeout` | `2s` | The maximum duration for reading the entire request. |
Expand All @@ -116,6 +125,72 @@ The configuration files are yaml mappings with the following values:
| `email.email` | (empty) | The email address used for sending emails. |
| `email.password` | (empty) | The password for authenticating with the email server. |

### 🔐 OAuth2 Configuration (Azure Entra ID Example)

Task Wizard supports OAuth2 authentication as an alternative to username/password authentication. This is particularly useful when integrating with identity providers like Azure Entra ID (formerly Azure AD).

#### Backend Configuration

1. Register two applications in your identity provider:
- **Backend API App**: This will validate tokens and define scopes (e.g., `Tasks.ReadWrite`)
- **Frontend Client App**: This will initiate the OAuth flow

2. Configure the backend via `config.yaml` or environment variables:

```yaml
oauth:
enabled: true
client_id: "your-backend-api-client-id"
client_secret: "your-backend-api-client-secret"
tenant_id: "your-tenant-id" # For Azure Entra ID
authorize_url: "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize"
token_url: "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token"
redirect_url: "https://your-domain.com/oauth/callback"
scope: "api://your-backend-api-client-id/Tasks.ReadWrite"
jwks_url: "https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys"
```

Or via environment variables:
```bash
TW_OAUTH_ENABLED=true
TW_OAUTH_CLIENT_ID=your-backend-api-client-id
TW_OAUTH_CLIENT_SECRET=your-backend-api-client-secret
TW_OAUTH_TENANT_ID=your-tenant-id
TW_OAUTH_AUTHORIZE_URL=https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
TW_OAUTH_TOKEN_URL=https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
TW_OAUTH_REDIRECT_URL=https://your-domain.com/oauth/callback
TW_OAUTH_SCOPE=api://your-backend-api-client-id/Tasks.ReadWrite
TW_OAUTH_JWKS_URL=https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys
```

#### Frontend Configuration

Configure the frontend by setting environment variables during the build:

```bash
VITE_OAUTH_ENABLED=true
VITE_OAUTH_CLIENT_ID=your-frontend-client-id
VITE_OAUTH_AUTHORITY=https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
VITE_OAUTH_SCOPE=api://your-backend-api-client-id/Tasks.ReadWrite
VITE_OAUTH_REDIRECT_URI=https://your-domain.com/oauth/callback
```

#### Enabling OAuth in the UI

1. Navigate to Settings > Feature Flags
2. Enable the "Use OAuth 2.0 authentication" feature flag
3. The login page will now show an "Sign in with OAuth" button

**Note:** OAuth authentication and traditional username/password authentication can coexist. The feature flag controls which method is displayed to users.

#### Security Considerations

- **HTTPS Required**: OAuth must be used over HTTPS in production to protect tokens in transit
- **Token Storage**: JWT tokens are stored in browser localStorage for session management. This is standard practice for SPA authentication, but means tokens are accessible to JavaScript code. Ensure your application is protected from XSS attacks.
- **State Parameter**: OAuth state parameter is validated to prevent CSRF attacks
- **Token Expiration**: Configure appropriate token expiration times in your OAuth provider
- **Scope Restrictions**: Use minimal required scopes (e.g., `Tasks.ReadWrite`) to follow principle of least privilege

## 🛠️ Development

A [devcontainer](./.devcontainer/devcontainer.json) configuration is set up in this repo to help jumpstart development with all the required dependencies available for both the frontend and backend. You can use this configuration alongside
Expand Down
22 changes: 22 additions & 0 deletions apiserver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
type Config struct {
Database DatabaseConfig `mapstructure:"database" yaml:"database"`
Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"`
OAuth OAuthConfig `mapstructure:"oauth" yaml:"oauth"`
Server ServerConfig `mapstructure:"server" yaml:"server"`
SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"`
EmailConfig EmailConfig `mapstructure:"email" yaml:"email"`
Expand All @@ -26,6 +27,18 @@ type JwtConfig struct {
MaxRefresh time.Duration `mapstructure:"max_refresh" yaml:"max_refresh"`
}

type OAuthConfig struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
ClientID string `mapstructure:"client_id" yaml:"client_id"`
ClientSecret string `mapstructure:"client_secret" yaml:"client_secret"`
TenantID string `mapstructure:"tenant_id" yaml:"tenant_id"`
AuthorizeURL string `mapstructure:"authorize_url" yaml:"authorize_url"`
TokenURL string `mapstructure:"token_url" yaml:"token_url"`
RedirectURL string `mapstructure:"redirect_url" yaml:"redirect_url"`
Scope string `mapstructure:"scope" yaml:"scope"`
JwksURL string `mapstructure:"jwks_url" yaml:"jwks_url"`
}

type ServerConfig struct {
HostName string `mapstructure:"host_name" yaml:"host_name"`
Port int `mapstructure:"port" yaml:"port"`
Expand Down Expand Up @@ -75,6 +88,15 @@ func LoadConfig(configFile string) *Config {

// Allow values with secrets to be set via environment variables
_ = viper.BindEnv("jwt.secret", "TW_JWT_SECRET")
_ = viper.BindEnv("oauth.enabled", "TW_OAUTH_ENABLED")
_ = viper.BindEnv("oauth.client_id", "TW_OAUTH_CLIENT_ID")
_ = viper.BindEnv("oauth.client_secret", "TW_OAUTH_CLIENT_SECRET")
_ = viper.BindEnv("oauth.tenant_id", "TW_OAUTH_TENANT_ID")
_ = viper.BindEnv("oauth.authorize_url", "TW_OAUTH_AUTHORIZE_URL")
_ = viper.BindEnv("oauth.token_url", "TW_OAUTH_TOKEN_URL")
_ = viper.BindEnv("oauth.redirect_url", "TW_OAUTH_REDIRECT_URL")
_ = viper.BindEnv("oauth.scope", "TW_OAUTH_SCOPE")
_ = viper.BindEnv("oauth.jwks_url", "TW_OAUTH_JWKS_URL")
_ = viper.BindEnv("email.host", "TW_EMAIL_HOST")
_ = viper.BindEnv("email.port", "TW_EMAIL_PORT")
_ = viper.BindEnv("email.email", "TW_EMAIL_SENDER")
Expand Down
58 changes: 58 additions & 0 deletions apiserver/config/config.oauth-example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Example OAuth configuration for Azure Entra ID (formerly Azure AD)
# Copy this file to config.yaml and update the values

database:
migration: true
path: /config/task-wizard.db

jwt:
secret: "CHANGEME-REPLACE-WITH-SECURE-SECRET" # IMPORTANT: Change this! Or set TW_JWT_SECRET env var
session_time: 168h # 7 days
max_refresh: 168h # 7 days

oauth:
enabled: true
# Backend API application client ID (the one with exposed scopes)
client_id: "your-backend-api-client-id"
# Backend API application client secret
client_secret: "your-backend-api-client-secret"
# Azure tenant ID
tenant_id: "your-tenant-id"
# Azure OAuth2 authorization endpoint
authorize_url: "https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/authorize"
# Azure OAuth2 token endpoint
token_url: "https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/token"
# Redirect URL - must match what's configured in Azure app registration
redirect_url: "https://your-domain.com/oauth/callback"
# Scope - format is api://backend-client-id/scope-name
scope: "api://your-backend-api-client-id/Tasks.ReadWrite"
# JWKS URL for token validation
jwks_url: "https://login.microsoftonline.com/your-tenant-id/discovery/v2.0/keys"

server:
host_name: your-domain.com
port: 2021
read_timeout: 2s
write_timeout: 1s
rate_period: 60s
rate_limit: 300
serve_frontend: true
registration: false # Typically false when using OAuth - users are managed through the OAuth provider
log_level: "info"
allowed_origins:
- "https://your-domain.com"
allow_cors_credentials: true

scheduler_jobs:
due_frequency: 5m
overdue_frequency: 24h
password_reset_validity: 24h
token_expiration_reminder: 72h
notification_cleanup: 10m
token_expiration_cleanup: 24h

email:
host: ""
port: 0
email: ""
password: ""
10 changes: 10 additions & 0 deletions apiserver/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ jwt:
secret: "secret"
session_time: 168h
max_refresh: 168h
oauth:
enabled: false
client_id: ""
client_secret: ""
tenant_id: ""
authorize_url: ""
token_url: ""
redirect_url: ""
scope: ""
jwks_url: ""
server:
host_name: example.com
port: 2021
Expand Down
3 changes: 3 additions & 0 deletions apiserver/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ require (
)

require (
github.com/coreos/go-oidc/v3 v3.16.0
github.com/stretchr/testify v1.10.0
github.com/wneessen/go-mail v0.7.1
golang.org/x/oauth2 v0.32.0
gorm.io/driver/sqlite v1.5.7
)

require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
Expand Down
6 changes: 6 additions & 0 deletions apiserver/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand All @@ -32,6 +34,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
Expand Down Expand Up @@ -163,6 +167,8 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
Loading
Loading