From 812271e389383c8e17c1742a33105572b3c345e3 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Tue, 9 Jun 2026 18:29:38 -0500 Subject: [PATCH 1/5] feat(core): add experimental signUp flow API and endpoint --- packages/core/src/@types/api.ts | 19 +++++ packages/core/src/@types/config.ts | 35 +++++++- packages/core/src/actions/index.ts | 1 + .../core/src/actions/signIn/authorization.ts | 2 +- packages/core/src/actions/signUp/signUp.ts | 42 ++++++++++ packages/core/src/api/createApi.ts | 35 +++++++- packages/core/src/api/index.ts | 1 + packages/core/src/api/signUp.ts | 83 +++++++++++++++++++ packages/core/src/client/client.ts | 2 +- packages/core/src/createAuth.ts | 29 +++++-- packages/core/src/router/context.ts | 7 +- packages/core/src/shared/identity.ts | 2 + packages/core/src/shared/logger.ts | 12 ++- packages/core/test/api/signUp.test.ts | 72 ++++++++++++++++ packages/core/test/presets.ts | 15 ++++ packages/core/test/types.test-d.ts | 22 ++--- 16 files changed, 346 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/actions/signUp/signUp.ts create mode 100644 packages/core/src/api/signUp.ts create mode 100644 packages/core/test/api/signUp.test.ts diff --git a/packages/core/src/@types/api.ts b/packages/core/src/@types/api.ts index 8f387553..d12d90d1 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..36c2948f 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -2,7 +2,7 @@ import { createJoseInstance } from "@/jose.ts" import { createAuthAPI } from "@/api/createApi.ts" import { createLogEntry } from "@/shared/logger.ts" import { createSchemaRegistry } from "@/validator/registry.ts" -import { UserIdentity, type Identities, type SchemaTypes } from "@/shared/identity.ts" +import { UserIdentity, type Identities, type NullableIdentities, type SchemaTypes } from "@/shared/identity.ts" import type { BuiltInOAuthProvider } from "@/oauth/index.ts" import type { SerializeOptions } from "@aura-stack/router/cookie" import type { ConfigSchema, FromShapeToObject, Prettify } from "@/@types/utility.ts" @@ -13,7 +13,7 @@ import type { JWTKey, SessionConfig, SessionStrategy, User } from "@/@types/sess * 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 +157,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`? @@ -416,6 +421,7 @@ export interface RouterGlobalContext { logger?: InternalLogger sessionStrategy: SessionStrategy identity: SchemaRegistryContext + signUp?: SignUpConfig } export interface SchemaRegistryContext { @@ -462,3 +468,28 @@ export type InternalContext = RouterGlobalContext { + payload: FromShapeToObject +} + +/** + * 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?: [SignUpIdentity] extends [never] + ? Record + : ConfigSchema + /** + * 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..21491425 100644 --- a/packages/core/src/actions/signIn/authorization.ts +++ b/packages/core/src/actions/signIn/authorization.ts @@ -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..48cc6d38 --- /dev/null +++ b/packages/core/src/actions/signUp/signUp.ts @@ -0,0 +1,42 @@ +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" +import { signInCredentials } from "@/api/credentials.ts" +import { RedirectOptionsSchema } from "@/schemas.ts" +import type { SignUpConfig } from "@/@types/config.ts" + +const signUpConfig = (config: SignUpConfig) => { + return createEndpointConfig({ + schemas: { + body: config.schema, + searchParams: RedirectOptionsSchema, + }, + }) +} + +/** + * Handles the credentials-based sign-in flow. + * It extracts credentials from the request body, calls the provider's `authorize` function, + * validates the returned user object, and creates a session. + * + * @returns The signed-in user and session cookies. + */ +export const signUpAction = (config: SignUpConfig) => { + return createEndpoint( + "POST", + "/signUp", + async (ctx) => { + const payload = ctx.body + const { toResponse } = await signInCredentials({ + ctx: ctx.context, + // Add type-inference from signUp.schema + // @ts-ignore + payload: 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..ceb06e35 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -1,4 +1,4 @@ -import { getSession, signIn, signInCredentials, signOut, updateSession } from "@/api/index.ts" +import { getSession, signIn, signInCredentials, signOut, updateSession, signUp } from "@/api/index.ts" import type { GlobalContext } from "@aura-stack/router" import type { BuiltInOAuthProvider, @@ -14,9 +14,16 @@ import type { SignInCredentialsAPIReturn, SignOutAPIReturn, UpdateSessionAPIReturn, + SignUpAPIOptions, + SignUpAPIReturn, } from "@/@types/index.ts" -export const createAuthAPI = (ctx: GlobalContext) => { +export const createAuthAPI = < + DefaultUser extends User = User, + SignUpPayload extends Record = Record, +>( + ctx: GlobalContext +) => { return { /** * Retrieves the current session data from the server-side. @@ -64,6 +71,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 = SignUpPayload>( + 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..35fcfcfa --- /dev/null +++ b/packages/core/src/api/signUp.ts @@ -0,0 +1,83 @@ +import { createRedirectTo, getBaseURL, getOriginURL } from "@/actions/signIn/authorization.ts" +import type { FunctionAPIContext, SignUpAPIOptions, SignUpAPIReturn } from "@/@types/api.ts" +import { AuthValidationError } 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}/signIn/credentials` + 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) { + return { + success: false, + error: { + code: error instanceof AuthValidationError ? error.code : "UNKNOWN_ERROR", + message: error instanceof Error ? error.message : "An unknown error occurred during sign-up.", + }, + redirect: false, + headers: new Headers(secureApiHeaders), + redirectURL: null, + toResponse: () => { + return Response.json({ + success: false, + redirect: false, + redirectURL: null, + }) + }, + } + } +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 664a9a2a..db65cf5b 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -122,8 +122,8 @@ export const createAuthClient = (options: AuthC } const user = session.user ?? {} 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..fc13db2d 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -11,12 +11,15 @@ 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 { EditableShape, Identities, NullableIdentities, 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 +34,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 +47,7 @@ export const createAuthInstance = (authConfig: Auth signOutAction, csrfTokenAction, updateSessionAction(config.context.identity as SchemaRegistryContext), + signUpAction(config.context.signUp as SignUpConfig), ], config ) @@ -76,8 +82,15 @@ 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, + SignUpIdentity extends NullableIdentities = undefined, +>( + config: AuthConfig +) => { + const authInstance = createAuthInstance(config) as unknown as AuthInstance< + FromShapeToObject + > 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..85bc57ca 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, NullableIdentities } 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,6 +47,7 @@ export const createContext = (config?: AuthConfig ctx.sessionStrategy = createSessionStrategy({ cookies: () => ctx.cookies, diff --git a/packages/core/src/shared/identity.ts b/packages/core/src/shared/identity.ts index 23349176..c79261f6 100644 --- a/packages/core/src/shared/identity.ts +++ b/packages/core/src/shared/identity.ts @@ -71,6 +71,8 @@ export type Identities = | EditableShapeTypebox | EditableUser +export type NullableIdentities = Identities | undefined | null + type ReturnShapeType = T extends EditableShape ? z.ZodObject diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index e59e38d8..cc6c417e 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, NullableIdentities } 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/api/signUp.test.ts b/packages/core/test/api/signUp.test.ts new file mode 100644 index 00000000..e963b7fb --- /dev/null +++ b/packages/core/test/api/signUp.test.ts @@ -0,0 +1,72 @@ +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" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe("signUp API", () => { + test("success signUp flow", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const signUp = await api.signUp({ + payload: { + name: "johndoe", + email: "john@example.com", + image: "https://example.com/image.jpg", + password: "1234567890", + }, + }) + 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")!) + console.log(decode) + 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: { + name: "johndoe", + email: "john@example.com", + image: "https://example.com/image.jpg", + password: "1234567890", + }, + }) + 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), + }) + }) +}) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index ffcb971a..6e8fa3ad 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -50,6 +50,21 @@ const auth = createAuth({ } }, }, + signUp: { + onCreateUser: async ({ payload }) => { + /** + * Add type-inference for the signUp payload. + */ + const { name, email, image, password } = payload + return { + sub: "1234567890", + name, + email, + image, + password, + } + }, + }, }) export const { diff --git a/packages/core/test/types.test-d.ts b/packages/core/test/types.test-d.ts index 9767a119..071cb023 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" @@ -20,24 +19,17 @@ import type { 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" describe("createAuth", () => { - expectTypeOf(createAuth).toEqualTypeOf< - >( - config: AuthConfig - ) => AuthInstance> - >() + //expectTypeOf(createAuth).toEqualTypeOf< + // , SignUpIdentity extends Identities = Identity>( + // config: AuthConfig + // ) => AuthInstance> + //>() expectTypeOf(createAuth({ oauth: [] }).api.getSession).toEqualTypeOf< (options: GetSessionAPIOptions) => Promise>> >() From 9615d72149fa646aa3c9d5c73ce97713a36f5ae8 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 10 Jun 2026 10:55:59 -0500 Subject: [PATCH 2/5] feat(core): support signUp schema type inference --- packages/core/src/@types/api.ts | 2 +- packages/core/src/@types/config.ts | 37 +++++++++++-------- .../core/src/actions/signIn/authorization.ts | 4 +- packages/core/src/actions/signUp/signUp.ts | 18 ++++----- packages/core/src/api/createApi.ts | 18 ++++++--- packages/core/src/api/signUp.ts | 26 +++++++++---- packages/core/src/client/client.ts | 2 +- packages/core/src/createAuth.ts | 28 +++++++------- packages/core/src/router/context.ts | 8 ++-- packages/core/src/shared/identity.ts | 2 - packages/core/src/shared/logger.ts | 6 +-- 11 files changed, 85 insertions(+), 66 deletions(-) diff --git a/packages/core/src/@types/api.ts b/packages/core/src/@types/api.ts index d12d90d1..dc34b896 100644 --- a/packages/core/src/@types/api.ts +++ b/packages/core/src/@types/api.ts @@ -285,7 +285,7 @@ export interface UpdateSessionAPIOptions /** Programmatic session update result with redirect metadata and `toResponse()`. */ export type UpdateSessionAPIReturn = AuthActionAPIReturn> -export interface SignUpAPIOptions = Record> +export interface SignUpAPIOptions = Record> extends APIOptionsWithRedirectTo, APIOptionsWithRequest { payload: Payload } diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 36c2948f..e7c9f2a3 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -2,18 +2,20 @@ import { createJoseInstance } from "@/jose.ts" import { createAuthAPI } from "@/api/createApi.ts" import { createLogEntry } from "@/shared/logger.ts" import { createSchemaRegistry } from "@/validator/registry.ts" -import { UserIdentity, type Identities, type NullableIdentities, type SchemaTypes } from "@/shared/identity.ts" +import { UserIdentity, type Identities, type SchemaTypes } from "@/shared/identity.ts" import type { BuiltInOAuthProvider } from "@/oauth/index.ts" 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 @@ -161,7 +163,7 @@ export type AuthConfig + signUp?: SignUpConfig } & TrustedProxyHeadersConfig // @todo Should trustedOrigins support subdomain wildcards like `https://*.example.com`? @@ -342,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> @@ -408,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 @@ -421,7 +425,7 @@ export interface RouterGlobalContext { logger?: InternalLogger sessionStrategy: SessionStrategy identity: SchemaRegistryContext - signUp?: SignUpConfig + signUp?: SignUpConfig } export interface SchemaRegistryContext { @@ -439,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. */ @@ -462,34 +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: FromShapeToObject +export interface OnCreateUserContext { + payload: InferSchema } /** * Configuration for the signUp process, including the schema for validation * and required callback for user creation. */ -export interface SignUpConfig { +export interface SignUpConfig { /** * Optional schema for validating the sign-up payload. It supports any * Zod, Arktype, Valibot or Typebox schema. */ - schema?: [SignUpIdentity] extends [never] - ? Record - : ConfigSchema + 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 + context: OnCreateUserContext ) => Promise | null> | FromShapeToObject | null } diff --git a/packages/core/src/actions/signIn/authorization.ts b/packages/core/src/actions/signIn/authorization.ts index 21491425..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 index 48cc6d38..ec11cd76 100644 --- a/packages/core/src/actions/signUp/signUp.ts +++ b/packages/core/src/actions/signUp/signUp.ts @@ -1,23 +1,23 @@ +import { z } from "zod" +import { signUp } from "@/api/signUp.ts" import { createEndpoint, createEndpointConfig } from "@aura-stack/router" -import { signInCredentials } from "@/api/credentials.ts" import { RedirectOptionsSchema } from "@/schemas.ts" import type { SignUpConfig } from "@/@types/config.ts" const signUpConfig = (config: SignUpConfig) => { return createEndpointConfig({ schemas: { - body: config.schema, + body: config?.schema ?? z.object({}), searchParams: RedirectOptionsSchema, }, }) } /** - * Handles the credentials-based sign-in flow. - * It extracts credentials from the request body, calls the provider's `authorize` function, - * validates the returned user object, and creates a session. + * 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-in user and session cookies. + * @returns The signed-up user's session */ export const signUpAction = (config: SignUpConfig) => { return createEndpoint( @@ -25,11 +25,9 @@ export const signUpAction = (config: SignUpConfig) => { "/signUp", async (ctx) => { const payload = ctx.body - const { toResponse } = await signInCredentials({ + const { toResponse } = await signUp({ ctx: ctx.context, - // Add type-inference from signUp.schema - // @ts-ignore - payload: payload, + payload, request: ctx.request, headers: ctx.request.headers, redirect: ctx.searchParams.redirect, diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index ceb06e35..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, signUp } from "@/api/index.ts" -import type { GlobalContext } from "@aura-stack/router" +import type { GlobalContext, InferSchema } from "@aura-stack/router" import type { BuiltInOAuthProvider, LiteralUnion, @@ -16,12 +16,18 @@ import type { UpdateSessionAPIReturn, SignUpAPIOptions, SignUpAPIReturn, + Wrap, } from "@/@types/index.ts" +import type { ZodObject } from "zod" +import type { SchemaTypes } from "@/shared/identity.ts" -export const createAuthAPI = < - DefaultUser extends User = User, - SignUpPayload extends Record = Record, ->( +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 { @@ -90,7 +96,7 @@ export const createAuthAPI = < * request: await getRequest() * }) */ - signUp: async = SignUpPayload>( + signUp: async = InferSignUp>( options: SignUpAPIOptions ): Promise => { return signUp({ ctx, ...options }) diff --git a/packages/core/src/api/signUp.ts b/packages/core/src/api/signUp.ts index 35fcfcfa..04c6d019 100644 --- a/packages/core/src/api/signUp.ts +++ b/packages/core/src/api/signUp.ts @@ -1,6 +1,6 @@ import { createRedirectTo, getBaseURL, getOriginURL } from "@/actions/signIn/authorization.ts" import type { FunctionAPIContext, SignUpAPIOptions, SignUpAPIReturn } from "@/@types/api.ts" -import { AuthValidationError } from "@/shared/errors.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" @@ -62,21 +62,31 @@ export const signUp = async = Record { - return Response.json({ - success: false, - redirect: false, - redirectURL: null, - }) + 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 db65cf5b..664a9a2a 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -122,8 +122,8 @@ export const createAuthClient = (options: AuthC } const user = session.user ?? {} 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 fc13db2d..80d4f298 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -13,13 +13,14 @@ import { updateSessionAction, signUpAction, } from "@/actions/index.ts" -import type { EditableShape, Identities, NullableIdentities, UserShape } from "@/shared/identity.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 +const createInternalConfig = ( + config?: AuthConfig ): RouterConfig => { - const context = createContext(config) + const context = createContext(config) return { basePath: config?.basePath ?? "/auth", onError: createErrorHandler(context.logger), @@ -34,10 +35,10 @@ const createInternalConfig = ( - authConfig: AuthConfig +export const createAuthInstance = ( + authConfig: AuthConfig ) => { - const config = createInternalConfig(authConfig) + const config = createInternalConfig(authConfig) const router = createRouter( [ signInAction(config.context.oauth), @@ -47,7 +48,7 @@ export const createAuthInstance = ), + signUpAction(config.context.signUp as SignUpConfig), ], config ) @@ -55,7 +56,7 @@ export const createAuthInstance = , SignUpSchema>(config.context), } } @@ -84,12 +85,13 @@ export const createAuthInstance = , - SignUpIdentity extends NullableIdentities = undefined, + SignUpSchema extends SchemaTypes = ZodObject, >( - config: AuthConfig + config: AuthConfig ) => { - const authInstance = createAuthInstance(config) as unknown as AuthInstance< - FromShapeToObject + const authInstance = createAuthInstance(config) as unknown as AuthInstance< + FromShapeToObject, + SignUpSchema > authInstance.handlers.ALL = async (request: Request) => { const method = request.method.toUpperCase() diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 85bc57ca..c4f5c683 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -5,11 +5,11 @@ 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, NullableIdentities } 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 = @@ -48,7 +48,7 @@ export const createContext = + } as InternalContext ctx.sessionStrategy = createSessionStrategy({ cookies: () => ctx.cookies, jose: ctx.jose, diff --git a/packages/core/src/shared/identity.ts b/packages/core/src/shared/identity.ts index c79261f6..23349176 100644 --- a/packages/core/src/shared/identity.ts +++ b/packages/core/src/shared/identity.ts @@ -71,8 +71,6 @@ export type Identities = | EditableShapeTypebox | EditableUser -export type NullableIdentities = Identities | undefined | null - type ReturnShapeType = T extends EditableShape ? z.ZodObject diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index cc6c417e..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, NullableIdentities } from "./identity.ts" +import type { Identities, SchemaTypes } from "./identity.ts" import type { AuthConfig, InternalLogger, Logger, LogLevel, SyslogOptions } from "@/@types/index.ts" /** @@ -395,8 +395,8 @@ 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") From 7e6ef676b7631f9f4fe6245157a75bda88791e74 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 10 Jun 2026 10:56:15 -0500 Subject: [PATCH 3/5] test(sign-up): add coverage for signUp server API --- .../core/test/api/signInCredentials.test.ts | 30 +++- packages/core/test/api/signUp.test.ts | 160 ++++++++++++++++-- packages/core/test/presets.ts | 12 +- packages/core/test/types.test-d.ts | 71 +++++++- 4 files changed, 244 insertions(+), 29 deletions(-) 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 index e963b7fb..f405d905 100644 --- a/packages/core/test/api/signUp.test.ts +++ b/packages/core/test/api/signUp.test.ts @@ -2,6 +2,7 @@ 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) @@ -11,17 +12,19 @@ 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: { - name: "johndoe", - email: "john@example.com", - image: "https://example.com/image.jpg", - password: "1234567890", - }, + payload, }) expect(signUp).toEqual({ success: true, @@ -31,7 +34,6 @@ describe("signUp API", () => { toResponse: expect.any(Function), }) const decode = await jose.decodeJWT(getSetCookie(signUp.headers, "aura-auth.session_token")!) - console.log(decode) expect(decode).toMatchObject({ sub: "1234567890", email: "john@example.com", @@ -50,12 +52,7 @@ describe("signUp API", () => { }, }) const output = await api.signUp({ - payload: { - name: "johndoe", - email: "john@example.com", - image: "https://example.com/image.jpg", - password: "1234567890", - }, + payload, }) expect(output).toEqual({ success: false, @@ -69,4 +66,141 @@ describe("signUp API", () => { 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 6e8fa3ad..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", @@ -51,18 +51,14 @@ const auth = createAuth({ }, }, signUp: { - onCreateUser: async ({ payload }) => { - /** - * Add type-inference for the signUp payload. - */ - const { name, email, image, password } = payload + onCreateUser: ({ payload }) => { + const { name, email, image } = payload return { sub: "1234567890", name, email, image, - password, - } + } as User }, }, }) diff --git a/packages/core/test/types.test-d.ts b/packages/core/test/types.test-d.ts index 071cb023..a5750b8a 100644 --- a/packages/core/test/types.test-d.ts +++ b/packages/core/test/types.test-d.ts @@ -15,6 +15,8 @@ import type { GetSessionAPIOptions, GetSessionAPIReturn, Session, + SignUpAPIOptions, + SignUpAPIReturn, UpdateSessionAPIOptions, UpdateSessionAPIReturn, UserShape, @@ -23,13 +25,9 @@ import type { AuthInstance, User } from "@/index.ts" import type { OAuthProviderCredentials } from "@/@types/oauth.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< - // , SignUpIdentity extends Identities = Identity>( - // config: AuthConfig - // ) => AuthInstance> - //>() expectTypeOf(createAuth({ oauth: [] }).api.getSession).toEqualTypeOf< (options: GetSessionAPIOptions) => Promise>> >() @@ -267,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", () => { From a09208c9c370ae91180465b48553a6bd008a43e8 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 10 Jun 2026 11:15:21 -0500 Subject: [PATCH 4/5] test(sign-up): add coverage for /signUp endpoint --- packages/core/src/api/signUp.ts | 2 +- packages/core/src/client/client.ts | 1 + .../core/test/actions/signUp/signUp.test.ts | 225 ++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/actions/signUp/signUp.test.ts diff --git a/packages/core/src/api/signUp.ts b/packages/core/src/api/signUp.ts index 04c6d019..26b03592 100644 --- a/packages/core/src/api/signUp.ts +++ b/packages/core/src/api/signUp.ts @@ -18,7 +18,7 @@ export const signUp = async = Record(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/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: "/", + }) + }) +}) From 9b6f6c4ff57a39d95331efb646e37551dde55de0 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 10 Jun 2026 11:49:05 -0500 Subject: [PATCH 5/5] docs(packages): update `CHANGELOG.md` files --- packages/core/CHANGELOG.md | 2 ++ packages/core/src/@types/config.ts | 2 +- packages/elysia/CHANGELOG.md | 4 ++++ packages/express/CHANGELOG.md | 4 ++++ packages/hono/CHANGELOG.md | 4 ++++ packages/next/CHANGELOG.md | 4 ++++ packages/react-router/CHANGELOG.md | 4 ++++ packages/react/CHANGELOG.md | 4 ++++ 8 files changed, 27 insertions(+), 1 deletion(-) 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/config.ts b/packages/core/src/@types/config.ts index e7c9f2a3..2f941f3e 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -15,7 +15,7 @@ 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 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