diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index efa32ac9..106a4200 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + - Added support for a custom `userInfo` function in OAuth provider configuration, enabling callers to perform the user info request themselves. The `userInfo` option continues to accept either a URL string or an object with a `url` and optional request options (for example, custom headers). [#182](https://github.com/aura-stack-ts/auth/pull/182) --- diff --git a/packages/core/src/@types/api.ts b/packages/core/src/@types/api.ts index 8f387553..dc34b896 100644 --- a/packages/core/src/@types/api.ts +++ b/packages/core/src/@types/api.ts @@ -284,3 +284,22 @@ export interface UpdateSessionAPIOptions /** Programmatic session update result with redirect metadata and `toResponse()`. */ export type UpdateSessionAPIReturn = AuthActionAPIReturn> + +export interface SignUpAPIOptions = Record> + extends APIOptionsWithRedirectTo, APIOptionsWithRequest { + payload: Payload +} + +export type SignUpReturnData = + /** redirect: true & redirectTo: string */ + | { success: true; redirect: true; redirectURL: null } + /** redirect: false & redirectTo: string */ + | { success: true; redirect: false; redirectURL: string } + /** redirect: false & redirectTo: null | undefined (not set) */ + /** redirect: true & redirectTo: null | undefined (not set) */ + | { success: true; redirect: false; redirectURL: null } + /** Failed sign-up */ + | { success: false; redirect: false; redirectURL: null } + +/** Programmatic sign-up result with redirect metadata and `toResponse()`. */ +export type SignUpAPIReturn = AuthActionAPIReturn diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index ea302854..2f941f3e 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -8,12 +8,14 @@ import type { SerializeOptions } from "@aura-stack/router/cookie" import type { ConfigSchema, FromShapeToObject, Prettify } from "@/@types/utility.ts" import type { OAuthProviderCredentials, OAuthProviderRecord } from "@/@types/oauth.ts" import type { JWTKey, SessionConfig, SessionStrategy, User } from "@/@types/session.ts" +import type { InferSchema } from "@aura-stack/router" +import type { ZodObject } from "zod" /** * Main configuration interface for Aura Auth. * This is the user-facing configuration object passed to `createAuth()`. */ -export type AuthConfig = { +export type AuthConfig> = { /** * OAuth providers available in the authentication and authorization flows. It provides a type-inference * for the OAuth providers that are supported by Aura Stack Auth; alternatively, you can provide a custom @@ -157,6 +159,11 @@ export type AuthConfig = { * Credentials provider for username/password or similar authentication. */ credentials?: CredentialsProvider + /** + * Configuration for the signUp process, including the schema for validation + * and required callback for user creation. + */ + signUp?: SignUpConfig } & TrustedProxyHeadersConfig // @todo Should trustedOrigins support subdomain wildcards like `https://*.example.com`? @@ -337,7 +344,9 @@ export interface Logger { * Programmatic auth API returned with the auth instance: `getSession`, `signIn`, `signInCredentials`, `signOut`, `updateSession`. * Each method returns a result object plus `headers` and `toResponse()` for HTTP responses. */ -export type AuthAPI = ReturnType> +export type AuthAPI> = ReturnType< + typeof createAuthAPI +> /** JWT and crypto helpers bound to the configured identity schema (sign, verify, claims). */ export type JoseInstance = ReturnType> @@ -403,7 +412,7 @@ export interface CredentialsProvider { * Runtime context passed into auth actions and API handlers: OAuth map, cookies, JWT, session strategy, trusted origins, etc. * This is the fully resolved configuration surface after `createAuth` initializes defaults. */ -export interface RouterGlobalContext { +export interface RouterGlobalContext> { oauth: OAuthProviderRecord credentials?: CredentialsProvider cookies: CookieStoreConfig @@ -416,6 +425,7 @@ export interface RouterGlobalContext { logger?: InternalLogger sessionStrategy: SessionStrategy identity: SchemaRegistryContext + signUp?: SignUpConfig } export interface SchemaRegistryContext { @@ -433,11 +443,11 @@ export type AuthRuntimeConfig = RouterGlobalCon /** * Public auth instance: programmatic {@link AuthAPI}, {@link JoseInstance}, and HTTP {@link AuthClient} handlers. */ -export interface AuthInstance { +export interface AuthInstance> { /** * Programmatic API for authentication actions (getSession, signIn, signOut, etc.) that can be used in server-side contexts or API routes. */ - api: AuthAPI + api: AuthAPI /** * JOSE helper functions for signin, encryption and verification of JWTs. */ @@ -456,9 +466,35 @@ export interface AuthInstance { /** * Extended context used inside the library with both secure and standard cookie materializations. */ -export type InternalContext = RouterGlobalContext & User> & { +export type InternalContext = RouterGlobalContext< + FromShapeToObject, + SignUpSchema +> & { cookieConfig: { secure: CookieStoreConfig standard: CookieStoreConfig } } + +export interface OnCreateUserContext { + payload: InferSchema +} + +/** + * Configuration for the signUp process, including the schema for validation + * and required callback for user creation. + */ +export interface SignUpConfig { + /** + * Optional schema for validating the sign-up payload. It supports any + * Zod, Arktype, Valibot or Typebox schema. + */ + schema?: SignUpSchema + /** + * Callback function that is called when a new user signs up. It receives the validated + * sign-up payload and must handle the user creation. + */ + onCreateUser: ( + context: OnCreateUserContext + ) => Promise | null> | FromShapeToObject | null +} diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 8dd66ea2..470e33d1 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -5,3 +5,4 @@ export { sessionAction } from "@/actions/session/session.ts" export { signOutAction } from "@/actions/signOut/signOut.ts" export { csrfTokenAction } from "@/actions/csrfToken/csrfToken.ts" export { updateSessionAction } from "@/actions/updateSession/updateSession.ts" +export { signUpAction } from "@/actions/signUp/signUp.ts" diff --git a/packages/core/src/actions/signIn/authorization.ts b/packages/core/src/actions/signIn/authorization.ts index b2d5628d..704544fe 100644 --- a/packages/core/src/actions/signIn/authorization.ts +++ b/packages/core/src/actions/signIn/authorization.ts @@ -3,7 +3,7 @@ import { AuthInternalError } from "@/shared/errors.ts" import { equals, extractPath, patternToRegex } from "@/shared/utils.ts" import { isRelativeURL, isSameOrigin, isValidURL, isTrustedOrigin } from "@/shared/assert.ts" import type { AuthConfig } from "@/@types/index.ts" -import type { Identities } from "@/shared/identity.ts" +import type { Identities, SchemaTypes } from "@/shared/identity.ts" import type { GlobalContext } from "@aura-stack/router" /** @@ -11,7 +11,7 @@ import type { GlobalContext } from "@aura-stack/router" */ export const getTrustedOrigins = async ( request: Request, - trustedOrigins: AuthConfig["trustedOrigins"] + trustedOrigins: AuthConfig["trustedOrigins"] ): Promise => { if (!trustedOrigins) return [] const raw = typeof trustedOrigins === "function" ? await trustedOrigins(request) : trustedOrigins diff --git a/packages/core/src/actions/signUp/signUp.ts b/packages/core/src/actions/signUp/signUp.ts new file mode 100644 index 00000000..ec11cd76 --- /dev/null +++ b/packages/core/src/actions/signUp/signUp.ts @@ -0,0 +1,40 @@ +import { z } from "zod" +import { signUp } from "@/api/signUp.ts" +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" +import { RedirectOptionsSchema } from "@/schemas.ts" +import type { SignUpConfig } from "@/@types/config.ts" + +const signUpConfig = (config: SignUpConfig) => { + return createEndpointConfig({ + schemas: { + body: config?.schema ?? z.object({}), + searchParams: RedirectOptionsSchema, + }, + }) +} + +/** + * Handles the user sign-up process. It validates the incoming request against the provided schema, + * creates a new user using the `onCreateUser` callback. + * + * @returns The signed-up user's session + */ +export const signUpAction = (config: SignUpConfig) => { + return createEndpoint( + "POST", + "/signUp", + async (ctx) => { + const payload = ctx.body + const { toResponse } = await signUp({ + ctx: ctx.context, + payload, + request: ctx.request, + headers: ctx.request.headers, + redirect: ctx.searchParams.redirect, + redirectTo: ctx.searchParams.redirectTo, + }) + return toResponse() + }, + signUpConfig(config) + ) +} diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index c37ab830..e7245c1d 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -1,5 +1,5 @@ -import { getSession, signIn, signInCredentials, signOut, updateSession } from "@/api/index.ts" -import type { GlobalContext } from "@aura-stack/router" +import { getSession, signIn, signInCredentials, signOut, updateSession, signUp } from "@/api/index.ts" +import type { GlobalContext, InferSchema } from "@aura-stack/router" import type { BuiltInOAuthProvider, LiteralUnion, @@ -14,9 +14,22 @@ import type { SignInCredentialsAPIReturn, SignOutAPIReturn, UpdateSessionAPIReturn, + SignUpAPIOptions, + SignUpAPIReturn, + Wrap, } from "@/@types/index.ts" +import type { ZodObject } from "zod" +import type { SchemaTypes } from "@/shared/identity.ts" -export const createAuthAPI = (ctx: GlobalContext) => { +type InferSignUp = Wrap>> + +type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K] +} + +export const createAuthAPI = >( + ctx: GlobalContext +) => { return { /** * Retrieves the current session data from the server-side. @@ -64,6 +77,30 @@ export const createAuthAPI = (ctx: GlobalContex signInCredentials: async (options: SignInCredentialsAPIOptions): Promise => { return signInCredentials({ ctx, ...options }) }, + /** + * Signs up a new user on the server-side. It requires a `payload` with the necessary information for + * user creation and a callback function configured in `signUp.onCreateUser` to handle the actual user + * creation logic. + * + * @params options - Options for the API call, including the sign-up payload, headers, and redirect behavior. + * @return The object returned by the API call {@link SignUpAPIReturn} + * @example + * const response = await api.signUp({ + * payload: { + * name: "John", + * lastName: "Doe", + * email: "john.doe@example.com", + * password: "1234567890" + * }, + * redirectTo: "/dashboard", + * request: await getRequest() + * }) + */ + signUp: async = InferSignUp>( + options: SignUpAPIOptions + ): Promise => { + return signUp({ ctx, ...options }) + }, /** * Updates the current session on the server-side. It allows partial updates to the session object, such as * modifying user fields or extending the session expiry. It implements CSRF Protection by default, for diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 668d410c..fa4033aa 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -4,3 +4,4 @@ export { signInCredentials } from "@/api/credentials.ts" export { signOut } from "@/api/signOut.ts" export { getSession } from "@/api/getSession.ts" export { updateSession } from "@/api/updateSession.ts" +export { signUp } from "@/api/signUp.ts" diff --git a/packages/core/src/api/signUp.ts b/packages/core/src/api/signUp.ts new file mode 100644 index 00000000..26b03592 --- /dev/null +++ b/packages/core/src/api/signUp.ts @@ -0,0 +1,93 @@ +import { createRedirectTo, getBaseURL, getOriginURL } from "@/actions/signIn/authorization.ts" +import type { FunctionAPIContext, SignUpAPIOptions, SignUpAPIReturn } from "@/@types/api.ts" +import { AuthValidationError, isAuthErrorWithCode } from "@/shared/errors.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { HeadersBuilder } from "@aura-stack/router" +import { secureApiHeaders } from "@/shared/headers.ts" + +export const signUp = async = Record>({ + ctx, + payload, + headers: headersInit, + request: requestInit, + redirect = true, + redirectTo, +}: FunctionAPIContext>): Promise => { + const { signUp, cookies, sessionStrategy, logger } = ctx + try { + let request = requestInit + if (!request) { + const origin = await getBaseURL({ ctx, headers: headersInit }) + const url = `${origin}${ctx.basePath}/signUp` + request = new Request(url, { headers: headersInit }) + } + await getOriginURL(request, ctx) + const user = await signUp?.onCreateUser({ + payload, + }) + if (!user) { + throw new AuthValidationError("USER_CREATION_FAILED", "Failed to create user with the provided payload.") + } + const sessionToken = await sessionStrategy.createSession(user) + const csrfToken = await createCSRF(ctx.jose) + logger?.log("SIGN_UP_SUCCESS") + + const headers = new HeadersBuilder(secureApiHeaders) + .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) + .setCookie(cookies.sessionToken.name, sessionToken, cookies.sessionToken.attributes) + + let redirectURL: string | null = await createRedirectTo(request, redirectTo, ctx) + redirectURL = redirectTo ? redirectURL : redirectURL === "/" ? null : redirectURL + + if (redirect && redirectURL) { + headers.setHeader("Location", redirectURL) + } + + const shouldRedirectServer = redirect && !!redirectURL + const toHeaders = headers.toHeaders() + return { + success: true, + redirect: shouldRedirectServer, + redirectURL: redirect ? null : redirectURL, + headers: toHeaders, + toResponse: () => { + return Response.json( + { + success: true, + redirect: shouldRedirectServer, + redirectURL: shouldRedirectServer ? null : redirectURL, + }, + { headers: toHeaders, status: shouldRedirectServer ? 302 : 200 } + ) + }, + } as SignUpAPIReturn + } catch (error) { + let code = "SIGN_UP_ERROR" + let message = "An error occurred during sign-up." + if (isAuthErrorWithCode(error)) { + code = error.code + message = error.message + } + + return { + success: false, + error: { + code, + message, + }, + redirect: false, + headers: new Headers(secureApiHeaders), + redirectURL: null, + toResponse: () => { + return Response.json( + { + success: false, + redirect: false, + redirectURL: null, + }, + { headers: secureApiHeaders, status: 400 } + ) + }, + } + } +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 664a9a2a..3633ea06 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -124,6 +124,7 @@ export const createAuthClient = (options: AuthC const response = await client.patch("/session", { // @ts-ignore - Fix type here - go to @aura-stack/router. body: { + // @ts-ignore - Fix type here - go to @aura-stack/router. user, expires: session.expires ? new Date(session.expires) : undefined, }, diff --git a/packages/core/src/createAuth.ts b/packages/core/src/createAuth.ts index d295e9cb..80d4f298 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -11,12 +11,16 @@ import { signOutAction, csrfTokenAction, updateSessionAction, + signUpAction, } from "@/actions/index.ts" -import type { EditableShape, Identities, UserShape } from "@/shared/identity.ts" -import type { AuthConfig, AuthInstance, FromShapeToObject, SchemaRegistryContext } from "@/@types/index.ts" +import type { ZodObject } from "zod" +import type { EditableShape, Identities, SchemaTypes, UserShape } from "@/shared/identity.ts" +import type { AuthConfig, AuthInstance, FromShapeToObject, SchemaRegistryContext, SignUpConfig } from "@/@types/index.ts" -const createInternalConfig = (config?: AuthConfig): RouterConfig => { - const context = createContext(config) +const createInternalConfig = ( + config?: AuthConfig +): RouterConfig => { + const context = createContext(config) return { basePath: config?.basePath ?? "/auth", onError: createErrorHandler(context.logger), @@ -31,8 +35,10 @@ const createInternalConfig = (config?: AuthConfig(authConfig: AuthConfig) => { - const config = createInternalConfig(authConfig) +export const createAuthInstance = ( + authConfig: AuthConfig +) => { + const config = createInternalConfig(authConfig) const router = createRouter( [ signInAction(config.context.oauth), @@ -42,6 +48,7 @@ export const createAuthInstance = (authConfig: Auth signOutAction, csrfTokenAction, updateSessionAction(config.context.identity as SchemaRegistryContext), + signUpAction(config.context.signUp as SignUpConfig), ], config ) @@ -49,7 +56,7 @@ export const createAuthInstance = (authConfig: Auth return { handlers: router, jose: config.context.jose, - api: createAuthAPI(config.context), + api: createAuthAPI, SignUpSchema>(config.context), } } @@ -76,8 +83,16 @@ export const createAuthInstance = (authConfig: Auth * }] * }) */ -export const createAuth = >(config: AuthConfig) => { - const authInstance = createAuthInstance(config) as unknown as AuthInstance> +export const createAuth = < + Identity extends Identities = EditableShape, + SignUpSchema extends SchemaTypes = ZodObject, +>( + config: AuthConfig +) => { + const authInstance = createAuthInstance(config) as unknown as AuthInstance< + FromShapeToObject, + SignUpSchema + > authInstance.handlers.ALL = async (request: Request) => { const method = request.method.toUpperCase() const methodHandlers = { diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 8789353c..c4f5c683 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -5,10 +5,12 @@ import { createSessionStrategy } from "@/session/strategy.ts" import { createSchemaRegistry } from "@/validator/registry.ts" import { createBuiltInOAuthProviders } from "@/oauth/index.ts" import { getEnv, getEnvArray, getEnvBoolean } from "@/shared/env.ts" -import type { Identities } from "@/shared/identity.ts" +import type { Identities, SchemaTypes } from "@/shared/identity.ts" import type { AuthConfig, InternalContext, FromShapeToObject } from "@/@types/index.ts" -export const createContext = (config?: AuthConfig) => { +export const createContext = ( + config?: AuthConfig +) => { const trustedProxyHeadersEnv = getEnv("TRUSTED_PROXY_HEADERS") const useProxyHeaders = trustedProxyHeadersEnv === undefined ? (config?.trustedProxyHeaders ?? false) : getEnvBoolean("TRUSTED_PROXY_HEADERS") @@ -45,7 +47,8 @@ export const createContext = (config?: AuthConfig + signUp: config?.signUp, + } as InternalContext ctx.sessionStrategy = createSessionStrategy({ cookies: () => ctx.cookies, jose: ctx.jose, diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index e59e38d8..49e6cc7a 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -1,5 +1,5 @@ import { getEnv, getEnvBoolean } from "@/shared/env.ts" -import type { Identities } from "./identity.ts" +import type { Identities, SchemaTypes } from "./identity.ts" import type { AuthConfig, InternalLogger, Logger, LogLevel, SyslogOptions } from "@/@types/index.ts" /** @@ -309,6 +309,12 @@ export const logMessages = { msgId: "CREDENTIALS_SIGN_IN_FAILED", message: "An error occurred during credentials sign-in", }, + SIGN_UP_SUCCESS: { + facility: 4, + severity: "info", + msgId: "SIGN_UP_SUCCESS", + message: "User successfully signed up and authenticated", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { @@ -389,7 +395,9 @@ export const createLogger = (logger?: Required): InternalLogger | undefi * Creates the logger instance based on the provided configuration and environment variables. * Priority: config.logger, LOG_LEVEL env, DEBUG env and defaults to undefined if logging is not enabled. */ -export const createProxyLogger = (config?: AuthConfig) => { +export const createProxyLogger = ( + config?: AuthConfig +) => { const level = getEnv("LOG_LEVEL") const debug = getEnvBoolean("DEBUG") if (typeof config?.logger === "object") { diff --git a/packages/core/test/actions/signUp/signUp.test.ts b/packages/core/test/actions/signUp/signUp.test.ts new file mode 100644 index 00000000..c29e0ea9 --- /dev/null +++ b/packages/core/test/actions/signUp/signUp.test.ts @@ -0,0 +1,225 @@ +import { describe, test, expect, beforeEach, vi, afterEach } from "vitest" +import { z } from "zod/v4" +import { POST } from "@test/presets.ts" +import { createAuth } from "@/createAuth.ts" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +const payload = { + name: "johndoe", + email: "john@example.com", + image: "https://example.com/image.jpg", + password: "1234567890", +} + +describe("signUp API", () => { + test("success signUp flow", async () => { + const response = await POST( + new Request("https://example.com/auth/signUp", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + }) + + test("invalid signUp.onCreateUser return", async () => { + const { handlers } = createAuth({ + oauth: [], + signUp: { + onCreateUser: () => null, + }, + }) + + const response = await handlers.POST( + new Request("https://example.com/auth/signUp", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + ) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + }) + + test("invalid signUp.onCreateUser return with custom schema", async () => { + const { handlers } = createAuth({ + oauth: [], + signUp: { + schema: z.object({ + name: z.string(), + lastName: z.string(), + email: z.string().email(), + password: z.string(), + }), + onCreateUser: () => null, + }, + }) + const response = await handlers.POST( + new Request("https://example.com/auth/signUp", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "John Doe", + lastName: "Doe", + password: "1234567890", + }), + }) + ) + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + type: "ROUTER_ERROR", + code: "INVALID_REQUEST", + message: { + email: { + code: "invalid_type", + message: "Invalid input: expected string, received undefined", + }, + }, + }) + }) + + test("valid signUp.onCreateUser return with custom schema", async () => { + const { handlers } = createAuth({ + oauth: [], + signUp: { + schema: z.object({ + name: z.string(), + lastName: z.string(), + email: z.string().email(), + password: z.string(), + }), + onCreateUser: ({ payload }) => ({ + sub: "1234567890", + email: payload.email, + name: payload.name, + image: "https://example.com/image.jpg", + }), + }, + }) + + const response = await handlers.POST( + new Request("https://example.com/auth/signUp", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "John Doe", + lastName: "Doe", + email: "john@example.com", + password: "1234567890", + }), + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + }) + + test("signUp with redirect: true and redirectTo", async () => { + const response = await POST( + new Request("https://example.com/auth/signUp?redirect=true&redirectTo=/dashboard", { + method: "POST", + body: JSON.stringify(payload), + }) + ) + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/dashboard") + expect(await response.json()).toEqual({ + success: true, + redirect: true, + redirectURL: null, + }) + }) + + test("signUp with redirect: false", async () => { + const response = await POST( + new Request("https://example.com/auth/signUp?redirect=false", { + method: "POST", + body: JSON.stringify(payload), + }) + ) + expect(response.status).toBe(200) + expect(response.headers.get("Location")).toBeNull() + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + }) + + test("signUp with redirect: false and redirectTo", async () => { + const response = await POST( + new Request("https://example.com/auth/signUp?redirect=false&redirectTo=/dashboard", { + method: "POST", + body: JSON.stringify(payload), + }) + ) + expect(response.status).toBe(200) + expect(response.headers.get("Location")).toBeNull() + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: "/dashboard", + }) + }) + + test("signUp with redirect: true and invalid redirectTo", async () => { + const response = await POST( + new Request("https://example.com/auth/signUp?redirect=true&redirectTo=http://malicious.com", { + method: "POST", + body: JSON.stringify(payload), + }) + ) + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/") + expect(await response.json()).toEqual({ + success: true, + redirect: true, + redirectURL: null, + }) + }) + + test("signUp with redirect: false and invalid redirectTo", async () => { + const response = await POST( + new Request("https://example.com/auth/signUp?redirect=false&redirectTo=http://malicious.com", { + method: "POST", + body: JSON.stringify(payload), + }) + ) + expect(response.status).toBe(200) + expect(response.headers.get("Location")).toBeNull() + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: "/", + }) + }) +}) diff --git a/packages/core/test/api/signInCredentials.test.ts b/packages/core/test/api/signInCredentials.test.ts index e01e5896..ea5099fa 100644 --- a/packages/core/test/api/signInCredentials.test.ts +++ b/packages/core/test/api/signInCredentials.test.ts @@ -162,18 +162,19 @@ describe("signInCredentials API", () => { username: "johndoe", password: "1234567890", }, + redirect: false, redirectTo: "https://example.com/dashboard", }) expect(signIn).toEqual({ success: true, - redirect: true, - redirectURL: null, + redirect: false, + redirectURL: "/dashboard", headers: expect.any(Headers), toResponse: expect.any(Function), }) }) - test("signIn with invalid redirectTo", async () => { + test("signIn redirect: true and invalid redirectTo", async () => { vi.stubEnv("BASE_URL", "https://example.com") const signIn = await api.signInCredentials({ @@ -181,8 +182,10 @@ describe("signInCredentials API", () => { username: "johndoe", password: "1234567890", }, + redirect: true, redirectTo: "https://malicious.com/phishing", }) + expect(signIn.headers.get("Location")).toBe("/") expect(signIn).toEqual({ success: true, redirect: true, @@ -191,4 +194,25 @@ describe("signInCredentials API", () => { toResponse: expect.any(Function), }) }) + + test("signIn with redirect: false and invalid redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const signIn = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + }, + redirect: false, + redirectTo: "https://malicious.com/phishing", + }) + expect(signIn.headers.get("Location")).toBeNull() + expect(signIn).toEqual({ + success: true, + redirect: false, + redirectURL: "/", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) }) diff --git a/packages/core/test/api/signUp.test.ts b/packages/core/test/api/signUp.test.ts new file mode 100644 index 00000000..f405d905 --- /dev/null +++ b/packages/core/test/api/signUp.test.ts @@ -0,0 +1,206 @@ +import { describe, test, expect, beforeEach, vi, afterEach } from "vitest" +import { getSetCookie } from "@/cookie.ts" +import { api, jose } from "@test/presets.ts" +import { createAuth } from "@/createAuth.ts" +import type { User } from "@/index.ts" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +const payload = { + name: "johndoe", + email: "john@example.com", + image: "https://example.com/image.jpg", + password: "1234567890", +} + +describe("signUp API", () => { + test("success signUp flow", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const signUp = await api.signUp({ + payload, + }) + expect(signUp).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + const decode = await jose.decodeJWT(getSetCookie(signUp.headers, "aura-auth.session_token")!) + expect(decode).toMatchObject({ + sub: "1234567890", + email: "john@example.com", + name: "johndoe", + image: "https://example.com/image.jpg", + }) + }) + + test("invalid signUp.onCreateUser return", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const { api } = createAuth({ + oauth: [], + signUp: { + onCreateUser: () => null, + }, + }) + const output = await api.signUp({ + payload, + }) + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "USER_CREATION_FAILED", + message: "Failed to create user with the provided payload.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("invalid signUp.onCreateUser by missing required fields", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const { api } = createAuth({ + oauth: [], + signUp: { + onCreateUser: () => + ({ + name: "John Doe", + email: "johndoe@example.com", + }) as User, + }, + }) + const output = await api.signUp({ + payload, + }) + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + error: { + code: "INVALID_IDENTITY_VALIDATION_FAILED", + message: expect.any(String), + }, + toResponse: expect.any(Function), + }) + }) + + test("signUp without URL configuration", async () => { + const signUp = await api.signUp({ + payload: {}, + }) + expect(signUp).toEqual({ + success: false, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + error: { + code: "INVALID_OAUTH_CONFIGURATION", + message: + "The URL cannot be constructed. Please set the BASE_URL environment variable or enable trustedProxyHeaders.", + }, + toResponse: expect.any(Function), + }) + }) + + test("signUp with redirect: true and redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + redirect: true, + redirectTo: "/dashboard", + }) + expect(output.headers.get("Location")).toBe("/dashboard") + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp with redirect: true and absolute redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + redirect: true, + redirectTo: "https://example.com/dashboard", + }) + expect(output.headers.get("Location")).toBe("/dashboard") + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp with redirect: false and valid redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + redirect: false, + redirectTo: "/dashboard", + }) + expect(output.headers.get("Location")).toBeNull() + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/dashboard", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp redirect: true and invalid redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + redirect: true, + redirectTo: "https://malicious.com/dashboard", + }) + expect(output.headers.get("Location")).toBe("/") + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp redirect: false and invalid redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + redirect: false, + redirectTo: "https://malicious.com/dashboard", + }) + expect(output.headers.get("Location")).toBeNull() + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) +}) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index ffcb971a..69631f26 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -1,6 +1,6 @@ import { createAuth } from "@/createAuth.ts" import type { JWTPayload } from "@/jose.ts" -import type { OAuthProviderCredentials } from "@/@types/index.ts" +import type { OAuthProviderCredentials, User } from "@/@types/index.ts" export const oauthCustomService: OAuthProviderCredentials = { id: "oauth-provider", @@ -50,6 +50,17 @@ const auth = createAuth({ } }, }, + signUp: { + onCreateUser: ({ payload }) => { + const { name, email, image } = payload + return { + sub: "1234567890", + name, + email, + image, + } as User + }, + }, }) export const { diff --git a/packages/core/test/types.test-d.ts b/packages/core/test/types.test-d.ts index 9767a119..a5750b8a 100644 --- a/packages/core/test/types.test-d.ts +++ b/packages/core/test/types.test-d.ts @@ -8,7 +8,6 @@ import { UserIdentityArkType, UserIdentityTypeBox, UserIdentityValibot, - type Identities, type UserShapeValibot, } from "@/shared/identity.ts" import { github, type GitHubProfile } from "@/oauth/github.ts" @@ -16,28 +15,19 @@ import type { GetSessionAPIOptions, GetSessionAPIReturn, Session, + SignUpAPIOptions, + SignUpAPIReturn, UpdateSessionAPIOptions, UpdateSessionAPIReturn, UserShape, } from "@/@types/index.ts" -import type { AuthConfig, AuthInstance, User } from "@/index.ts" +import type { AuthInstance, User } from "@/index.ts" import type { OAuthProviderCredentials } from "@/@types/oauth.ts" -import type { - EditableShape, - FromShapeToObject, - InferSession, - InferUser, - ValibotShapeToObject, - ZodShapeToObject, -} from "@/@types/utility.ts" +import type { InferSession, InferUser, ValibotShapeToObject, ZodShapeToObject } from "@/@types/utility.ts" import type { JWTHeaderParameters, JWTVerifyOptions, Prettify, TypedJWTPayload } from "@aura-stack/jose" +import { type } from "arktype" describe("createAuth", () => { - expectTypeOf(createAuth).toEqualTypeOf< - >( - config: AuthConfig - ) => AuthInstance> - >() expectTypeOf(createAuth({ oauth: [] }).api.getSession).toEqualTypeOf< (options: GetSessionAPIOptions) => Promise>> >() @@ -275,6 +265,69 @@ describe("createAuth", () => { > > >().toEqualTypeOf>>() + + expectTypeOf( + createAuth({ + oauth: [], + signUp: { + onCreateUser: () => null, + }, + }).api.signUp + ).toEqualTypeOf< = {}>(options: SignUpAPIOptions) => Promise>() + expectTypeOf( + createAuth({ + oauth: [], + signUp: { + schema: z.object({ + name: z.string(), + lastName: z.string(), + email: z.string().email(), + password: z.string().min(8), + }), + onCreateUser: () => null, + }, + }).api.signUp + ).toEqualTypeOf< + = { name: string; lastName: string; email: string; password: string }>( + options: SignUpAPIOptions + ) => Promise + >() + expectTypeOf( + createAuth({ + oauth: [], + signUp: { + schema: valibot.object({ + name: valibot.string(), + lastName: valibot.string(), + email: valibot.pipe(valibot.string(), valibot.email()), + password: valibot.pipe(valibot.string(), valibot.minLength(8)), + }), + onCreateUser: () => null, + }, + }).api.signUp + ).toEqualTypeOf< + = { name: string; lastName: string; email: string; password: string }>( + options: SignUpAPIOptions + ) => Promise + >() + expectTypeOf( + createAuth({ + oauth: [], + signUp: { + schema: type({ + name: "string", + lastName: "string", + email: "string", + password: "string", + }), + onCreateUser: () => null, + }, + }).api.signUp + ).toEqualTypeOf< + = { name: string; lastName: string; email: string; password: string }>( + options: SignUpAPIOptions + ) => Promise + >() }) describe("OAuth providers", () => { diff --git a/packages/elysia/CHANGELOG.md b/packages/elysia/CHANGELOG.md index e159b8f6..f22a661c 100644 --- a/packages/elysia/CHANGELOG.md +++ b/packages/elysia/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + --- ## [0.1.0] - 2026-06-05 diff --git a/packages/express/CHANGELOG.md b/packages/express/CHANGELOG.md index 8ca2c6aa..441a6949 100644 --- a/packages/express/CHANGELOG.md +++ b/packages/express/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + --- ## [0.1.0] - 2026-06-05 diff --git a/packages/hono/CHANGELOG.md b/packages/hono/CHANGELOG.md index 9a99e20a..8993db76 100644 --- a/packages/hono/CHANGELOG.md +++ b/packages/hono/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + --- ## [0.1.0] - 2026-06-05 diff --git a/packages/next/CHANGELOG.md b/packages/next/CHANGELOG.md index 45d553bf..1be69802 100644 --- a/packages/next/CHANGELOG.md +++ b/packages/next/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + --- ## [0.1.0] - 2026-06-05 diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 920deb82..faf73955 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + --- ## [0.1.0] - 2026-06-05 diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index e935acc8..a159efb6 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Introduced an experimental `signUp` flow for both the API and endpoint definitions. The new action enables user account creation within the authentication system and provides customizable payload validation through the supported schema. To enable this feature, developers must configure the `signUp` option when calling `createAuth`. [#183](https://github.com/aura-stack-ts/auth/pull/183) + --- ## [0.1.0] - 2026-06-05