Skip to content
Merged
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
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,106 @@ Please see our [security policy](.github/SECURITY.md) for details about reportin
[issue-1065]: https://github.com/dexidp/dex/issues/1065
[release-notes]: https://github.com/dexidp/dex/releases

## Custom Signup Feature (Fork Addition)

This fork includes a custom user signup feature that allows users to register with email and password.

### Configuration

Enable the signup feature in your `config.yaml`:

```yaml
enablePasswordDB: true # Required
enableSignup: true # Enables signup
```

### API Endpoint

**Create a new user:**
```bash
curl -X POST http://127.0.0.1:5556/dex/signup \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"username": "username",
"password": "password123"
}'
```

**Response:**
```json
{
"user_id": "generated-id",
"email": "[email protected]",
"username": "username",
"message": "User created successfully"
}
```

### UI Access

- Signup page: `http://127.0.0.1:5556/dex/signup`
- Signup links automatically appear on login pages when enabled

### Validation Rules

- Email: Required, valid format
- Password: Minimum 8 characters
- Username: Required
- Duplicate emails rejected with 409 Conflict

### Security

- Passwords hashed with bcrypt (cost 10)
- Emails stored in lowercase
- Request body size limit (1MB)
- Input validation and sanitization

### Code Organization

The signup feature is modularized to minimize merge conflicts with upstream:

**New Files:**
- `server/handlers_signup.go` - Complete signup implementation
- `web/templates/signup.html` - Signup form template

**Modified Files (minimal changes):**
- `cmd/dex/config.go` - Config field + validation
- `server/server.go` - Field + route registration
- `server/templates.go` - Template rendering
- `web/templates/login.html` & `password.html` - Conditional signup links

### Testing

```bash
# Run signup tests
go test ./server -run TestHandleSignup -v

# Build and start
go build -o bin/dex ./cmd/dex
./bin/dex serve config.dev.yaml

# Test API
curl -X POST http://127.0.0.1:5556/dex/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","username":"test","password":"password123"}'

# Verify in database
sqlite3 var/sqlite/dex.db "SELECT email, username FROM password;"
```

### Syncing with Upstream

When syncing with upstream dexidp/dex:

1. **No conflict files**: `handlers_signup.go`, `signup.html` (completely custom)
2. **Potential conflicts**: Config, server struct, templates
3. **Resolution**: Re-add the `enableSignup` field and route registration lines

The modular structure isolates ~350 lines of custom code with only ~45 lines of integration changes across 6 existing files.

---

## Development

When all coding and testing is done, please run the test suite:
Expand Down
5 changes: 5 additions & 0 deletions cmd/dex/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ type Config struct {
// database.
StaticPasswords []password `json:"staticPasswords"`

// If enabled, allows users to sign up with email and password via REST API.
// Requires EnablePasswordDB to be true.
EnableSignup bool `json:"enableSignup"`

HiddenConnectors []string `json:"hiddenConnectors"`
}

Expand All @@ -64,6 +68,7 @@ func (c Config) Validate() error {
}{
{c.Issuer == "", "no issuer specified in config file"},
{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
{c.EnableSignup && !c.EnablePasswordDB, "cannot enable signup without enabling password db"},
{c.Storage.Config == nil, "no storage supplied in config file"},
{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
Expand Down
1 change: 1 addition & 0 deletions cmd/dex/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ func runServe(options serveOptions) error {
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
PasswordConnector: c.OAuth2.PasswordConnector,
EnableSignup: c.EnableSignup,
Headers: c.Web.Headers.ToHTTPHeader(),
AllowedOrigins: c.Web.AllowedOrigins,
AllowedHeaders: c.Web.AllowedHeaders,
Expand Down
4 changes: 4 additions & 0 deletions config.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ connectors:

enablePasswordDB: true

# Enable user signup via REST API (POST /signup)
# Requires enablePasswordDB to be true
enableSignup: true

staticPasswords:
- email: "[email protected]"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
Expand Down
39 changes: 39 additions & 0 deletions config.test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
issuer: http://127.0.0.1:5556/dex

storage:
type: sqlite3
config:
file: var/sqlite/dex.db

web:
http: 127.0.0.1:5556

telemetry:
http: 127.0.0.1:5558

grpc:
addr: 127.0.0.1:5557

staticClients:
- id: example-app
redirectURIs:
- 'http://127.0.0.1:5555/callback'
name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0

connectors:
- type: mockCallback
id: mock
name: Example

enablePasswordDB: true

# Enable user signup via REST API (POST /signup)
# Requires enablePasswordDB to be true
enableSignup: false

staticPasswords:
- email: "[email protected]"
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
5 changes: 5 additions & 0 deletions config.yaml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ web:
# login credentials in Dex's store.
enablePasswordDB: true

# Enable user signup via REST API endpoint (POST /signup).
# This allows users to register with email and password.
# Requires enablePasswordDB to be true.
# enableSignup: false

# If this option isn't chosen users may be added through the gRPC API.
# A static list of passwords for the password connector.
#
Expand Down
177 changes: 3 additions & 174 deletions server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
}
}

if err := s.templates.login(r, w, connectorInfos); err != nil {
if err := s.templates.login(r, w, connectorInfos, s.enableSignup); err != nil {
s.logger.ErrorContext(r.Context(), "server template error", "err", err)
}
}
Expand Down Expand Up @@ -387,7 +387,7 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {

switch r.Method {
case http.MethodGet:
if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil {
if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, s.enableSignup); err != nil {
s.logger.ErrorContext(r.Context(), "server template error", "err", err)
}
case http.MethodPost:
Expand All @@ -402,7 +402,7 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !ok {
if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil {
if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, s.enableSignup); err != nil {
s.logger.ErrorContext(r.Context(), "server template error", "err", err)
}
s.logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username)
Expand Down Expand Up @@ -1539,174 +1539,3 @@ func usernamePrompt(conn connector.PasswordConnector) string {
}
return "Username"
}

// clientRegistrationRequest represents an RFC 7591 client registration request
type clientRegistrationRequest struct {
RedirectURIs []string `json:"redirect_uris"`
ClientName string `json:"client_name,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
}

// clientRegistrationResponse represents an RFC 7591 client registration response
type clientRegistrationResponse struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
ClientSecretExpiresAt int64 `json:"client_secret_expires_at"`
ClientName string `json:"client_name,omitempty"`
RedirectURIs []string `json:"redirect_uris"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"`
LogoURI string `json:"logo_uri,omitempty"`
}

// handleClientRegistration implements RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol
func (s *Server) handleClientRegistration(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// Only POST method is allowed
if r.Method != http.MethodPost {
s.registrationErrHelper(w, errInvalidRequest, "Method not allowed", http.StatusMethodNotAllowed)
return
}

// Check Initial Access Token if configured (RFC 7591 Section 3.1)
if s.registrationToken != "" {
authHeader := r.Header.Get("Authorization")
const bearerPrefix = "Bearer "

if authHeader == "" || !strings.HasPrefix(authHeader, bearerPrefix) {
w.Header().Set("WWW-Authenticate", "Bearer")
s.registrationErrHelper(w, errInvalidRequest, "Initial access token required", http.StatusUnauthorized)
return
}

providedToken := strings.TrimPrefix(authHeader, bearerPrefix)
if providedToken != s.registrationToken {
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
s.registrationErrHelper(w, errInvalidRequest, "Invalid initial access token", http.StatusUnauthorized)
return
}

s.logger.InfoContext(ctx, "client registration authenticated with initial access token")
} else {
s.logger.WarnContext(ctx, "client registration endpoint is open - no authentication required. Set registrationToken in config for production use.")
}

// Parse the request body
var req clientRegistrationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.logger.ErrorContext(ctx, "failed to parse registration request", "err", err)
s.registrationErrHelper(w, errInvalidRequest, "Invalid JSON request body", http.StatusBadRequest)
return
}

// Validate required fields
if len(req.RedirectURIs) == 0 {
s.registrationErrHelper(w, errInvalidRequest, "redirect_uris is required", http.StatusBadRequest)
return
}

// Apply default values
if req.TokenEndpointAuthMethod == "" {
req.TokenEndpointAuthMethod = "client_secret_basic"
}
if len(req.GrantTypes) == 0 {
req.GrantTypes = []string{grantTypeAuthorizationCode, grantTypeRefreshToken}
}
if len(req.ResponseTypes) == 0 {
req.ResponseTypes = []string{responseTypeCode}
}

// Validate token_endpoint_auth_method
if req.TokenEndpointAuthMethod != "client_secret_basic" && req.TokenEndpointAuthMethod != "client_secret_post" && req.TokenEndpointAuthMethod != "none" {
s.registrationErrHelper(w, errInvalidRequest, "Unsupported token_endpoint_auth_method", http.StatusBadRequest)
return
}

// Validate grant_types
for _, gt := range req.GrantTypes {
if !contains(s.supportedGrantTypes, gt) {
s.registrationErrHelper(w, errInvalidRequest, fmt.Sprintf("Unsupported grant_type: %s", gt), http.StatusBadRequest)
return
}
}

// Validate response_types
for _, rt := range req.ResponseTypes {
if !s.supportedResponseTypes[rt] {
s.registrationErrHelper(w, errInvalidRequest, fmt.Sprintf("Unsupported response_type: %s", rt), http.StatusBadRequest)
return
}
}

// Generate client_id and client_secret
// Following the same pattern as the gRPC API (api.go:CreateClient)
clientID := storage.NewID()

// Determine if this is a public client
isPublic := req.TokenEndpointAuthMethod == "none"

// Only generate secret for confidential clients
var clientSecret string
if !isPublic {
clientSecret = storage.NewID() + storage.NewID() // Double NewID for longer secret
}

// Create the client in storage
client := storage.Client{
ID: clientID,
Secret: clientSecret,
RedirectURIs: req.RedirectURIs,
Name: req.ClientName,
LogoURL: req.LogoURI,
Public: isPublic,
}

if err := s.storage.CreateClient(ctx, client); err != nil {
s.logger.ErrorContext(ctx, "failed to create client", "err", err)
if err == storage.ErrAlreadyExists {
s.registrationErrHelper(w, errInvalidRequest, "Client ID already exists", http.StatusBadRequest)
} else {
s.registrationErrHelper(w, errServerError, "Failed to register client", http.StatusInternalServerError)
}
return
}

// Build the response
resp := clientRegistrationResponse{
ClientID: clientID,
ClientSecret: clientSecret,
ClientSecretExpiresAt: 0, // 0 indicates the secret never expires
ClientName: req.ClientName,
RedirectURIs: req.RedirectURIs,
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
GrantTypes: req.GrantTypes,
ResponseTypes: req.ResponseTypes,
Scope: req.Scope,
LogoURI: req.LogoURI,
}

// For public clients, don't return the secret
if isPublic {
resp.ClientSecret = ""
}

// Return HTTP 201 Created
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.ErrorContext(ctx, "failed to encode registration response", "err", err)
}
}

func (s *Server) registrationErrHelper(w http.ResponseWriter, typ, description string, statusCode int) {
if err := tokenErr(w, typ, description, statusCode); err != nil {
s.logger.Error("registration error response", "err", err)
}
}
Loading
Loading