diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index affd2653..0f1b0c4a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,12 +10,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added `InferSignUp`, a utility type for inferring the sign-up payload from `createAuth().signUp.schema`. This type can be reused as the second generic parameter of `createAuthClient()` to ensure consistent typing between server and client authentication configurations. [#190](https://github.com/aura-stack-ts/auth/pull/190) + - Added a `signUp` client function accessible via `createAuthClient`, allowing interaction with the mounted `POST /signUp` endpoint. [#184](https://github.com/aura-stack-ts/auth/pull/184) - 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) +### Fixed + +- Fixed type inference for authentication actions created with `createAuth()` and `createAuthClient()`. The `signUp.schema` configuration is now inferred correctly, improving type safety and reducing the need for manual type annotations. [#190](https://github.com/aura-stack-ts/auth/pull/190) + +### Changed + +- Refactored and standardized error handling across authentication flows. All authentication errors now extend the `AuraAuthError` base class, providing a consistent error model throughout the library. Error objects now expose structured metadata, including `type`, `code`, `message`, and `userMessage`. [#190](https://github.com/aura-stack-ts/auth/pull/190) + --- ## [0.7.2] - 2026-06-05 diff --git a/packages/core/src/@types/utility.ts b/packages/core/src/@types/utility.ts index 1c2fda53..ae204b76 100644 --- a/packages/core/src/@types/utility.ts +++ b/packages/core/src/@types/utility.ts @@ -2,9 +2,10 @@ import { Type } from "arktype" import type { TProperties, TObject, TSchema } from "typebox" import type { AuthInstance } from "@/@types/config.ts" import type { Session, User } from "@/@types/session.ts" -import type { ZodObject, ZodRawShape, ZodTypeAny, infer as Infer } from "zod/v4" +import type { ZodObject, ZodRawShape, ZodTypeAny, infer as Infer, ZodOptional } from "zod/v4" import type { Identities, IsArkType, IsZod, UserShapeTypeBox, UserShapeValibot } from "@/shared/identity.ts" import type { ObjectSchema, BaseSchema, AnySchema as AnyValibotSchema, ObjectEntries, InferOutput } from "valibot" +import type { InferSchema } from "@aura-stack/router" /** Expands intersection types into a single flat object type for readable editor hints. */ export type Prettify = { [K in keyof T]: T[K] } @@ -80,6 +81,28 @@ export type FromShapeToObject = S extends ZodRawShape ? S : never +export type EditableToSchema = + T extends EditableShape + ? ZodObject + : T extends EditableShapeValibot + ? ObjectSchema + : T extends EditableShapeTypebox + ? TObject + : T extends EditableShapeArkType + ? T + : never + +export type ReturnUpdateSessionShape = + T extends EditableShape + ? ZodObject<{ user?: ZodObject; expires?: ZodOptional }> + : T extends EditableShapeValibot + ? ObjectSchema<{ user?: ObjectSchema; expires?: BaseSchema }, undefined> + : T extends EditableShapeArkType + ? Type<{ user?: T; expires?: Type }> + : T extends EditableShapeTypebox + ? TObject<{ user?: TObject; expires?: TSchema }> + : never + /** Recursively makes every property required. */ export type DeepRequired = { [K in keyof T]-?: T[K] extends object ? DeepRequired : T[K] @@ -153,6 +176,35 @@ export type UserFrom = Prettify = Wrap>>> +/** + * Infers the sign-up data type from an {@link AuthInstance} config's `signUp.schema`. It supports + * Zod, Valibot and ArkType schemas. + * + * > For TypeBox its recommended to use the `Static` utility type directly to infer the schema. + * + * @example + * const auth = createAuth({ + * oauth: [], + * signUp: { + * schema: z.object({ + * username: z.string(), + * nickname: z.string(), + * password: z.string(), + * }) + * } + * }) + * + * type SignUp = InferSignUp + */ +export type InferSignUp = + Config extends AuthInstance + ? Wrap>> + : Record + +export type RemoveIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K] +} + /** * HTTP `Response` with `json()` typed to resolve to `Body` (defaults to `unknown`). */ diff --git a/packages/core/src/actions/signUp/signUp.ts b/packages/core/src/actions/signUp/signUp.ts index 1413dec5..881a9df1 100644 --- a/packages/core/src/actions/signUp/signUp.ts +++ b/packages/core/src/actions/signUp/signUp.ts @@ -2,8 +2,11 @@ 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" +import type { Identities, SchemaTypes } from "@/shared/identity.ts" -const signUpConfig = (config: SignUpConfig) => { +const signUpConfig = ( + config: SignUpConfig +) => { return createEndpointConfig({ schemas: { body: config?.schema, @@ -18,15 +21,18 @@ const signUpConfig = (config: SignUpConfig) => { * * @returns The signed-up user's session */ -export const signUpAction = (config: SignUpConfig) => { +export const signUpAction = ( + config: SignUpConfig +) => { return createEndpoint( "POST", "/signUp", async (ctx) => { - const payload = ctx.body + // @ts-ignore - Deep generic inference with router body type is currently too expensive. + const payload = ctx.body as any const { toResponse } = await signUp({ ctx: ctx.context, - payload, + payload: payload, request: ctx.request, headers: ctx.request.headers, redirect: ctx.searchParams.redirect, diff --git a/packages/core/src/actions/updateSession/updateSession.ts b/packages/core/src/actions/updateSession/updateSession.ts index 2b714cf2..e37885d7 100644 --- a/packages/core/src/actions/updateSession/updateSession.ts +++ b/packages/core/src/actions/updateSession/updateSession.ts @@ -2,13 +2,13 @@ import { createEndpoint, createEndpointConfig } from "@aura-stack/router" import { RedirectOptionsSchema } from "@/schemas.ts" import { updateSession } from "@/api/updateSession.ts" import { getFullSchema } from "@/validator/registry.ts" -import type { User } from "@/@types/session.ts" import type { SchemaRegistryContext } from "@/@types/config.ts" +import type { Identities } from "@/shared/identity.ts" -export const config = (identity: SchemaRegistryContext) => { +export const config = (identity: SchemaRegistryContext) => { return createEndpointConfig({ schemas: { - body: getFullSchema(identity.schemaRegistry.schemaAsPartial), + body: getFullSchema(identity.schemaRegistry.schemaAsPartial), searchParams: RedirectOptionsSchema, }, }) @@ -19,16 +19,14 @@ export const updateSessionAction = (identity: SchemaRegistryContext) => { "PATCH", "/session", async (ctx) => { + const session = ctx.body const { toResponse } = await updateSession({ ctx: ctx.context, request: ctx.request, headers: ctx.request.headers, redirect: ctx.searchParams.redirect, redirectTo: ctx.searchParams.redirectTo, - session: { - user: ctx.body?.user as User, - expires: ctx.body?.expires?.toISOString(), - }, + session: session as any, }) return toResponse() }, diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index e7245c1d..f1b15ab7 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -17,16 +17,13 @@ import type { SignUpAPIOptions, SignUpAPIReturn, Wrap, + RemoveIndexSignature, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SchemaTypes } from "@/shared/identity.ts" 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 ) => { diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index b6cf4d52..a7fe54b7 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -136,7 +136,6 @@ export const createAuthClient = < const { redirectTo } = options ?? {} const response = await client.post("/signIn/credentials", { body: options.payload, - // @ts-ignore - Fix type here - go to @aura-stack/router. searchParams: { redirectTo, redirect: false, @@ -174,7 +173,6 @@ export const createAuthClient = < const { redirectTo } = options ?? {} // @ts-ignore const response = await client.post("/signUp", { - // @ts-ignore - Fix type here - go to @aura-stack/router. body: options.payload, searchParams: { redirectTo, @@ -226,6 +224,7 @@ export const createAuthClient = < body: { // @ts-ignore - Fix type here - go to @aura-stack/router. user, + // @ts-ignore - Fix type here - go to @aura-stack/router. expires: session.expires ? new Date(session.expires) : undefined, }, searchParams: { @@ -267,7 +266,6 @@ export const createAuthClient = < throw new AuraAuthError({ code: "CSRF_TOKEN_MISSING" }) } - // @ts-ignore - Fix type here - go to @aura-stack/router. const response = await client.post("/signOut", { searchParams: { redirectTo: options?.redirectTo, diff --git a/packages/core/src/createAuth.ts b/packages/core/src/createAuth.ts index 80d4f298..9abe945f 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -48,7 +48,7 @@ export const createAuthInstance = ), + signUpAction(config.context.signUp as SignUpConfig), ], config ) diff --git a/packages/core/src/validator/registry.ts b/packages/core/src/validator/registry.ts index dc7961bf..0709eafa 100644 --- a/packages/core/src/validator/registry.ts +++ b/packages/core/src/validator/registry.ts @@ -2,11 +2,12 @@ import { z } from "zod/v4" import * as valibot from "valibot" import { type } from "arktype" import { IsObject, Type as Typebox } from "typebox" -import { UserIdentity, type SchemaTypes } from "@/shared/identity.ts" +import { UserIdentity, type Identities, type SchemaTypes } from "@/shared/identity.ts" import { isArkType, isValibotSchema, isZodSchema } from "@/shared/assert.ts" import { AuraAuthError } from "@/shared/errors.ts" import { createValidator } from "@aura-stack/router/validator" import type { IdentityConfig } from "@/@types/config.ts" +import type { EditableToSchema, ReturnUpdateSessionShape } from "@/@types/utility.ts" export const deriveSchema = ( schema: Schema, @@ -116,7 +117,9 @@ export const deriveSchemaWithJWT = (schema: Schema): throw new AuraAuthError({ code: "SCHEMA_UNSUPPORTED" }) } -export const getFullSchema = (schema: Schema): any => { +export const getFullSchema = >( + schema: Schema +): ReturnUpdateSessionShape => { if (isValibotSchema(schema)) { // @ts-ignore Deep type instantiation with external schemas return valibot.object({ @@ -145,13 +148,13 @@ export const getFullSchema = (schema: Schema): any = return Typebox.Object({ user: schema, expires: Typebox.Optional(Typebox.String()), - }) + }) as unknown as ReturnUpdateSessionShape } if (isZodSchema(schema)) { return z.object({ user: schema, expires: z.coerce.date().optional(), - }) + }) as unknown as ReturnUpdateSessionShape } throw new AuraAuthError({ code: "SCHEMA_UNSUPPORTED" }) } diff --git a/packages/core/test/client/client.test-d.ts b/packages/core/test/client/client.test-d.ts new file mode 100644 index 00000000..db808ae6 --- /dev/null +++ b/packages/core/test/client/client.test-d.ts @@ -0,0 +1,366 @@ +import { describe, expectTypeOf, test } from "vitest" +import { createAuthClient } from "@/client/client.ts" +import type { + Session, + User, + LiteralUnion, + BuiltInOAuthProvider, + SignInCredentialsOptions, + SignInCredentialsReturn, + SignInOptions, + SignInReturn, + SignOutOptions, + SignOutReturn, + SignUpOptions, + SignUpReturn, + UpdateSessionOptions, + UpdateSessionReturn, + InferUser, + InferSignUp, +} from "@/@types/index.ts" +import { createAuth } from "@/createAuth.ts" +import { z } from "zod/v4" +import { type } from "arktype" +import * as valibot from "valibot" +import * as typebox from "typebox" +import { UserIdentity, UserIdentityArkType, UserIdentityTypeBox, UserIdentityValibot } from "@/shared/identity.ts" + +describe("Client Types", () => { + test("createAuthClient returns the correct type", () => { + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >>(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom zod identity schema", () => { + const auth = createAuth({ + oauth: [], + identity: { + schema: UserIdentity.extend({ + isAdmin: z.boolean(), + role: z.enum(["admin", "user"]), + }), + }, + }) + + type User = InferUser + + expectTypeOf().toEqualTypeOf<{ + sub: string + name?: string | null | undefined + image?: string | null | undefined + email?: string | null | undefined + isAdmin: boolean + role: "admin" | "user" + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >>(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom valibot identity schema", () => { + const auth = createAuth({ + oauth: [], + identity: { + schema: valibot.object({ + ...UserIdentityValibot.entries, + isAdmin: valibot.boolean(), + role: valibot.union([valibot.literal("admin"), valibot.literal("user")]), + }), + }, + }) + + type User = InferUser + + expectTypeOf().toEqualTypeOf<{ + sub: string + name?: string | null | undefined + image?: string | null | undefined + email?: string | null | undefined + isAdmin: boolean + role: "admin" | "user" + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >>(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom arktype identity schema", () => { + const auth = createAuth({ + oauth: [], + identity: { + schema: UserIdentityArkType.and({ + isAdmin: "boolean", + role: type.enumerated("admin", "user"), + }), + }, + }) + + type User = InferUser + + expectTypeOf().toEqualTypeOf<{ + sub: string + name?: string | null | undefined + image?: string | null | undefined + email?: string | null | undefined + isAdmin: boolean + role: "admin" | "user" + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >>(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom typebox identity schema", () => { + /** + * NOTE: Currently, the TypeBox schema is not correctly inferred due to the expensive nature + * of the Static type from "typebox". This is a known issue and we're looking into ways to optimize + * the type inference for TypeBox schemas in the future. + */ + const schema = typebox.Type.Object({ + ...UserIdentityTypeBox.properties, + isAdmin: typebox.Type.Boolean(), + role: typebox.Type.Union([typebox.Type.Literal("admin"), typebox.Type.Literal("user")]), + }) + + createAuth({ + oauth: [], + identity: { + schema, + }, + }) + + type User = typebox.Static + + expectTypeOf().toEqualTypeOf<{ + sub: string + name?: string | null | undefined + image?: string | null | undefined + email?: string | null | undefined + isAdmin: boolean + role: "admin" | "user" + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >>(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom zod signUp schema", () => { + const auth = createAuth({ + oauth: [], + signUp: { + schema: z.object({ + username: z.string(), + nickname: z.string(), + password: z.string(), + }), + onCreateUser: () => null, + }, + }) + type SignUp = InferSignUp + expectTypeOf().toEqualTypeOf<{ + username: string + nickname: string + password: string + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom valibot signUp schema", () => { + const auth = createAuth({ + oauth: [], + signUp: { + schema: valibot.object({ + username: valibot.string(), + nickname: valibot.string(), + password: valibot.string(), + }), + onCreateUser: () => null, + }, + }) + type SignUp = InferSignUp + expectTypeOf().toEqualTypeOf<{ + username: string + nickname: string + password: string + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom arktype signUp schema", () => { + const auth = createAuth({ + oauth: [], + signUp: { + schema: type({ + username: "string", + nickname: "string", + password: "string", + }), + onCreateUser: () => null, + }, + }) + type SignUp = InferSignUp + expectTypeOf().toEqualTypeOf<{ + username: string + nickname: string + password: string + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) + + test("with custom typebox signUp schema", () => { + const schema = typebox.Type.Object({ + username: typebox.Type.String(), + nickname: typebox.Type.String(), + password: typebox.Type.String(), + }) + + createAuth({ + oauth: [], + signUp: { + schema, + onCreateUser: () => null, + }, + }) + type SignUp = typebox.Static + expectTypeOf().toEqualTypeOf<{ + username: string + nickname: string + password: string + }>() + + const authClient = createAuthClient({}) + expectTypeOf(authClient).toEqualTypeOf<{ + getSession: () => Promise | null> + signIn: ( + oauth: LiteralUnion, + options?: Options | undefined + ) => Promise> + signInCredentials: ( + options: Options + ) => Promise> + signUp: >(options: Options) => Promise> + updateSession: >( + options: Options + ) => Promise> + signOut: (options?: Options) => Promise> + }>() + }) +})