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
27 changes: 27 additions & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1845,3 +1845,30 @@ func TestMFAChallenge_SucceedsBelowAttemptCap(t *testing.T) {
rec := postChallenge(t, router, ticket, code)
assert.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
}

// With verification required, sign-up answers 201 with the user and empty
// tokens and sets no cookie, so the account can't use the app from another
// tab before it enters its code. A duplicate email gets the same shape.
func TestHandleSignUp_VerificationRequiredSetsNoSession(t *testing.T) {
t.Parallel()
_, eng := newTestAPI(t)
router := newAPIWithRouter(t, eng)
require.NoError(t, eng.Settings().Set(context.Background(), "auth.require_email_verification",
json.RawMessage(`true`), settings.ScopeGlobal, "", "", "", "verification-required-test"))

for _, attempt := range []string{"fresh", "duplicate"} {
body := []byte(`{"email":"pending@example.com","password":"SecureP@ss123"}`)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/signup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusCreated, rec.Code, "%s sign-up; body=%s", attempt, rec.Body.String())
assert.Empty(t, rec.Result().Cookies(), "%s sign-up must set no cookie", attempt)
var resp map[string]any
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.NotNil(t, resp["user"], attempt)
assert.Empty(t, resp["session_token"], "%s sign-up must carry no session", attempt)
assert.Empty(t, resp["refresh_token"], attempt)
}
}
68 changes: 40 additions & 28 deletions api/auth_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,9 @@ func (a *API) handleSignUp(ctx forge.Context, req *SignUpRequest) (*AuthResponse
//
// NOTE: ideally we'd queue a "someone tried to register with your
// email" notification to the legitimate owner here, but the engine
// does not yet expose a notifier hook for this. Phase 2A Task 4
// (default-on email verification) will give every fresh signup a
// structurally identical "verification pending" path, at which
// point this synthetic shape becomes indistinguishable from a real
// pending verification. Until then the lack of a session token
// is still ambiguous to an attacker — could be a fresh signup
// awaiting verification — so it is strictly better than 409.
// does not yet expose a notifier hook for this. Where verification
// is required, a fresh signup gets the same "verification pending"
// response with no session, so the two can't be told apart.
if errors.Is(err, account.ErrEmailTaken) {
// Run a dummy password hash on the duplicate path so the
// HTTP-response timing is indistinguishable from a fresh
Expand All @@ -173,15 +169,31 @@ func (a *API) handleSignUp(ctx forge.Context, req *SignUpRequest) (*AuthResponse
// addresses (duplicate ~1ms vs fresh ~100ms+). Result is
// discarded.
a.consumeDummyHashBudget(ctx.Context(), req.Password)
return nil, ctx.JSON(http.StatusCreated, a.syntheticSignupResponse(req.Email, appID))
return nil, ctx.JSON(http.StatusCreated, a.syntheticSignupResponse(ctx.Context(), req.Email, appID))
}
return nil, mapErrorCtx(ctx, err)
}

// The email has to be verified first: no session, no cookie. The code
// sent at sign-up signs the user in once they enter it.
if sess == nil {
return nil, ctx.JSON(http.StatusCreated, pendingVerificationResponse(u))
}
a.setSessionCookie(ctx, sess.Token, a.sessionTokenMaxAge())
return nil, ctx.JSON(http.StatusCreated, authResponse(u, sess))
}

// pendingVerificationResponse is a sign-up's answer while its email awaits
// verification: the user, with empty tokens.
func pendingVerificationResponse(u *user.User) map[string]any {
return map[string]any{
"user": u,
"session_token": "",
"refresh_token": "",
"expires_at": "",
}
}

// consumeDummyHashBudget runs the engine's configured password hashing
// algorithm against the supplied password and discards the result. Used on
// the duplicate-email path so the response time matches a real signup. Any
Expand All @@ -205,18 +217,26 @@ func (a *API) consumeDummyHashBudget(ctx context.Context, password string) {
}

// syntheticSignupResponse returns a response shaped exactly like a real
// fresh-signup response — populated user, non-empty session_token /
// refresh_token, and a forward-dated expires_at — so an attacker cannot use
// the response shape itself to distinguish duplicate-email signups from
// fresh ones. The synthetic tokens are generated via crypto/rand and will
// not validate against the session store; any attacker probe that tries to
// USE one in a follow-up request gets the same "invalid session" response
// they would get from a fully forged token.
//
// This is a Phase 2A Task 4 stopgap. Once RequireEmailVerification defaults
// to true, fresh signups will also return empty tokens until verification
// completes, at which point this synthesis can be collapsed.
func (a *API) syntheticSignupResponse(email string, appID id.AppID) map[string]any {
// fresh-signup response, so an attacker cannot use the response shape to
// tell a duplicate-email signup from a fresh one. Where the app requires
// email verification a fresh signup gets no session, so neither does this.
// Otherwise it carries a populated user, non-empty session_token and
// refresh_token, and a forward-dated expires_at. Those synthetic tokens
// come from crypto/rand and never validate against the session store; any
// probe that tries to USE one gets the same "invalid session" response as
// a forged token.
func (a *API) syntheticSignupResponse(ctx context.Context, email string, appID id.AppID) map[string]any {
syntheticUser := &user.User{
ID: id.NewUserID(),
AppID: appID,
Email: strings.ToLower(strings.TrimSpace(email)),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if a.engine.EmailVerificationRequired(ctx, appID) {
return pendingVerificationResponse(syntheticUser)
}

token, err := syntheticOpaqueToken()
if err != nil {
token = ""
Expand All @@ -232,14 +252,6 @@ func (a *API) syntheticSignupResponse(email string, appID id.AppID) map[string]a
}
expiresAt := time.Now().Add(ttl)

syntheticUser := &user.User{
ID: id.NewUserID(),
AppID: appID,
Email: strings.ToLower(strings.TrimSpace(email)),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}

return map[string]any{
"user": syntheticUser,
"session_token": token,
Expand Down
40 changes: 27 additions & 13 deletions docs/content/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,9 @@ Response:

```json
{
"session_token": "0d6283f1...",
"refresh_token": "ebad821f...",
"expires_at": "2026-10-14T18:29:39Z",
"session_token": "",
"refresh_token": "",
"expires_at": "",
"user": {
"id": "ausr_01m4...",
"app_id": "aapp_01m4...",
Expand All @@ -173,8 +173,30 @@ Response:
}
```

The tokens are empty because `auth.require_email_verification` is on by default: the account exists, but it gets no session and no cookie until it proves the address. Sign-up sends a 6-digit code for that. Authsome doesn't deliver it by itself; the `notification` plugin sends it through a Herald bridge.

Browser and mobile clients usually send an `X-Publishable-Key` header instead of the app ID.

### Verify the email

Post the code with the address:

```bash
curl -s -X POST http://localhost:8080/v1/verify-email \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","code":"123456"}'
```

A right code marks the email verified and signs you in, so the response has the same shape as above with real tokens in it. With no mail set up yet, take the code from `engine.SendEmailVerification(ctx, user)`, or switch the setting off for local work:

```go
// after engine.Start; needs encoding/json and github.com/xraph/authsome/settings
_ = engine.Settings().Set(ctx, "auth.require_email_verification",
json.RawMessage(`false`), settings.ScopeGlobal, "", "", "", "local-dev")
```

With it off, sign-up hands back a session straight away.

### Call /me

```bash
Expand All @@ -184,21 +206,13 @@ curl -s http://localhost:8080/v1/me \

### Sign in

`/v1/signin` refuses an account whose email isn't verified, because the `auth.require_email_verification` setting is on by default:
`/v1/signin` refuses an account whose email isn't verified while the setting is on:

```json
{"code":403,"error":"please verify your email address before signing in","type":"email_not_verified"}
```

Sign-up issues a 6-digit code for this, but Authsome doesn't send it by itself; the `notification` plugin delivers it through a Herald bridge. For local work, either switch the setting off:

```go
// after engine.Start; needs encoding/json and github.com/xraph/authsome/settings
_ = engine.Settings().Set(ctx, "auth.require_email_verification",
json.RawMessage(`false`), settings.ScopeGlobal, "", "", "", "local-dev")
```

or take the code from `engine.SendEmailVerification(ctx, user)` and post `{"email": "...", "code": "..."}` to `/v1/verify-email`. After that, sign-in returns the same shape as sign-up.
So does every other way in (passkeys, magic links, social sign-in), since they all mint their session in the same place. An account with no email at all, such as a phone sign-up, isn't affected.

### Refresh tokens

Expand Down
38 changes: 38 additions & 0 deletions email_verification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package authsome

import (
"context"

"github.com/xraph/authsome/environment"
"github.com/xraph/authsome/id"
"github.com/xraph/authsome/settings"
)

// EmailVerificationRequired reports whether an account in appID must verify
// its email before it gets a session. Sign-up, sign-in, IssueSession and the
// client config all ask this, so the UI and the server never disagree.
//
// Resolution order (first match wins):
// 1. Dynamic setting auth.require_email_verification, when it says true
// 2. Per-app client config override (RequireEmailVerification)
// 3. Environment setting (SkipEmailVerification, inverted, with type defaults merged)
// 4. Default: no enforcement
func (e *Engine) EmailVerificationRequired(ctx context.Context, appID id.AppID) bool {
if mgr := e.Settings(); mgr != nil {
opts := settings.ResolveOpts{}
if appID.Prefix() != "" {
opts.AppID = appID.String()
}
if on, err := settings.Get(ctx, mgr, SettingRequireEmailVerification, opts); err == nil && on {
return true
}
}
if cfg, err := e.store.GetAppClientConfig(ctx, appID); err == nil && cfg.RequireEmailVerification != nil {
return *cfg.RequireEmailVerification
}
if env, _ := e.GetDefaultEnvironment(ctx, appID); env != nil { //nolint:errcheck // best-effort env lookup
effective := environment.MergeSettings(environment.DefaultSettingsForType(env.Type), env.Settings)
return effective != nil && !effective.SkipEmailVerificationEnabled()
}
return false
}
105 changes: 105 additions & 0 deletions email_verification_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package authsome_test

import (
"context"
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

authsome "github.com/xraph/authsome"
"github.com/xraph/authsome/account"
"github.com/xraph/authsome/id"
"github.com/xraph/authsome/plugins/password"
"github.com/xraph/authsome/settings"
"github.com/xraph/authsome/user"
)

func requireVerification(t *testing.T, eng *authsome.Engine, on bool) {
t.Helper()
raw := json.RawMessage(`false`)
if on {
raw = json.RawMessage(`true`)
}
require.NoError(t, eng.Settings().Set(context.Background(), "auth.require_email_verification", raw,
settings.ScopeGlobal, "", "", "", "verification-gate-test"))
}

// A session from sign-up worked in any tab, so "verify your email first"
// was only ever a screen. With verification required, sign-up creates the
// account and sends the code, and hands out no session.
func TestSignUp_NoSessionUntilEmailVerified(t *testing.T) {
eng, _ := newTestEngine(t)
requireVerification(t, eng, true)

u, sess, err := eng.SignUp(context.Background(), &account.SignUpRequest{
AppID: testAppID(t), Email: "pending@example.com", Password: "SecureP@ss123",
})
require.NoError(t, err)
require.NotNil(t, u)
assert.False(t, u.EmailVerified)
assert.Nil(t, sess, "an unverified sign-up must not get a session")
}

func TestSignUp_SessionWhenVerificationIsOff(t *testing.T) {
eng, _ := newTestEngine(t)
requireVerification(t, eng, false)

_, sess, err := eng.SignUp(context.Background(), &account.SignUpRequest{
AppID: testAppID(t), Email: "open@example.com", Password: "SecureP@ss123",
})
require.NoError(t, err)
assert.NotNil(t, sess)
}

// IssueSession mints for every sign-in method, so the rule holds for
// passkeys, magic links and social sign-in too, not just passwords.
func TestIssueSession_RefusesAnUnverifiedEmail(t *testing.T) {
eng, st := newTestEngine(t)
ctx := context.Background()
requireVerification(t, eng, true)

u, _, err := eng.SignUp(ctx, &account.SignUpRequest{
AppID: testAppID(t), Email: "gate@example.com", Password: "SecureP@ss123",
})
require.NoError(t, err)

_, err = eng.IssueSession(ctx, &authsome.IssueSessionRequest{User: u, AppID: testAppID(t), AuthMethod: "passkey"})
require.ErrorIs(t, err, account.ErrEmailNotVerified)

u.EmailVerified = true
require.NoError(t, st.UpdateUser(ctx, u))
res, err := eng.IssueSession(ctx, &authsome.IssueSessionRequest{User: u, AppID: testAppID(t), AuthMethod: "passkey"})
require.NoError(t, err)
assert.NotNil(t, res.Session)
}

// Phone and anonymous accounts have no email to verify.
func TestIssueSession_AllowsAnAccountWithNoEmail(t *testing.T) {
eng, st := newTestEngine(t)
ctx := context.Background()
requireVerification(t, eng, true)

now := time.Now()
u := &user.User{ID: id.NewUserID(), AppID: testAppID(t), Phone: "+15555550100", CreatedAt: now, UpdatedAt: now}
require.NoError(t, st.CreateUser(ctx, u))
_, err := eng.IssueSession(ctx, &authsome.IssueSessionRequest{User: u, AppID: testAppID(t), AuthMethod: "phone"})
require.NoError(t, err)
}

// The client config tells the UI whether to wait for verification. It
// used to read only the environment, so it could say "verify first"
// while sign-in let the account straight in, or the other way round.
func TestClientConfig_VerificationMatchesEnforcement(t *testing.T) {
eng, _ := newTestEngine(t, authsome.WithPlugin(password.New()))
ctx := context.Background()
for _, on := range []bool{false, true} {
requireVerification(t, eng, on)
assert.Equal(t, on, eng.EmailVerificationRequired(ctx, testAppID(t)))
cfg := eng.ClientConfig(ctx, testAppID(t))
require.NotNil(t, cfg.EmailVerification)
assert.Equal(t, on, cfg.EmailVerification.Required, "client config with the setting %v", on)
}
}
10 changes: 2 additions & 8 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1530,17 +1530,11 @@ func (e *Engine) ClientConfig(ctx context.Context, appID id.AppID) *ClientConfig
}

// Email verification: enabled when password plugin is registered,
// required based on environment settings (default: required in production).
// required by the same rule the server enforces.
if passwordEnabled {
emailVerifRequired := true
if env, _ := e.GetDefaultEnvironment(ctx, appID); env != nil && env.Settings != nil { //nolint:errcheck // best-effort env lookup
if env.Settings.SkipEmailVerification != nil && *env.Settings.SkipEmailVerification {
emailVerifRequired = false
}
}
resp.EmailVerification = &ClientConfigEmailVerification{
Enabled: true,
Required: emailVerifRequired,
Required: e.EmailVerificationRequired(ctx, appID),
}
}

Expand Down
1 change: 1 addition & 0 deletions engine_dpop_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func TestEngine_SessionAuthProviderEnforcesDPoP(t *testing.T) {
t.Parallel()

eng := secutil.NewTestEngine(t)
secutil.RelaxAuthDefaults(t, eng) // sign-up has to hand back a session to bind
ctx := context.Background()
require.NoError(t, eng.InitPlugins(ctx))

Expand Down
7 changes: 7 additions & 0 deletions engine_issue_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ func (e *Engine) issueSession(ctx context.Context, req *IssueSessionRequest) (*I
if req.AppID.IsNil() {
req.AppID = req.User.AppID
}
// The same holds for an email nobody has verified, where the app
// requires it. An account with no email (phone, anonymous) has none to
// verify. The verify-email paths mark the user verified before they
// sign anyone in.
if req.User.Email != "" && !req.User.EmailVerified && e.EmailVerificationRequired(ctx, req.AppID) {
return nil, account.ErrEmailNotVerified
}

// Resolve the default environment when the caller didn't supply one.
// authsome_sessions.env_id is NOT NULL, so a session minted without an
Expand Down
Loading
Loading