Skip to content

Commit 9f4ac4a

Browse files
oasiskByteBaker
authored andcommitted
feat: add signup to Dex
1 parent eceb368 commit 9f4ac4a

File tree

15 files changed

+868
-180
lines changed

15 files changed

+868
-180
lines changed

README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,106 @@ Please see our [security policy](.github/SECURITY.md) for details about reportin
127127
[issue-1065]: https://github.com/dexidp/dex/issues/1065
128128
[release-notes]: https://github.com/dexidp/dex/releases
129129

130+
## Custom Signup Feature (Fork Addition)
131+
132+
This fork includes a custom user signup feature that allows users to register with email and password.
133+
134+
### Configuration
135+
136+
Enable the signup feature in your `config.yaml`:
137+
138+
```yaml
139+
enablePasswordDB: true # Required
140+
enableSignup: true # Enables signup
141+
```
142+
143+
### API Endpoint
144+
145+
**Create a new user:**
146+
```bash
147+
curl -X POST http://127.0.0.1:5556/dex/signup \
148+
-H "Content-Type: application/json" \
149+
-d '{
150+
"email": "[email protected]",
151+
"username": "username",
152+
"password": "password123"
153+
}'
154+
```
155+
156+
**Response:**
157+
```json
158+
{
159+
"user_id": "generated-id",
160+
"email": "[email protected]",
161+
"username": "username",
162+
"message": "User created successfully"
163+
}
164+
```
165+
166+
### UI Access
167+
168+
- Signup page: `http://127.0.0.1:5556/dex/signup`
169+
- Signup links automatically appear on login pages when enabled
170+
171+
### Validation Rules
172+
173+
- Email: Required, valid format
174+
- Password: Minimum 8 characters
175+
- Username: Required
176+
- Duplicate emails rejected with 409 Conflict
177+
178+
### Security
179+
180+
- Passwords hashed with bcrypt (cost 10)
181+
- Emails stored in lowercase
182+
- Request body size limit (1MB)
183+
- Input validation and sanitization
184+
185+
### Code Organization
186+
187+
The signup feature is modularized to minimize merge conflicts with upstream:
188+
189+
**New Files:**
190+
- `server/handlers_signup.go` - Complete signup implementation
191+
- `web/templates/signup.html` - Signup form template
192+
193+
**Modified Files (minimal changes):**
194+
- `cmd/dex/config.go` - Config field + validation
195+
- `server/server.go` - Field + route registration
196+
- `server/templates.go` - Template rendering
197+
- `web/templates/login.html` & `password.html` - Conditional signup links
198+
199+
### Testing
200+
201+
```bash
202+
# Run signup tests
203+
go test ./server -run TestHandleSignup -v
204+
205+
# Build and start
206+
go build -o bin/dex ./cmd/dex
207+
./bin/dex serve config.dev.yaml
208+
209+
# Test API
210+
curl -X POST http://127.0.0.1:5556/dex/signup \
211+
-H "Content-Type: application/json" \
212+
-d '{"email":"[email protected]","username":"test","password":"password123"}'
213+
214+
# Verify in database
215+
sqlite3 var/sqlite/dex.db "SELECT email, username FROM password;"
216+
```
217+
218+
### Syncing with Upstream
219+
220+
When syncing with upstream dexidp/dex:
221+
222+
1. **No conflict files**: `handlers_signup.go`, `signup.html` (completely custom)
223+
2. **Potential conflicts**: Config, server struct, templates
224+
3. **Resolution**: Re-add the `enableSignup` field and route registration lines
225+
226+
The modular structure isolates ~350 lines of custom code with only ~45 lines of integration changes across 6 existing files.
227+
228+
---
229+
130230
## Development
131231

132232
When all coding and testing is done, please run the test suite:

cmd/dex/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ type Config struct {
5252
// database.
5353
StaticPasswords []password `json:"staticPasswords"`
5454

55+
// If enabled, allows users to sign up with email and password via REST API.
56+
// Requires EnablePasswordDB to be true.
57+
EnableSignup bool `json:"enableSignup"`
58+
5559
HiddenConnectors []string `json:"hiddenConnectors"`
5660
}
5761

@@ -64,6 +68,7 @@ func (c Config) Validate() error {
6468
}{
6569
{c.Issuer == "", "no issuer specified in config file"},
6670
{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
71+
{c.EnableSignup && !c.EnablePasswordDB, "cannot enable signup without enabling password db"},
6772
{c.Storage.Config == nil, "no storage supplied in config file"},
6873
{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS address to listen on"},
6974
{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},

cmd/dex/serve.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ func runServe(options serveOptions) error {
296296
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
297297
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
298298
PasswordConnector: c.OAuth2.PasswordConnector,
299+
EnableSignup: c.EnableSignup,
299300
Headers: c.Web.Headers.ToHTTPHeader(),
300301
AllowedOrigins: c.Web.AllowedOrigins,
301302
AllowedHeaders: c.Web.AllowedHeaders,

config.dev.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ connectors:
2828

2929
enablePasswordDB: true
3030

31+
# Enable user signup via REST API (POST /signup)
32+
# Requires enablePasswordDB to be true
33+
enableSignup: true
34+
3135
staticPasswords:
3236
- email: "[email protected]"
3337
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"

config.test.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
issuer: http://127.0.0.1:5556/dex
2+
3+
storage:
4+
type: sqlite3
5+
config:
6+
file: var/sqlite/dex.db
7+
8+
web:
9+
http: 127.0.0.1:5556
10+
11+
telemetry:
12+
http: 127.0.0.1:5558
13+
14+
grpc:
15+
addr: 127.0.0.1:5557
16+
17+
staticClients:
18+
- id: example-app
19+
redirectURIs:
20+
- 'http://127.0.0.1:5555/callback'
21+
name: 'Example App'
22+
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
23+
24+
connectors:
25+
- type: mockCallback
26+
id: mock
27+
name: Example
28+
29+
enablePasswordDB: true
30+
31+
# Enable user signup via REST API (POST /signup)
32+
# Requires enablePasswordDB to be true
33+
enableSignup: false
34+
35+
staticPasswords:
36+
- email: "[email protected]"
37+
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
38+
username: "admin"
39+
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"

config.yaml.dist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ web:
131131
# login credentials in Dex's store.
132132
enablePasswordDB: true
133133

134+
# Enable user signup via REST API endpoint (POST /signup).
135+
# This allows users to register with email and password.
136+
# Requires enablePasswordDB to be true.
137+
# enableSignup: false
138+
134139
# If this option isn't chosen users may be added through the gRPC API.
135140
# A static list of passwords for the password connector.
136141
#

server/handlers.go

Lines changed: 3 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
214214
}
215215
}
216216

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

388388
switch r.Method {
389389
case http.MethodGet:
390-
if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil {
390+
if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, s.enableSignup); err != nil {
391391
s.logger.ErrorContext(r.Context(), "server template error", "err", err)
392392
}
393393
case http.MethodPost:
@@ -402,7 +402,7 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
402402
return
403403
}
404404
if !ok {
405-
if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil {
405+
if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, s.enableSignup); err != nil {
406406
s.logger.ErrorContext(r.Context(), "server template error", "err", err)
407407
}
408408
s.logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username)
@@ -1540,173 +1540,3 @@ func usernamePrompt(conn connector.PasswordConnector) string {
15401540
return "Username"
15411541
}
15421542

1543-
// clientRegistrationRequest represents an RFC 7591 client registration request
1544-
type clientRegistrationRequest struct {
1545-
RedirectURIs []string `json:"redirect_uris"`
1546-
ClientName string `json:"client_name,omitempty"`
1547-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
1548-
GrantTypes []string `json:"grant_types,omitempty"`
1549-
ResponseTypes []string `json:"response_types,omitempty"`
1550-
Scope string `json:"scope,omitempty"`
1551-
LogoURI string `json:"logo_uri,omitempty"`
1552-
}
1553-
1554-
// clientRegistrationResponse represents an RFC 7591 client registration response
1555-
type clientRegistrationResponse struct {
1556-
ClientID string `json:"client_id"`
1557-
ClientSecret string `json:"client_secret,omitempty"`
1558-
ClientSecretExpiresAt int64 `json:"client_secret_expires_at"`
1559-
ClientName string `json:"client_name,omitempty"`
1560-
RedirectURIs []string `json:"redirect_uris"`
1561-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
1562-
GrantTypes []string `json:"grant_types,omitempty"`
1563-
ResponseTypes []string `json:"response_types,omitempty"`
1564-
Scope string `json:"scope,omitempty"`
1565-
LogoURI string `json:"logo_uri,omitempty"`
1566-
}
1567-
1568-
// handleClientRegistration implements RFC 7591 OAuth 2.0 Dynamic Client Registration Protocol
1569-
func (s *Server) handleClientRegistration(w http.ResponseWriter, r *http.Request) {
1570-
ctx := r.Context()
1571-
1572-
// Only POST method is allowed
1573-
if r.Method != http.MethodPost {
1574-
s.registrationErrHelper(w, errInvalidRequest, "Method not allowed", http.StatusMethodNotAllowed)
1575-
return
1576-
}
1577-
1578-
// Check Initial Access Token if configured (RFC 7591 Section 3.1)
1579-
if s.registrationToken != "" {
1580-
authHeader := r.Header.Get("Authorization")
1581-
const bearerPrefix = "Bearer "
1582-
1583-
if authHeader == "" || !strings.HasPrefix(authHeader, bearerPrefix) {
1584-
w.Header().Set("WWW-Authenticate", "Bearer")
1585-
s.registrationErrHelper(w, errInvalidRequest, "Initial access token required", http.StatusUnauthorized)
1586-
return
1587-
}
1588-
1589-
providedToken := strings.TrimPrefix(authHeader, bearerPrefix)
1590-
if providedToken != s.registrationToken {
1591-
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
1592-
s.registrationErrHelper(w, errInvalidRequest, "Invalid initial access token", http.StatusUnauthorized)
1593-
return
1594-
}
1595-
1596-
s.logger.InfoContext(ctx, "client registration authenticated with initial access token")
1597-
} else {
1598-
s.logger.WarnContext(ctx, "client registration endpoint is open - no authentication required. Set registrationToken in config for production use.")
1599-
}
1600-
1601-
// Parse the request body
1602-
var req clientRegistrationRequest
1603-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1604-
s.logger.ErrorContext(ctx, "failed to parse registration request", "err", err)
1605-
s.registrationErrHelper(w, errInvalidRequest, "Invalid JSON request body", http.StatusBadRequest)
1606-
return
1607-
}
1608-
1609-
// Validate required fields
1610-
if len(req.RedirectURIs) == 0 {
1611-
s.registrationErrHelper(w, errInvalidRequest, "redirect_uris is required", http.StatusBadRequest)
1612-
return
1613-
}
1614-
1615-
// Apply default values
1616-
if req.TokenEndpointAuthMethod == "" {
1617-
req.TokenEndpointAuthMethod = "client_secret_basic"
1618-
}
1619-
if len(req.GrantTypes) == 0 {
1620-
req.GrantTypes = []string{grantTypeAuthorizationCode, grantTypeRefreshToken}
1621-
}
1622-
if len(req.ResponseTypes) == 0 {
1623-
req.ResponseTypes = []string{responseTypeCode}
1624-
}
1625-
1626-
// Validate token_endpoint_auth_method
1627-
if req.TokenEndpointAuthMethod != "client_secret_basic" && req.TokenEndpointAuthMethod != "client_secret_post" && req.TokenEndpointAuthMethod != "none" {
1628-
s.registrationErrHelper(w, errInvalidRequest, "Unsupported token_endpoint_auth_method", http.StatusBadRequest)
1629-
return
1630-
}
1631-
1632-
// Validate grant_types
1633-
for _, gt := range req.GrantTypes {
1634-
if !contains(s.supportedGrantTypes, gt) {
1635-
s.registrationErrHelper(w, errInvalidRequest, fmt.Sprintf("Unsupported grant_type: %s", gt), http.StatusBadRequest)
1636-
return
1637-
}
1638-
}
1639-
1640-
// Validate response_types
1641-
for _, rt := range req.ResponseTypes {
1642-
if !s.supportedResponseTypes[rt] {
1643-
s.registrationErrHelper(w, errInvalidRequest, fmt.Sprintf("Unsupported response_type: %s", rt), http.StatusBadRequest)
1644-
return
1645-
}
1646-
}
1647-
1648-
// Generate client_id and client_secret
1649-
// Following the same pattern as the gRPC API (api.go:CreateClient)
1650-
clientID := storage.NewID()
1651-
1652-
// Determine if this is a public client
1653-
isPublic := req.TokenEndpointAuthMethod == "none"
1654-
1655-
// Only generate secret for confidential clients
1656-
var clientSecret string
1657-
if !isPublic {
1658-
clientSecret = storage.NewID() + storage.NewID() // Double NewID for longer secret
1659-
}
1660-
1661-
// Create the client in storage
1662-
client := storage.Client{
1663-
ID: clientID,
1664-
Secret: clientSecret,
1665-
RedirectURIs: req.RedirectURIs,
1666-
Name: req.ClientName,
1667-
LogoURL: req.LogoURI,
1668-
Public: isPublic,
1669-
}
1670-
1671-
if err := s.storage.CreateClient(ctx, client); err != nil {
1672-
s.logger.ErrorContext(ctx, "failed to create client", "err", err)
1673-
if err == storage.ErrAlreadyExists {
1674-
s.registrationErrHelper(w, errInvalidRequest, "Client ID already exists", http.StatusBadRequest)
1675-
} else {
1676-
s.registrationErrHelper(w, errServerError, "Failed to register client", http.StatusInternalServerError)
1677-
}
1678-
return
1679-
}
1680-
1681-
// Build the response
1682-
resp := clientRegistrationResponse{
1683-
ClientID: clientID,
1684-
ClientSecret: clientSecret,
1685-
ClientSecretExpiresAt: 0, // 0 indicates the secret never expires
1686-
ClientName: req.ClientName,
1687-
RedirectURIs: req.RedirectURIs,
1688-
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
1689-
GrantTypes: req.GrantTypes,
1690-
ResponseTypes: req.ResponseTypes,
1691-
Scope: req.Scope,
1692-
LogoURI: req.LogoURI,
1693-
}
1694-
1695-
// For public clients, don't return the secret
1696-
if isPublic {
1697-
resp.ClientSecret = ""
1698-
}
1699-
1700-
// Return HTTP 201 Created
1701-
w.Header().Set("Content-Type", "application/json")
1702-
w.WriteHeader(http.StatusCreated)
1703-
if err := json.NewEncoder(w).Encode(resp); err != nil {
1704-
s.logger.ErrorContext(ctx, "failed to encode registration response", "err", err)
1705-
}
1706-
}
1707-
1708-
func (s *Server) registrationErrHelper(w http.ResponseWriter, typ, description string, statusCode int) {
1709-
if err := tokenErr(w, typ, description, statusCode); err != nil {
1710-
s.logger.Error("registration error response", "err", err)
1711-
}
1712-
}

0 commit comments

Comments
 (0)