Skip to content

Commit f869c90

Browse files
committed
docs: document registration policy hook and email-change endpoints
Document the OnPreUserRegister hook (Go embedding + JS plugin), the opt-in built-in RegistrationPolicy with AUTH_REG_* config, and the new /api/auth/local/email/change and /api/auth/local/email/confirm endpoints. Claude-Session: https://claude.ai/code/session_014noPgZC7LHPJ376yMNMxH8
1 parent f992750 commit f869c90

4 files changed

Lines changed: 247 additions & 3 deletions

File tree

src/docs/backend/authentication.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,56 @@ Content-Type: application/json
162162
}
163163
```
164164

165+
#### Change Email
166+
167+
Starts an authenticated email change. The request re-authenticates the caller with the current password, validates the new address (format, [registration policy](#registration-policy), and uniqueness), and creates a single-use pending change. It then sends two emails: a notification to the **current** address and a confirmation link to the **new** address. The account email is **not** changed until the new address is confirmed.
168+
169+
Email change is only available for **local (password) accounts**.
170+
171+
**Request:**
172+
```http
173+
POST /api/auth/local/email/change HTTP/1.1
174+
Content-Type: application/json
175+
Authorization: Bearer <access_token>
176+
177+
{
178+
"new_email": "new@example.com",
179+
"current_password": "your_current_password"
180+
}
181+
```
182+
183+
**Response:**
184+
```json
185+
{
186+
"message": "A confirmation link has been sent to your new email address"
187+
}
188+
```
189+
190+
#### Confirm Email Change
191+
192+
Confirms a pending email change using the single-use, time-limited (24 hours) token delivered to the new address. On success, the account email is committed to the new value.
193+
194+
**Request:**
195+
```http
196+
POST /api/auth/local/email/confirm HTTP/1.1
197+
Content-Type: application/json
198+
199+
{
200+
"token": "<token_from_new_email>"
201+
}
202+
```
203+
204+
**Response:**
205+
```json
206+
{
207+
"message": "Your email address has been updated"
208+
}
209+
```
210+
211+
::: tip Confirmation link
212+
The confirmation link base defaults to `<base_url>/auth/local/email/confirm`. To point it at a custom page (for example, a dashboard route that calls the confirm endpoint), set `email_change_url` in the local provider configuration.
213+
:::
214+
165215
---
166216

167217
## Session Management
@@ -278,6 +328,81 @@ Required: `AUTH_OTP_ENABLED=true`
278328

279329
---
280330

331+
## Registration Policy
332+
333+
FastSchema can apply an opt-in, built-in validation policy to **self-service signups**. The policy runs on **both** local email/password registration and OAuth/social signup, just before the user row is created. It does **not** apply to admin-created users (content API). By default (no policy configured) nothing is blocked.
334+
335+
The policy supports:
336+
337+
| Field | Description |
338+
|---|---|
339+
| `allowed_email_domains` | Allowlist. When non-empty, only emails on these domains may register. |
340+
| `blocked_email_domains` | Denylist of email domains to reject. |
341+
| `reserved_usernames` | Usernames that may not be registered (e.g. `admin`, `root`, `system`). |
342+
| `normalize_email` | Lowercases the email domain and converts IDN domains to punycode. |
343+
344+
### Configure via environment variables
345+
346+
Each variable is a comma-separated list. Setting **any** of them enables the policy with `normalize_email` turned on.
347+
348+
| Variable | Description |
349+
|---|---|
350+
| `AUTH_REG_ALLOWED_DOMAINS` | Allowed email domains (allowlist). |
351+
| `AUTH_REG_BLOCKED_DOMAINS` | Blocked email domains (denylist). |
352+
| `AUTH_REG_RESERVED_USERNAMES` | Reserved usernames. |
353+
354+
```bash
355+
AUTH_REG_ALLOWED_DOMAINS=example.com,acme.io
356+
AUTH_REG_RESERVED_USERNAMES=admin,root,system
357+
```
358+
359+
### Configure programmatically
360+
361+
When embedding FastSchema as a Go framework, set the policy on `AuthConfig`:
362+
363+
```go
364+
app, _ := fastschema.New(&fs.Config{
365+
AuthConfig: &fs.AuthConfig{
366+
Registration: &fs.RegistrationPolicy{
367+
AllowedEmailDomains: []string{"example.com"},
368+
ReservedUsernames: []string{"admin", "root"},
369+
NormalizeEmail: true,
370+
},
371+
},
372+
})
373+
```
374+
375+
The built-in policy runs as the **first** `OnPreUserRegister` hook, so any custom hooks you register run afterwards on the already-normalized input.
376+
377+
### Custom signup rules (`OnPreUserRegister`)
378+
379+
For anything beyond the basics above — invite-only gating, blocking disposable-email or free-webmail domains, custom username rules — register an `OnPreUserRegister` hook. The hook fires for both local and OAuth signups, may mutate `Email`/`Username` (for example, to normalize them), and returning an error rejects the registration.
380+
381+
```go
382+
app.OnPreUserRegister(func(ctx context.Context, in *fs.RegistrationInput) error {
383+
// Block a disposable-email domain
384+
if strings.HasSuffix(strings.ToLower(in.Email), "@tempmail.example") {
385+
return errors.BadRequest("Disposable email addresses are not allowed")
386+
}
387+
return nil
388+
})
389+
```
390+
391+
`RegistrationInput` carries the signup data:
392+
393+
| Field | Type | Description |
394+
|---|---|---|
395+
| `Email` | `string` | Signup email (mutable). |
396+
| `Username` | `string` | Signup username (mutable). |
397+
| `Provider` | `string` | `local` or the OAuth provider name. |
398+
| `ProviderID` | `string` | OAuth provider subject id (empty for local). |
399+
| `Profile` | `map[string]any` | Raw provider profile (OAuth only). |
400+
| `IsOAuth` | `bool` | `true` for social-login registration. |
401+
402+
See the [Hooks](/docs/framework/hooks/#onpreuserregister) reference for more detail, or [Plugin Configuration](/docs/plugins/configuration#add-hooks) to register the hook from a JS plugin.
403+
404+
---
405+
281406
## Token Usage
282407

283408
Clients must send the access token in every authenticated request. Two methods:
@@ -311,6 +436,9 @@ Cookie: token=<access_token>
311436
| `AUTH_OTP_LENGTH` | `6` | OTP code length |
312437
| `AUTH_OTP_EXPIRATION` | `300` | OTP expiration in seconds (5 minutes) |
313438
| `AUTH_OTP_MAX_ATTEMPTS` | `3` | Max verification attempts before OTP expires |
439+
| `AUTH_REG_ALLOWED_DOMAINS` | _(empty)_ | Comma-separated allowlist of email domains for signup (see [Registration Policy](#registration-policy)) |
440+
| `AUTH_REG_BLOCKED_DOMAINS` | _(empty)_ | Comma-separated denylist of email domains for signup |
441+
| `AUTH_REG_RESERVED_USERNAMES` | _(empty)_ | Comma-separated list of usernames that cannot be registered |
314442

315443
### Email Templates
316444

src/docs/configuration.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,27 @@ For the first time you run FastSchema, the application will generate a random `A
7171

7272
```go
7373
type AuthConfig struct {
74-
EnabledProviders []string `json:"enabled_providers"`
75-
Providers map[string]Map `json:"providers"`
74+
EnabledProviders []string `json:"enabled_providers"`
75+
Providers map[string]Map `json:"providers"`
76+
Registration *RegistrationPolicy `json:"registration"` // opt-in signup policy
77+
}
78+
79+
// RegistrationPolicy is the opt-in built-in validation applied to self-service
80+
// signup (local + OAuth). All fields are optional; a nil policy blocks nothing.
81+
type RegistrationPolicy struct {
82+
AllowedEmailDomains []string `json:"allowed_email_domains"` // non-empty = allowlist
83+
BlockedEmailDomains []string `json:"blocked_email_domains"` // deny list
84+
ReservedUsernames []string `json:"reserved_usernames"` // admin, root, system, ...
85+
NormalizeEmail bool `json:"normalize_email"` // lowercase domain + IDN punycode
7686
}
7787
```
7888

7989
*The `local` provider is enabled by default.*
8090

91+
The optional `registration` policy validates self-service signups for both local and OAuth providers. It can also be configured with the `AUTH_REG_ALLOWED_DOMAINS`, `AUTH_REG_BLOCKED_DOMAINS`, and `AUTH_REG_RESERVED_USERNAMES` environment variables. See [Registration Policy](/docs/backend/authentication#registration-policy) for details.
92+
93+
The `local` provider also accepts an `email_change_url` key, used as the base for the confirmation link sent when a user changes their email address (defaults to `<base_url>/auth/local/email/confirm`). See [Change Email](/docs/backend/authentication#change-email).
94+
8195
For example, to enable the `github` and `google` providers:
8296

8397
```json
@@ -90,7 +104,8 @@ For example, to enable the `github` and `google` providers:
90104
"local": {
91105
"activation_method": "email",
92106
"activation_url": "http://localhost:3001/activation",
93-
"recovery_url": "http://localhost:3001/recover"
107+
"recovery_url": "http://localhost:3001/recover",
108+
"email_change_url": "http://localhost:3001/email/confirm"
94109
},
95110
"github": {
96111
"client_id": "github_client_id",
@@ -104,6 +119,11 @@ For example, to enable the `github` and `google` providers:
104119
"consumer_key": "twitter_consumer_key",
105120
"consumer_secret": "twitter_consumer_secret"
106121
}
122+
},
123+
"registration": {
124+
"allowed_email_domains": ["example.com"],
125+
"reserved_usernames": ["admin", "root", "system"],
126+
"normalize_email": true
107127
}
108128
}
109129
```

src/docs/framework/hooks/index.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,63 @@ app.API().Add(fs.Get("hello", func(ctx fs.Context, _ any) (any, error) {
109109
app.Start()
110110
```
111111

112+
## OnPreUserRegister
113+
114+
```go
115+
type RegistrationInput struct {
116+
Email string // signup email (mutable)
117+
Username string // signup username (mutable)
118+
Provider string // "local" or the OAuth provider name
119+
ProviderID string // OAuth provider subject id (empty for local)
120+
Profile map[string]any // raw provider profile (OAuth only)
121+
IsOAuth bool // true for social-login registration
122+
}
123+
124+
type PreUserRegisterHook = func(
125+
ctx context.Context,
126+
in *RegistrationInput,
127+
) error
128+
129+
func (a *App) OnPreUserRegister(hooks ...fs.PreUserRegisterHook)
130+
```
131+
132+
The `OnPreUserRegister` hook is executed just before a **self-service** user is created. It fires for both local (email/password) registration and OAuth/social signup. It does **not** fire for admin-created users.
133+
134+
This is the extension point for custom signup rules such as invite-only gating, or blocking disposable-email / free-webmail domains.
135+
136+
**There are two possible behaviors for the `OnPreUserRegister` hook:**
137+
138+
- `Return nil` to allow the registration to continue.
139+
- `Return an error` to reject the registration before the user row is created.
140+
141+
The hook receives a pointer to `RegistrationInput` and **may mutate** `Email`/`Username` (for example, to normalize them); the mutated values are applied to the persisted user.
142+
143+
::: tip Built-in registration policy
144+
The opt-in [Registration Policy](/docs/backend/authentication#registration-policy) (allow/block email domains, reserved usernames, email normalization) runs as the **first** `OnPreUserRegister` hook, so your custom hooks run afterwards on the already-normalized input.
145+
:::
146+
147+
**Example:**
148+
149+
```go{5-14}
150+
app, _ := fastschema.New(&fs.Config{
151+
SystemSchemas: []any{Tag{}, Blog{}},
152+
})
153+
154+
app.OnPreUserRegister(func(ctx context.Context, in *fs.RegistrationInput) error {
155+
// Invite-only: reject any email that has not been invited
156+
if !isInvited(in.Email) {
157+
return errors.BadRequest("Registration is invite-only")
158+
}
159+
160+
// Normalize the username
161+
in.Username = strings.ToLower(in.Username)
162+
163+
return nil
164+
})
165+
166+
app.Start()
167+
```
168+
112169
## OnPreDBQuery
113170

114171
```go{6}

src/docs/plugins/configuration.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface FsAppConfig {
5656
OnPreResolve(...hooks: _FsResolveHook[]): void;
5757
OnPostResolve(...hooks: _FsResolveHook[]): void;
5858

59+
OnPreUserRegister(...hooks: _FsPreUserRegister[]): void;
60+
5961
OnPreDBQuery(...hooks: _FsPreDBQueryHook[]): void;
6062
OnPostDBQuery(...hooks: _FsPostDBQueryHook[]): void;
6163

@@ -148,6 +150,8 @@ const Config = config => {
148150
config.OnPreResolve(hookPreResolve);
149151
config.OnPostResolve(hookPostResolve);
150152

153+
config.OnPreUserRegister(hookPreUserRegister);
154+
151155
config.OnPreDBQuery(hookPreDBQuery);
152156
config.OnPostDBQuery(hookPostDBQuery);
153157

@@ -170,6 +174,22 @@ const Config = config => {
170174
```ts
171175
export type _FsResolveHook = (ctx: FsContext) => Promise<void> | void;
172176

177+
export interface FsRegistrationInput {
178+
email: string;
179+
username: string;
180+
provider: string;
181+
provider_id: string;
182+
profile?: { [key: string]: any };
183+
is_oauth: boolean;
184+
}
185+
186+
// Runs before a self-service user is created (local + OAuth).
187+
// Throw to reject registration. Does NOT fire for admin-created users.
188+
export type _FsPreUserRegister = (
189+
ctx: FsContext,
190+
input: FsRegistrationInput,
191+
) => Promise<void> | void;
192+
173193
export type _FsPreDBQueryHook = (
174194
ctx: FsContext,
175195
option: FsQueryOption,
@@ -235,3 +255,22 @@ export type _FsPostDBDeleteHook = (
235255
affected: number,
236256
) => Promise<void> | void;
237257
```
258+
259+
## Pre user register hook
260+
261+
The `OnPreUserRegister` hook runs before a self-service user is created, for both local and OAuth signups. Throw to reject the registration; you may also mutate `input.email` / `input.username` (for example, to normalize them). It does **not** fire for admin-created users.
262+
263+
This is the place to implement custom signup rules such as invite-only gating or blocking disposable-email domains. See the [Registration Policy](/docs/backend/authentication#registration-policy) for the built-in basics (allow/block domains, reserved usernames, email normalization).
264+
265+
```js [plugin.js]
266+
const hookPreUserRegister = (ctx, input) => {
267+
// Block a disposable-email domain
268+
if (input.email.toLowerCase().endsWith('@tempmail.example')) {
269+
throw new Error('Disposable email addresses are not allowed');
270+
}
271+
};
272+
273+
const Config = config => {
274+
config.OnPreUserRegister(hookPreUserRegister);
275+
}
276+
```

0 commit comments

Comments
 (0)