diff --git a/clients/typescript/.changeset/social-tips-dance.md b/clients/typescript/.changeset/social-tips-dance.md new file mode 100644 index 00000000..63e7de8b --- /dev/null +++ b/clients/typescript/.changeset/social-tips-dance.md @@ -0,0 +1,5 @@ +--- +"limen-auth": patch +--- + +Support resending an email verification without a session, and error codes such as `email_not_verified`. diff --git a/clients/typescript/packages/client/src/auth-store.ts b/clients/typescript/packages/client/src/auth-store.ts index d9a77343..ba16843d 100644 --- a/clients/typescript/packages/client/src/auth-store.ts +++ b/clients/typescript/packages/client/src/auth-store.ts @@ -34,7 +34,7 @@ export function createAuthStore(options: CreateAuthStoreOptions): DataStor const raw = await ctx.fetch(path, { method: "GET", ...init }); return parse === undefined ? (raw as T) : parse(raw); } catch (error) { - if (error instanceof LimenError && error.isUnauthorized) { + if (error instanceof LimenError && error.is("unauthorized")) { return null; } throw error; diff --git a/clients/typescript/packages/client/src/envelope.ts b/clients/typescript/packages/client/src/envelope.ts index 613d42de..e4970971 100644 --- a/clients/typescript/packages/client/src/envelope.ts +++ b/clients/typescript/packages/client/src/envelope.ts @@ -27,6 +27,14 @@ export function unwrapPayload(body: unknown, envelope: EnvelopeConfig): unknown return body; } +export function unwrapErrorCode(body: unknown): string | undefined { + if (body === null || typeof body !== "object") { + return undefined; + } + const value = (body as Record).code; + return typeof value === "string" && value !== "" ? value : undefined; +} + /** * Pull the human-readable error message out of a non-2xx body. * diff --git a/clients/typescript/packages/client/src/errors.ts b/clients/typescript/packages/client/src/errors.ts index b74fb107..14ff14f4 100644 --- a/clients/typescript/packages/client/src/errors.ts +++ b/clients/typescript/packages/client/src/errors.ts @@ -1,13 +1,34 @@ -export type LimenErrorCode = - | "unauthorized" - | "forbidden" - | "not_found" - | "rate_limited" - | "validation_error" - | "conflict" - | "server_error" - | "timeout" - | "unknown"; +import { unwrapErrorCode } from "./envelope"; + +const LIMEN_ERROR_CODES = [ + "unauthorized", + "forbidden", + "not_found", + "rate_limited", + "validation_error", + "conflict", + "server_error", + "timeout", + "email_not_verified", + "unknown", +] as const; + +export type LimenErrorCode = (typeof LIMEN_ERROR_CODES)[number]; + +const LIMEN_ERROR_CODE_SET = new Set(LIMEN_ERROR_CODES); + +export function isLimenErrorCode(code: string): code is LimenErrorCode { + return LIMEN_ERROR_CODE_SET.has(code); +} + +/** Prefer a known server code on the body; otherwise derive one from the HTTP status. */ +export function resolveErrorCode(status: number, body: unknown): LimenErrorCode { + const serverCode = unwrapErrorCode(body); + if (serverCode !== undefined && isLimenErrorCode(serverCode)) { + return serverCode; + } + return deriveErrorCode(status); +} /** Map HTTP status → typed code. Anything unmapped becomes `"unknown"`. */ // prettier-ignore @@ -37,6 +58,10 @@ export class LimenError extends Error { this.code = code ?? deriveErrorCode(status); } + is(code: LimenErrorCode): boolean { + return this.code === code; + } + get isUnauthorized(): boolean { return this.code === "unauthorized"; } diff --git a/clients/typescript/packages/client/src/fetcher.ts b/clients/typescript/packages/client/src/fetcher.ts index 6c6033c1..5f06ebc8 100644 --- a/clients/typescript/packages/client/src/fetcher.ts +++ b/clients/typescript/packages/client/src/fetcher.ts @@ -1,6 +1,6 @@ import { DEFAULT_TIMEOUT_MS } from "./constants"; import { unwrapErrorMessage, unwrapPayload } from "./envelope"; -import { LimenError, deriveErrorCode } from "./errors"; +import { LimenError, resolveErrorCode } from "./errors"; import { ensureLeadingSlash, joinURL, stripTrailingSlash } from "./helpers"; import type { HookRunner } from "./hooks"; import type { FetchInit, FetchOptions, RequestContext, ResponseContext } from "./plugin"; @@ -129,7 +129,7 @@ export class Fetcher { : (unwrapErrorMessage(resCtx.body, this.opts.envelope) ?? response.statusText ?? `Request failed with status ${response.status}`); - this.fail(reqCtx, response.status, new LimenError(message, response.status, deriveErrorCode(response.status))); + this.fail(reqCtx, response.status, new LimenError(message, response.status, resolveErrorCode(response.status, resCtx.body))); } private fail(reqCtx: RequestContext, status: number, error: LimenError): never { diff --git a/clients/typescript/packages/client/src/index.ts b/clients/typescript/packages/client/src/index.ts index 338c3ee0..a7ab4cca 100644 --- a/clients/typescript/packages/client/src/index.ts +++ b/clients/typescript/packages/client/src/index.ts @@ -40,7 +40,7 @@ export type { DeclaredFields, FieldsOf, ModelFields, PluginSchema, RunRoute, Str export type { RouteCallOptions, RouteHandler } from "./route"; export { coreClientPlugin } from "./routes"; -export type { ActiveSession, CoreContribution, VerifyEmailInput } from "./routes"; +export type { ActiveSession, CoreContribution, RequestEmailVerificationInput, VerifyEmailInput } from "./routes"; export type { CoreStores, StoresOf, StoreValues } from "./infer"; diff --git a/clients/typescript/packages/client/src/normalize.ts b/clients/typescript/packages/client/src/normalize.ts index 18cc578e..94ab305c 100644 --- a/clients/typescript/packages/client/src/normalize.ts +++ b/clients/typescript/packages/client/src/normalize.ts @@ -14,11 +14,18 @@ export function normalizeUser(raw: Record): User; } -export function defaultSessionParse(raw: unknown): Session { - if (!raw || typeof raw !== "object") { - throw new TypeError(`Expected session response to be an object, got ${raw === null ? "null" : typeof raw}`); +/** + * Map a default Limen session body into a `Session`. Returns `false` unless + * `user` is a non-null object, so non-session payloads (a pending two-factor + * challenge, a plain message) are not stored. + */ +export function defaultSessionParse(raw: unknown): Session | false { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return false; + } + const userRaw = (raw as Record)["user"]; + if (typeof userRaw !== "object" || userRaw === null || Array.isArray(userRaw)) { + return false; } - const obj = raw as Record; - const userRaw = (obj["user"] ?? obj) as Record; - return { user: normalizeUser(userRaw) }; + return { user: normalizeUser(userRaw as Record) }; } diff --git a/clients/typescript/packages/client/src/pipeline.ts b/clients/typescript/packages/client/src/pipeline.ts index 944f438c..b0685903 100644 --- a/clients/typescript/packages/client/src/pipeline.ts +++ b/clients/typescript/packages/client/src/pipeline.ts @@ -10,8 +10,15 @@ import type { QueryParams, Session } from "./types"; /** * Run the default HTTP steps for a route — merge defaults, resolve path params, * serialize, dispatch, parse — without applying session effects. + * `onSession` receives a session `parseSession` accepted. Handlers omit it. */ -async function runHttp(ctx: AnyRouteContext, def: AnyRoute, input: unknown, callInit?: FetchOptions): Promise { +async function runHttp( + ctx: AnyRouteContext, + def: AnyRoute, + input: unknown, + callInit?: FetchOptions, + onSession?: (session: Session) => void, +): Promise { let merged = input; if (def.defaults !== undefined) { merged = { ...(def.defaults as Record), ...((input ?? {}) as Record) }; @@ -29,8 +36,12 @@ async function runHttp(ctx: AnyRouteContext, def: AnyRoute, input: unknown, call const raw = await ctx.fetch(path, init); - if (def.parseSession === true && isSessionResponse(raw)) { - return ctx.parseSession(raw); + if (def.parseSession === true) { + const session = ctx.parseSession(raw); + if (session !== false) { + onSession?.(session); + return session; + } } if (def.parse !== undefined) { @@ -42,13 +53,17 @@ async function runHttp(ctx: AnyRouteContext, def: AnyRoute, input: unknown, call return Array.isArray(raw) ? camelizeEach(raw) : camelizeKeys(raw); } -async function applyEffects(ctx: AnyRouteContext, def: AnyRoute, result: unknown): Promise { +async function applyEffects( + ctx: AnyRouteContext, + def: AnyRoute, + session: Session | undefined, +): Promise { if (def.clearSession === true) { ctx.store.setData(null); } - if (def.parseSession === true && def.skipStore !== true && isSessionResponse(result)) { - ctx.store.setData(result); + if (session !== undefined && def.skipStore !== true) { + ctx.store.setData(session); } if (def.refetchSession === true) { @@ -67,10 +82,6 @@ function makeHttpRunner( return run as HttpRunner; } -function isSessionResponse(raw: unknown): raw is Session { - return typeof raw === "object" && raw !== null && "user" in raw; -} - /** * Execute a route's behaviour: delegate to its `handler` when present (handler * owns all behaviour, including any effects), otherwise run the default @@ -85,8 +96,11 @@ async function dispatchRoute( if (def.handler !== undefined) { return def.handler(ctx, input, makeHttpRunner(ctx, def, input, callInit)); } - const result = await runHttp(ctx, def, input, callInit); - await applyEffects(ctx, def, result); + let session: Session | undefined; + const result = await runHttp(ctx, def, input, callInit, (parsed) => { + session = parsed; + }); + await applyEffects(ctx, def, session); return result; } diff --git a/clients/typescript/packages/client/src/route.ts b/clients/typescript/packages/client/src/route.ts index d56fa544..122ba951 100644 --- a/clients/typescript/packages/client/src/route.ts +++ b/clients/typescript/packages/client/src/route.ts @@ -45,11 +45,11 @@ export type RouteDef = { defaults?: Partial; /** SDK input → wire body/query. Defaults to shallow camelCase → snake_case. */ serialize?: (input: I) => unknown; - /** Raw response → typed output. Ignored when `parseSession` is set. */ + /** Raw response → typed output. Used when `parseSession` is unset or returns `false`. */ parse?: (raw: unknown) => O; /** - * Parse the response as a session and store it when it contains a `user`. - * Set `skipStore` to return the parsed session without writing it. + * Parse the response with the client's `parseSession`. A returned session is + * stored unless `skipStore` is set. */ parseSession?: boolean; /** Resolve `path` from the client base path instead of the plugin base path. */ diff --git a/clients/typescript/packages/client/src/routes.ts b/clients/typescript/packages/client/src/routes.ts index a0f53b7a..3b028d41 100644 --- a/clients/typescript/packages/client/src/routes.ts +++ b/clients/typescript/packages/client/src/routes.ts @@ -7,6 +7,10 @@ export type VerifyEmailInput = { token: string; }; +export type RequestEmailVerificationInput = { + email?: string; +}; + export type ActiveSession = { id: string | number; token: string; @@ -36,12 +40,13 @@ export function coreClientPlugin() { path: "/revoke-sessions", clearSession: true, }), - route()({ + route>()({ method: "POST", path: "/verify-email", + parseSession: true, refetchSession: true, }), - route()({ + route()({ method: "POST", path: "/email-verifications", as: "requestEmailVerification", diff --git a/clients/typescript/packages/client/src/session-store.ts b/clients/typescript/packages/client/src/session-store.ts index 8a7da334..436ff33e 100644 --- a/clients/typescript/packages/client/src/session-store.ts +++ b/clients/typescript/packages/client/src/session-store.ts @@ -34,10 +34,11 @@ export function createSessionStore(options: CreateSessionStor loader: async () => { try { const raw = await options.fetch("/me", { method: "GET" }); - return options.parseSession(raw); + const session = options.parseSession(raw); + return session === false ? null : session; } catch (error) { // Not an error — the user is simply signed out. - if (error instanceof LimenError && error.isUnauthorized) { + if (error instanceof LimenError && error.is("unauthorized")) { return null; } throw error; diff --git a/clients/typescript/packages/client/src/types.ts b/clients/typescript/packages/client/src/types.ts index b876b72c..9e8ea9b7 100644 --- a/clients/typescript/packages/client/src/types.ts +++ b/clients/typescript/packages/client/src/types.ts @@ -16,10 +16,11 @@ export type CreateAuthClientOptions>; /** @@ -130,7 +131,7 @@ export type EnvelopeConfig = { fields?: EnvelopeFields; }; -export type ParseSession = (raw: unknown) => Session; +export type ParseSession = (raw: unknown) => Session | false; export type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; diff --git a/clients/typescript/packages/client/test/client-integration.test.ts b/clients/typescript/packages/client/test/client-integration.test.ts index 8c315492..a8b28442 100644 --- a/clients/typescript/packages/client/test/client-integration.test.ts +++ b/clients/typescript/packages/client/test/client-integration.test.ts @@ -60,6 +60,55 @@ describe("createAuthClient — session effects", () => { expect(session?.user.id).toBe("u1"); expect(auth.$session.get().data?.user.id).toBe("u1"); }); + + it("does not store a non-session sign-in body", async () => { + const { auth } = setup(() => ({ body: { two_factor_required: true } })); + + const result = await auth.signIn.credential({ credential: "ada@example.com", password: "pw" }); + + expect(result).toEqual({ twoFactorRequired: true }); + expect(auth.$session.get().data).toBeNull(); + }); + + it("stores a custom session shape when parseSession returns one", async () => { + const { impl } = mockFetch(() => ({ + body: { account: { id: "u9", email: "ada@example.com" } }, + })); + const auth = createAuthClient({ + baseURL: "http://localhost:8080", + plugins: [credentialPasswordPlugin()], + fetchOptions: { impl }, + crossTabSync: false, + refetchOnWindowFocus: false, + parseSession: (raw) => { + if (typeof raw !== "object" || raw === null || !("account" in raw)) { + return false; + } + const account = (raw as { account: { id: string; email: string } }).account; + return { user: { id: account.id, email: account.email, emailVerifiedAt: null } }; + }, + }); + + const session = await auth.signIn.credential({ credential: "ada@example.com", password: "pw" }); + + expect(session.user.id).toBe("u9"); + expect(auth.$session.get().data?.user.id).toBe("u9"); + }); + + it("treats a false parse of /me as signed out", async () => { + const { impl } = mockFetch(() => ({ body: { status: "anonymous" } })); + const auth = createAuthClient({ + baseURL: "http://localhost:8080", + plugins: [credentialPasswordPlugin()], + fetchOptions: { impl }, + crossTabSync: false, + refetchOnWindowFocus: false, + parseSession: () => false, + }); + + await expect(auth.getSession()).resolves.toBeNull(); + expect(auth.$session.get().data).toBeNull(); + }); }); describe("createAuthClient — custom parse / handlers", () => { diff --git a/clients/typescript/packages/client/test/fetcher.test.ts b/clients/typescript/packages/client/test/fetcher.test.ts index ee3a8dac..66bc74fa 100644 --- a/clients/typescript/packages/client/test/fetcher.test.ts +++ b/clients/typescript/packages/client/test/fetcher.test.ts @@ -37,14 +37,33 @@ describe("fetcher — timeout", () => { const err = (await auth.sessions().catch((e) => e)) as LimenError; expect(err).toBeInstanceOf(LimenError); expect(err.code).toBe("timeout"); - expect(err.isTimeout).toBe(true); + expect(err.is("timeout")).toBe(true); }); it("per-call timeout overrides the client default", async () => { const auth = makeClient(hangingImpl(), { timeout: 0 }); // client default disabled const err = (await auth.sessions(undefined, { timeout: 20 }).catch((e) => e)) as LimenError; expect(err).toBeInstanceOf(LimenError); - expect(err.isTimeout).toBe(true); + expect(err.is("timeout")).toBe(true); + }); +}); + +describe("fetcher — error codes", () => { + it("uses a known server code instead of the status-derived code", async () => { + const auth = makeClient( + staticImpl(JSON.stringify({ message: "email is not verified", code: "email_not_verified" }), { status: 403 }), + { timeout: 0 }, + ); + const err = (await auth.sessions().catch((e) => e)) as LimenError; + expect(err).toBeInstanceOf(LimenError); + expect(err.status).toBe(403); + expect(err.code).toBe("email_not_verified"); + }); + + it("derives forbidden when a 403 body has no code", async () => { + const auth = makeClient(staticImpl(JSON.stringify({ message: "nope" }), { status: 403 }), { timeout: 0 }); + const err = (await auth.sessions().catch((e) => e)) as LimenError; + expect(err.code).toBe("forbidden"); }); }); diff --git a/email_verification.go b/email_verification.go index 470e6d55..9b183f1a 100644 --- a/email_verification.go +++ b/email_verification.go @@ -56,29 +56,35 @@ func (c *LimenCore) SendEmailVerificationMail(user *User, verification *Verifica } // VerifyEmail validates the token, marks the user's email as verified, and -// deletes the consumed token -func (c *LimenCore) VerifyEmail(ctx context.Context, token string) error { +// deletes the consumed token. It returns the verified email address. +func (c *LimenCore) VerifyEmail(ctx context.Context, token string) (string, error) { verification, err := c.DBAction.FindValidVerificationByToken(ctx, token) if err != nil { - return ErrEmailVerificationTokenInvalid + return "", ErrEmailVerificationTokenInvalid } action, identifier := ParseVerificationAction(verification.Subject) if action != EmailVerificationAction { - return ErrEmailVerificationTokenInvalid + return "", ErrEmailVerificationTokenInvalid } + email := NormalizeEmail(identifier) now := time.Now() - return c.WithTransaction(ctx, func(ctx context.Context) error { + err = c.WithTransaction(ctx, func(ctx context.Context) error { if err := c.DBAction.UpdateUser(ctx, &User{EmailVerifiedAt: &now}, []Where{ - Eq(c.Schema.User.GetEmailField(), NormalizeEmail(identifier)), + Eq(c.Schema.User.GetEmailField(), email), }); err != nil { return err } return c.DBAction.DeleteVerificationToken(ctx, verification.Value) }) + if err != nil { + return "", err + } + + return email, nil } func (c *LimenCore) generateEmailVerificationToken(user *User) (string, error) { diff --git a/email_verification_challenge.go b/email_verification_challenge.go new file mode 100644 index 00000000..8a648423 --- /dev/null +++ b/email_verification_challenge.go @@ -0,0 +1,65 @@ +package limen + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +const emailVerificationChallengeType = "email_verification" + +type emailVerificationChallenge struct { + Email string `json:"email"` + Exp int64 `json:"exp"` + Type string `json:"type"` +} + +// EmailVerificationChallengeEnabled reports whether waiting-room cookies may be issued. +// Callers should only issue one when a session is withheld until the email is verified. +func (c *LimenCore) EmailVerificationChallengeEnabled() bool { + return c.EmailVerificationEnabled() && c.config.Email.verification.challengeEnabled +} + +// IssueEmailVerificationChallenge sets an encrypted cookie that can resend verification +// without a full session. +func (c *LimenCore) IssueEmailVerificationChallenge(w http.ResponseWriter, email string) error { + cfg := c.config.Email.verification + if !cfg.enabled { + return fmt.Errorf("email verification is not enabled") + } + + payload, err := json.Marshal(emailVerificationChallenge{ + Email: email, + Exp: time.Now().Add(cfg.challengeTTL).Unix(), + Type: emailVerificationChallengeType, + }) + if err != nil { + return err + } + + return c.cookies.SetSignedCookie(w, cfg.challengeCookieName, string(payload), int(cfg.challengeTTL.Seconds())) +} + +// ClearEmailVerificationChallenge removes the waiting-room cookie. +func (c *LimenCore) ClearEmailVerificationChallenge(w http.ResponseWriter) { + c.cookies.Delete(w, c.config.Email.verification.challengeCookieName) +} + +// ResolveEmailVerificationChallenge loads the user bound to a valid waiting-room cookie. +func (c *LimenCore) ResolveEmailVerificationChallenge(r *http.Request) (*User, error) { + raw, err := c.cookies.GetSignedCookie(r, c.config.Email.verification.challengeCookieName) + if err != nil { + return nil, ErrUnauthorized + } + + var payload emailVerificationChallenge + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, ErrUnauthorized + } + if payload.Type != emailVerificationChallengeType || time.Now().Unix() >= payload.Exp { + return nil, ErrUnauthorized + } + + return c.DBAction.FindUserByEmail(r.Context(), payload.Email) +} diff --git a/email_verification_challenge_test.go b/email_verification_challenge_test.go new file mode 100644 index 00000000..7b3d6da8 --- /dev/null +++ b/email_verification_challenge_test.go @@ -0,0 +1,33 @@ +package limen + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEmailVerificationChallenge_Expired(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + l := newTestLimenWithEmailVerification(t, + WithEmailVerification(WithVerificationChallengeTTL(time.Minute)), + ) + user := SeedTestUser(t, l, "expired@test.com") + + w := httptest.NewRecorder() + require.NoError(t, l.IssueEmailVerificationChallenge(w, user.Email)) + time.Sleep(time.Minute) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/auth/email-verifications", http.NoBody) + copyResponseCookies(t, w, req) + + _, err := l.ResolveEmailVerificationChallenge(req) + assert.ErrorIs(t, err, ErrUnauthorized) + }) +} diff --git a/email_verification_config.go b/email_verification_config.go index b054f8da..6ebe7919 100644 --- a/email_verification_config.go +++ b/email_verification_config.go @@ -8,11 +8,22 @@ type emailConfig struct { verification *emailVerificationConfig } +const ( + defaultEmailVerificationChallengeTTL = 30 * time.Minute + defaultEmailVerificationChallengeCookieName = "limen_email_verify" +) + type emailVerificationConfig struct { expiration time.Duration sendEmail func(email string, token string) generateToken func(*User) (string, error) enabled bool + + challengeEnabled bool + challengeTTL time.Duration + challengeCookieName string + requestByEmailEnabled bool + autoSignInAfterVerification bool } type EmailVerificationConfigOption func(*emailVerificationConfig) @@ -38,8 +49,11 @@ func WithEmailVerification(opts ...EmailVerificationConfigOption) EmailConfigOpt // NewDefaultEmailVerification creates a default email verification config. func NewDefaultEmailVerification(opts ...EmailVerificationConfigOption) *emailVerificationConfig { c := &emailVerificationConfig{ - expiration: 24 * time.Hour, - enabled: true, + expiration: 24 * time.Hour, + enabled: true, + challengeEnabled: true, + challengeTTL: defaultEmailVerificationChallengeTTL, + challengeCookieName: defaultEmailVerificationChallengeCookieName, } for _, opt := range opts { opt(c) @@ -76,3 +90,34 @@ func WithEmailVerificationTokenGenerator(fn func(*User) (string, error)) EmailVe c.generateToken = fn } } + +// WithEmailVerificationChallenge enables or disables the waiting-room cookie +// issued when sign-up or sign-in withholds a session until the email is verified. +// The cookie is on by default. +func WithEmailVerificationChallenge(enabled bool) EmailVerificationConfigOption { + return func(c *emailVerificationConfig) { + c.challengeEnabled = enabled + } +} + +// WithVerificationChallengeTTL sets how long the waiting-room cookie stays valid. +func WithVerificationChallengeTTL(d time.Duration) EmailVerificationConfigOption { + return func(c *emailVerificationConfig) { + c.challengeTTL = d + } +} + +// WithEmailVerificationRequestByEmail allows POST /email-verifications to accept +// a bare email with no session or challenge cookie. +func WithEmailVerificationRequestByEmail() EmailVerificationConfigOption { + return func(c *emailVerificationConfig) { + c.requestByEmailEnabled = true + } +} + +// WithAutoSignInAfterVerification creates a session after a successful verify. +func WithAutoSignInAfterVerification() EmailVerificationConfigOption { + return func(c *emailVerificationConfig) { + c.autoSignInAfterVerification = true + } +} diff --git a/email_verification_test.go b/email_verification_test.go index 0c8d5ebf..ca028014 100644 --- a/email_verification_test.go +++ b/email_verification_test.go @@ -42,8 +42,9 @@ func TestRequestEmailVerification_AlreadyVerified(t *testing.T) { verification, err := l.RequestEmailVerification(context.Background(), &User{Email: "verified@test.com"}, false) require.NoError(t, err) - err = l.VerifyEmail(context.Background(), verification.Value) + email, err := l.VerifyEmail(context.Background(), verification.Value) require.NoError(t, err) + require.Equal(t, "verified@test.com", email) _, err = l.RequestEmailVerification(context.Background(), &User{Email: "verified@test.com"}, false) assert.ErrorIs(t, err, ErrEmailAlreadyVerified) @@ -53,7 +54,8 @@ func TestVerifyEmail_InvalidToken(t *testing.T) { t.Parallel() l := newTestLimenWithEmailVerification(t) - err := l.VerifyEmail(context.Background(), "bad-token") + email, err := l.VerifyEmail(context.Background(), "bad-token") + require.Empty(t, email) assert.ErrorIs(t, err, ErrEmailVerificationTokenInvalid) } @@ -66,10 +68,13 @@ func TestVerifyEmail_TokenConsumed(t *testing.T) { verification, err := l.RequestEmailVerification(context.Background(), &User{Email: "consumed@test.com"}, false) require.NoError(t, err) - err = l.VerifyEmail(context.Background(), verification.Value) + email, err := l.VerifyEmail(context.Background(), verification.Value) + require.NoError(t, err) + require.Equal(t, "consumed@test.com", email) require.NoError(t, err) - err = l.VerifyEmail(context.Background(), verification.Value) + email, err = l.VerifyEmail(context.Background(), verification.Value) + require.Empty(t, email) assert.ErrorIs(t, err, ErrEmailVerificationTokenInvalid, "reusing a consumed token should fail") } diff --git a/errors.go b/errors.go index 3e86c9bd..c78a5d4f 100644 --- a/errors.go +++ b/errors.go @@ -7,6 +7,7 @@ import ( type LimenError struct { message string + code string details any status int } @@ -52,10 +53,18 @@ func NewLimenError(message string, status int, details any) *LimenError { return &LimenError{message: message, details: details, status: status} } +func NewLimenErrorWithCode(code, message string, status int, details any) *LimenError { + return &LimenError{code: code, message: message, details: details, status: status} +} + func (e *LimenError) Error() string { return e.message } +func (e *LimenError) Code() string { + return e.code +} + func (e *LimenError) Details() any { return e.details } diff --git a/limen.go b/limen.go index fc6ca61b..4c6b154e 100644 --- a/limen.go +++ b/limen.go @@ -146,10 +146,25 @@ func (a *Limen) RequestEmailVerification(ctx context.Context, user *User, should // VerifyEmail validates the token, marks the email as verified, and deletes // the consumed token. -func (a *Limen) VerifyEmail(ctx context.Context, token string) error { +func (a *Limen) VerifyEmail(ctx context.Context, token string) (string, error) { return a.core.VerifyEmail(ctx, token) } +// IssueEmailVerificationChallenge sets the waiting-room cookie for user. +func (a *Limen) IssueEmailVerificationChallenge(w http.ResponseWriter, email string) error { + return a.core.IssueEmailVerificationChallenge(w, email) +} + +// ClearEmailVerificationChallenge removes the waiting-room cookie. +func (a *Limen) ClearEmailVerificationChallenge(w http.ResponseWriter) { + a.core.ClearEmailVerificationChallenge(w) +} + +// ResolveEmailVerificationChallenge loads the user bound to a valid waiting-room cookie. +func (a *Limen) ResolveEmailVerificationChallenge(r *http.Request) (*User, error) { + return a.core.ResolveEmailVerificationChallenge(r) +} + // Use retrieves a registered plugin by name and returns it as type T. // It panics if the plugin is not registered or does not implement T. // diff --git a/limen_handlers.go b/limen_handlers.go index 5aaabcd6..4caa308c 100644 --- a/limen_handlers.go +++ b/limen_handlers.go @@ -1,6 +1,7 @@ package limen import ( + "errors" "net/http" ) @@ -35,7 +36,7 @@ func (h *limenHandlers) RegisterRoutes(routeBuilder *RouteBuilder) { if h.core.EmailVerificationEnabled() { routeBuilder.POST("/verify-email", "verify-email", h.VerifyEmail) - routeBuilder.ProtectedPOST("/email-verifications", "email-verifications", h.RequestEmailVerification) + routeBuilder.POST("/email-verifications", "email-verifications", h.RequestEmailVerification) } } @@ -90,31 +91,79 @@ func (h *limenHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) { return } - err := h.core.VerifyEmail(r.Context(), body["token"].(string)) + email, err := h.core.VerifyEmail(r.Context(), body["token"].(string)) if err != nil { h.responder.Error(w, r, err) return } - h.responder.JSON(w, r, http.StatusOK, "email verified successfully") -} + h.core.ClearEmailVerificationChallenge(w) + if !h.core.config.Email.verification.autoSignInAfterVerification { + h.responder.JSON(w, r, http.StatusOK, "email verified successfully") + return + } -func (h *limenHandlers) RequestEmailVerification(w http.ResponseWriter, r *http.Request) { - session, err := GetCurrentSessionFromCtx(r.Context()) + user, err := h.core.DBAction.FindUserByEmail(r.Context(), email) if err != nil { h.responder.Error(w, r, err) return } - _, err = h.core.RequestEmailVerification(r.Context(), &User{ - Email: session.User.Email, - }, true) + sessionResult, err := h.core.CreateSession(r.Context(), r, w, &AuthenticationResult{User: user}) if err != nil { h.responder.Error(w, r, err) return } + h.responder.SessionResponse(w, r, h.core, &AuthenticationResult{User: user}, sessionResult) +} + +func (h *limenHandlers) RequestEmailVerification(w http.ResponseWriter, r *http.Request) { + body := ValidateRequest(w, r, h.responder, func(v *Validator) { + v.Field("email").Optional().Email() + }) + if body == nil { + return + } - h.responder.JSON(w, r, http.StatusOK, "email verification requested successfully") + user, refreshed := h.provenEmailVerificationUser(r) + if user != nil { + _, err := h.core.RequestEmailVerification(r.Context(), &User{Email: user.Email}, true) + if err != nil { + h.responder.ErrorWithSession(w, r, err, refreshed) + return + } + h.responder.JSONWithSession(w, r, http.StatusOK, "email verification requested successfully", refreshed) + return + } + + if !h.core.config.Email.verification.requestByEmailEnabled { + h.responder.Error(w, r, ErrUnauthorized) + return + } + + if email, _ := body["email"].(string); email != "" { + _, err := h.core.RequestEmailVerification(r.Context(), &User{Email: email}, true) + if err != nil && !errors.Is(err, ErrRecordNotFound) && !errors.Is(err, ErrEmailAlreadyVerified) { + h.responder.Error(w, r, err) + return + } + } + + h.responder.JSON(w, r, http.StatusOK, "if the email address is associated with an account, "+ + "you will receive an email with instructions to verify it") +} + +func (h *limenHandlers) provenEmailVerificationUser(r *http.Request) (*User, *SessionResult) { + session, err := h.core.SessionManager.ValidateSession(r.Context(), r) + if err == nil && session != nil && session.User != nil { + return session.User, session.Refreshed + } + + user, err := h.core.ResolveEmailVerificationChallenge(r) + if err != nil { + return nil, nil + } + return user, nil } func (h *limenHandlers) SignOut(w http.ResponseWriter, r *http.Request) { diff --git a/limen_handlers_test.go b/limen_handlers_test.go index 6bd750cb..e43dd483 100644 --- a/limen_handlers_test.go +++ b/limen_handlers_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func newTestHandlersFromLimen(t *testing.T, l *Limen) *limenHandlers { @@ -167,3 +168,130 @@ func TestSignOut_WithoutSession(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) } + +func TestRequestEmailVerificationHandler_RejectsBareEmailByDefault(t *testing.T) { + t.Parallel() + + l := newTestLimenWithEmailVerification(t) + req := jsonRequest(t, http.MethodPost, "/auth/email-verifications", `{"email":"ghost@test.com"}`) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestRequestEmailVerificationHandler_RequestByEmailAlwaysOK(t *testing.T) { + t.Parallel() + + var sentTo string + l := newTestLimenWithEmailVerification(t, + WithEmailVerification( + WithEmailVerificationRequestByEmail(), + WithSendEmailVerificationMail(func(email, _ string) { + sentTo = email + }), + ), + ) + SeedTestUser(t, l, "unverified@test.com") + verified := SeedTestUser(t, l, "verified@test.com") + verification, err := l.RequestEmailVerification(t.Context(), verified, false) + require.NoError(t, err) + email, err := l.VerifyEmail(t.Context(), verification.Value) + require.NoError(t, err) + require.Equal(t, verified.Email, email) + + tests := []struct { + name string + email string + wantSentTo string + }{ + {name: "unverified account", email: "unverified@test.com", wantSentTo: "unverified@test.com"}, + {name: "unknown email", email: "ghost@test.com"}, + {name: "verified account", email: "verified@test.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sentTo = "" + req := jsonRequest(t, http.MethodPost, "/auth/email-verifications", `{"email":"`+tt.email+`"}`) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, tt.wantSentTo, sentTo) + }) + } +} + +func TestRequestEmailVerificationHandler_ProofOverridesBodyEmail(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + attachProof func(t *testing.T, l *Limen, owner *User, req *http.Request) + }{ + { + name: "challenge cookie", + attachProof: func(t *testing.T, l *Limen, owner *User, req *http.Request) { + issued := httptest.NewRecorder() + require.NoError(t, l.IssueEmailVerificationChallenge(issued, owner.Email)) + copyResponseCookies(t, issued, req) + }, + }, + { + name: "session", + attachProof: func(t *testing.T, l *Limen, owner *User, req *http.Request) { + req.AddCookie(SeedTestSession(t, l, owner.ID, owner.Email).Cookie) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var sentTo string + l := newTestLimenWithEmailVerification(t, + WithEmailVerification( + WithEmailVerificationRequestByEmail(), + WithSendEmailVerificationMail(func(email, _ string) { + sentTo = email + }), + ), + ) + owner := SeedTestUser(t, l, "owner@test.com") + SeedTestUser(t, l, "other@test.com") + + req := jsonRequest(t, http.MethodPost, "/auth/email-verifications", `{"email":"other@test.com"}`) + tt.attachProof(t, l, owner, req) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "owner@test.com", sentTo) + }) + } +} + +func TestVerifyEmailHandler_ClearsChallengeAndAutoSignsIn(t *testing.T) { + t.Parallel() + + l := newTestLimenWithEmailVerification(t, + WithEmailVerification(WithAutoSignInAfterVerification()), + ) + user := SeedTestUser(t, l, "autosign@test.com") + verification, err := l.RequestEmailVerification(t.Context(), user, false) + require.NoError(t, err) + + issued := httptest.NewRecorder() + require.NoError(t, l.IssueEmailVerificationChallenge(issued, user.Email)) + + req := jsonRequest(t, http.MethodPost, "/auth/verify-email", `{"token":"`+verification.Value+`"}`) + copyResponseCookies(t, issued, req) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.NotNil(t, findCookie(w, "limen_session")) + cleared := findCookie(w, defaultEmailVerificationChallengeCookieName) + require.NotNil(t, cleared) + assert.Negative(t, cleared.MaxAge) +} diff --git a/plugins/credential-password/authentication.go b/plugins/credential-password/authentication.go index c701478d..9b7452a8 100644 --- a/plugins/credential-password/authentication.go +++ b/plugins/credential-password/authentication.go @@ -42,6 +42,10 @@ func (p *credentialPasswordPlugin) authenticateUser(user *limen.User, password s return nil, ErrInvalidPassword } + if p.config.requireEmailVerification && !user.IsEmailVerified() { + return nil, emailNotVerifiedError{email: user.Email} + } + return &limen.AuthenticationResult{User: user}, nil } diff --git a/plugins/credential-password/errors.go b/plugins/credential-password/errors.go index eb3347d0..f00aa053 100644 --- a/plugins/credential-password/errors.go +++ b/plugins/credential-password/errors.go @@ -27,4 +27,14 @@ var ( ErrPasswordNotSet = limen.NewLimenError("password is not set", http.StatusForbidden, nil) ErrPasswordAlreadySet = limen.NewLimenError("password is already set", http.StatusForbidden, nil) ErrUsernameNotEnabled = limen.NewLimenError("username support is not enabled", http.StatusBadRequest, nil) + ErrEmailNotVerified = limen.NewLimenErrorWithCode("email_not_verified", "email is not verified", http.StatusForbidden, nil) ) + +// emailNotVerifiedError wraps ErrEmailNotVerified with the address that failed +// sign-in. +type emailNotVerifiedError struct { + email string +} + +func (e emailNotVerifiedError) Error() string { return ErrEmailNotVerified.Error() } +func (e emailNotVerifiedError) Unwrap() error { return ErrEmailNotVerified } diff --git a/plugins/credential-password/handlers.go b/plugins/credential-password/handlers.go index 172b55aa..76d28d05 100644 --- a/plugins/credential-password/handlers.go +++ b/plugins/credential-password/handlers.go @@ -69,6 +69,12 @@ func (p *credentialPasswordHandlers) SignInWithCredentialAndPassword(w http.Resp result, err := p.plugin.SignInWithCredentialAndPassword(r.Context(), body["credential"].(string), body["password"].(string)) if err != nil { + var unverified emailNotVerifiedError + if errors.As(err, &unverified) { + p.handleUnverifiedSignIn(w, r, unverified.email) + return + } + p.responder.Error(w, r, limen.NewLimenError(ErrInvalidCredential.Error(), ErrInvalidCredential.Status(), nil)) return } @@ -86,6 +92,34 @@ func (p *credentialPasswordHandlers) SignInWithCredentialAndPassword(w http.Resp p.responder.SessionResponse(w, r, p.plugin.core, result, sessionResult) } +// handleUnverifiedSignIn keeps the 403 and, when configured, issues a waiting-room +// cookie and resends the verification email. +func (p *credentialPasswordHandlers) handleUnverifiedSignIn(w http.ResponseWriter, r *http.Request, email string) { + if err := p.issueVerificationChallenge(w, email); err != nil { + p.responder.Error(w, r, err) + return + } + + if p.plugin.config.sendEmailVerificationOnSignIn { + _, err := p.plugin.core.RequestEmailVerification(r.Context(), &limen.User{Email: email}, true) + if err != nil && !errors.Is(err, limen.ErrEmailAlreadyVerified) { + p.responder.Error(w, r, err) + return + } + } + + p.responder.Error(w, r, ErrEmailNotVerified) +} + +// issueVerificationChallenge sets the waiting-room cookie when the session is +// withheld until the email is verified. +func (p *credentialPasswordHandlers) issueVerificationChallenge(w http.ResponseWriter, email string) error { + if !p.plugin.config.requireEmailVerification || !p.plugin.core.EmailVerificationChallengeEnabled() { + return nil + } + return p.plugin.core.IssueEmailVerificationChallenge(w, email) +} + func (p *credentialPasswordHandlers) isUsernameRequiredOnSignup() bool { return p.plugin.config.usernameRequiredOnSignup && p.plugin.config.enableUsername } @@ -125,7 +159,11 @@ func (p *credentialPasswordHandlers) SignUpWithCredentialAndPassword(w http.Resp return } - if !p.plugin.config.autoSignInOnSignUp { + if !p.plugin.config.autoSignInOnSignUp || p.plugin.config.requireEmailVerification { + if err := p.issueVerificationChallenge(w, result.User.Email); err != nil { + p.responder.Error(w, r, err) + return + } p.responder.SessionResponse(w, r, p.plugin.core, result, nil) return } diff --git a/plugins/credential-password/handlers_test.go b/plugins/credential-password/handlers_test.go new file mode 100644 index 00000000..0e8d4fb3 --- /dev/null +++ b/plugins/credential-password/handlers_test.go @@ -0,0 +1,55 @@ +package credentialpassword + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRequireEmailVerification_IssuesChallengeWithoutSession(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + seedUser bool + path string + body string + wantStatus int + }{ + { + name: "sign-up", + path: "/auth/signup/credential", + body: `{"email":"user@test.com","password":"Password1"}`, + wantStatus: http.StatusOK, + }, + { + name: "unverified sign-in", + seedUser: true, + path: "/auth/signin/credential", + body: `{"credential":"user@test.com","password":"Password1"}`, + wantStatus: http.StatusForbidden, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + l, plugin := newTestLimenAndPlugin(t, WithRequireEmailVerification(true)) + if tt.seedUser { + seedTestUser(t, plugin, "user@test.com", "Password1") + } + + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, newJSONRequest(t, tt.path, tt.body)) + + assert.Equal(t, tt.wantStatus, w.Code) + assert.NotNil(t, liveCookie(w, "limen_email_verify")) + assert.Nil(t, liveCookie(w, "limen_session")) + if tt.wantStatus == http.StatusForbidden { + assert.Contains(t, w.Body.String(), `"code":"email_not_verified"`) + } + }) + } +} diff --git a/plugins/credential-password/plugin.go b/plugins/credential-password/plugin.go index 72bc655f..baadb32a 100644 --- a/plugins/credential-password/plugin.go +++ b/plugins/credential-password/plugin.go @@ -46,6 +46,9 @@ type config struct { usernameValidationRegex *regexp.Regexp // Custom regex pattern for username validation usernameRequiredOnSignup bool // require username during sign up usernameValidationFunc func(username string) error // custom function to validate the username + requireEmailVerification bool + + sendEmailVerificationOnSignIn bool } // New returns a new config with the default values. @@ -64,6 +67,7 @@ func New(opts ...ConfigOption) *credentialPasswordPlugin { usernameMaxLength: defaultMaxUsernameLength, usernameValidationRegex: regexp.MustCompile(`^[a-zA-Z0-9_-]+$`), // alphanumeric, underscore, hyphen usernameRequiredOnSignup: false, + requireEmailVerification: false, } for _, opt := range opts { @@ -105,5 +109,9 @@ func (p *credentialPasswordPlugin) Initialize(core *limen.LimenCore) error { return fmt.Errorf("config is required") } + if !p.core.EmailVerificationEnabled() && p.config.requireEmailVerification { + return fmt.Errorf("credential-password: WithRequireEmailVerification needs limen.WithEmailVerification enabled") + } + return nil } diff --git a/plugins/credential-password/testutil_test.go b/plugins/credential-password/testutil_test.go index b0213926..47923ef4 100644 --- a/plugins/credential-password/testutil_test.go +++ b/plugins/credential-password/testutil_test.go @@ -2,6 +2,9 @@ package credentialpassword import ( "context" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/require" @@ -45,3 +48,20 @@ func seedOAuthTestUser(t *testing.T, plugin *credentialPasswordPlugin, email str require.NoError(t, err) return user } + +func newJSONRequest(t *testing.T, path, body string) *http.Request { + t.Helper() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +// liveCookie returns the named cookie unless the response deletes it. +func liveCookie(rec *httptest.ResponseRecorder, name string) *http.Cookie { + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == name && cookie.MaxAge >= 0 && cookie.Value != "" { + return cookie + } + } + return nil +} diff --git a/plugins/credential-password/types.go b/plugins/credential-password/types.go index 85c46bc9..3453459c 100644 --- a/plugins/credential-password/types.go +++ b/plugins/credential-password/types.go @@ -144,3 +144,20 @@ func WithUsernameField(columnName string) limen.SchemaConfigOption { limen.WithPluginFieldName(CredentialPasswordUserSchemaUsernameField, columnName), ) } + +// WithRequireEmailVerification sets whether to require email verification during sign in +// +// When enabled, the user will be required to verify their email before signing in. +func WithRequireEmailVerification(requireEmailVerification bool) ConfigOption { + return func(c *config) { + c.requireEmailVerification = requireEmailVerification + } +} + +// WithSendEmailVerificationOnSignIn resends the verification email when sign-in +// is blocked because the address is not verified. +func WithSendEmailVerificationOnSignIn() ConfigOption { + return func(c *config) { + c.sendEmailVerificationOnSignIn = true + } +} diff --git a/response.go b/response.go index 7cf87b84..bd2823df 100644 --- a/response.go +++ b/response.go @@ -121,6 +121,9 @@ func (rs *Responder) Error(w http.ResponseWriter, r *http.Request, err error) er rs.cfg.fields.Message: errMsg, } } + if code := ae.Code(); code != "" { + out["code"] = code + } return json.NewEncoder(w).Encode(out) } diff --git a/testutil_test.go b/testutil_test.go index 04bc6838..867e96eb 100644 --- a/testutil_test.go +++ b/testutil_test.go @@ -2,7 +2,9 @@ package limen import ( "net/http" + "net/http/httptest" "regexp" + "strings" "testing" "github.com/stretchr/testify/require" @@ -45,6 +47,30 @@ func newTestLimenWithSessionConfig(t *testing.T, opts ...SessionConfigOption) *L return l } +func jsonRequest(t *testing.T, method, path, body string) *http.Request { + t.Helper() + req := httptest.NewRequestWithContext(t.Context(), method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func copyResponseCookies(t *testing.T, rec *httptest.ResponseRecorder, req *http.Request) { + t.Helper() + for _, cookie := range rec.Result().Cookies() { + req.AddCookie(cookie) + } +} + +func findCookie(rec *httptest.ResponseRecorder, name string) *http.Cookie { + var found *http.Cookie + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == name { + found = cookie + } + } + return found +} + func newTestHTTPCore(t *testing.T, l *Limen) *LimenHTTPCore { t.Helper() return &LimenHTTPCore{ diff --git a/user.go b/user.go index a551a226..f19e2dce 100644 --- a/user.go +++ b/user.go @@ -22,6 +22,10 @@ func (c User) TableName() string { return string(UserSchemaTableName) } +func (u User) IsEmailVerified() bool { + return u.EmailVerifiedAt != nil +} + type UserSchema struct { BaseSchema // If true, the schema will include the first name and last name fields