Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Introduced the `identity` configuration option in `createAuth` to validate and extend default user fields (for example, role and permissions) using the `identity.schema` Zod schema. It also supports unknown field handling through `unknownKeys`, which can strip, pass through, or reject unknown fields. Additionally, the `/session` endpoint now supports any fields defined in `identity.schema`. [#130](https://github.com/aura-stack-ts/auth/pull/130)

- Introduced an experimental `/session` endpoint to update default session data from the initial OAuth profile data. It currently supports updates only for the `email`, `name`, and `image` fields. For broader claim support, use the experimental `api.updateSession` function. [#129](https://github.com/aura-stack-ts/auth/pull/129)

- Introduced an experimental `updateSession` API to update default session data from the initial OAuth profile data. The function infers the user generic type provided in `createAuth` and offers autocomplete. For security, the `sub` value cannot be overridden. [#129](https://github.com/aura-stack-ts/auth/pull/129)
Expand Down
63 changes: 54 additions & 9 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import { createJoseInstance } from "@/jose.ts"
import { createLogEntry } from "@/shared/logger.ts"
import { createAuthAPI } from "@/api/createApi.ts"
import type { Prettify } from "@/@types/utility.ts"
import { createLogEntry } from "@/shared/logger.ts"
import { UserIdentity } from "@/shared/identity.ts"
import type { ZodObject } from "zod/v4"
import type { BuiltInOAuthProvider } from "@/oauth/index.ts"
import type { OAuthProviderCredentials, OAuthProviderRecord } from "@/@types/oauth.ts"
import type { SerializeOptions } from "@aura-stack/router/cookie"
import type { JWTKey, SessionConfig, SessionStrategy, User } from "@/@types/session.ts"
import type { EditableShape, Prettify, ShapeToObject } from "@/@types/utility.ts"
import type { OAuthProviderCredentials, OAuthProviderRecord } from "@/@types/oauth.ts"
import type { JWTKey, SessionConfig, SessionStrategy, User, UserShape } from "@/@types/session.ts"

/**
* Main configuration interface for Aura Auth.
* This is the user-facing configuration object passed to `createAuth()`.
*/
export interface AuthConfig<DefaultUser extends User = User> {
export interface AuthConfig<Identity extends EditableShape<UserShape> = EditableShape<UserShape>> {
/**
* 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
* OAuth third-party authorization service by implementing the `OAuthProviderCredentials` interface.
*
* Built-in OAuth providers:
* ```ts
* oauth: ["github", "google"]
*
* ```
* Custom credentials via factory:
* ```ts
* oauth: [github({ clientId: "...", clientSecret: "..." })]
*
* ```
* Custom OAuth providers:
* ```ts
* oauth: [
* {
* id: "oauth-providers",
Expand All @@ -37,8 +42,10 @@ export interface AuthConfig<DefaultUser extends User = User> {
* clientSecret: process.env.AURA_AUTH_PROVIDER_CLIENT_SECRET,
* }
* ]
* ```
*/
oauth: (BuiltInOAuthProvider | OAuthProviderCredentials<any, DefaultUser>)[]
// @todo: add type inference for built-in providers
oauth: (BuiltInOAuthProvider | OAuthProviderCredentials<any, ShapeToObject<Identity>>)[]
/**
* Cookie options defines the configuration for cookies used in Aura Auth.
* It includes a prefix for cookie names and flag options to determine
Expand Down Expand Up @@ -115,6 +122,33 @@ export interface AuthConfig<DefaultUser extends User = User> {
* Defines the session management strategy for Aura Auth. It determines how sessions are created, stored, and validated.
*/
session?: SessionConfig

/**
* Identity schema configuration for user data validation.
* Allows you to define a custom Zod schema that will be used to validate:
* - OAuth provider profile data
* - Session user data
* - JWT payload data
*
* If not provided, the default `UserIdentity` schema will be used.
*
* @example
* identity: {
* schema: z.object({
* sub: z.string(),
* email: z.string().email(),
* name: z.string().optional(),
* custom_field: z.string().optional(),
* }),
* skipValidation: false,
* unknownKeys: "strip",
* }
*/
identity?: Partial<{
skipValidation: boolean
schema: ZodObject<Identity>
unknownKeys: "passthrough" | "strict" | "strip"
}>
Comment thread
halvaradop marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -212,6 +246,12 @@ export interface InternalLogger {
log: typeof createLogEntry
}

export interface IdentityConfig<Schema extends ZodObject<any> = typeof UserIdentity> {
schema?: Schema
skipValidation?: boolean
unknownKeys?: "passthrough" | "strict" | "strip"
}

export interface RouterGlobalContext<DefaultUser extends User = User> {
oauth: OAuthProviderRecord
cookies: CookieStoreConfig
Expand All @@ -223,6 +263,11 @@ export interface RouterGlobalContext<DefaultUser extends User = User> {
trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise<TrustedOrigin[]> | TrustedOrigin[])
logger?: InternalLogger
sessionStrategy: SessionStrategy<DefaultUser>
identity: {
unknownKeys: "passthrough" | "strict" | "strip"
schema: ZodObject<any>
skipValidation?: boolean
}
}

/**
Expand All @@ -242,7 +287,7 @@ export interface AuthInstance<DefaultUser extends User = User> {
}
}

export type InternalContext<DefaultUser extends User = User> = RouterGlobalContext<DefaultUser> & {
export type InternalContext<Identity extends EditableShape<UserShape>> = RouterGlobalContext<ShapeToObject<Identity> & User> & {
cookieConfig: {
secure: CookieStoreConfig
standard: CookieStoreConfig
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type * from "@/@types/errors.ts"
export type * from "@/@types/oauth.ts"
export type * from "@/@types/session.ts"
export type * from "@/@types/utility.ts"

export type { UserIdentityType, UserShape } from "@/shared/identity.ts"
/**
* Standard JWT claims that are managed internally by the token system.
* These fields are typically filtered out before returning user data.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/@types/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type ResponseType = LiteralUnion<"code" | "token" | "refresh_token" | "id
* Configuration for an OAuth provider without credentials.
* Use this type when defining provider metadata and endpoints.
*/
export interface OAuthProviderConfig<Profile extends object = Record<string, any>, DefaultUser extends User = User> {
export interface OAuthProviderConfig<Profile extends object = Record<string, any>, DefaultUser = User> {
Comment thread
halvaradop marked this conversation as resolved.
id: string
name: string
/**
Expand Down
22 changes: 9 additions & 13 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { EditableShape, ShapeToObject } from "./utility.ts"
import type { TypedJWTPayload } from "@aura-stack/jose"
import type { CookieStoreConfig, InternalLogger, JoseInstance, RouterGlobalContext } from "@/@types/config.ts"
import type { UserIdentityType, UserShape } from "@/shared/identity.ts"
import type { CookieStoreConfig, IdentityConfig, InternalLogger, JoseInstance, RouterGlobalContext } from "@/@types/config.ts"

/**
* Standardized user profile returned by OAuth providers after fetching user information
* and mapping the response to this format by default or via the `profile` custom function.
*/
export interface User extends Record<string, unknown> {
sub: string
name?: string | null
email?: string | null
image?: string | null
}
export type User = UserIdentityType
export type { UserShape } from "@/shared/identity.ts"

/**
* Session data returned by the session endpoint.
Expand Down Expand Up @@ -216,18 +210,20 @@ export interface GetSessionReturn<DefaultUser extends User = User> {
headers: Headers
}

export interface CreateSessionStrategyOptions<DefaultUser extends User = User> {
export interface CreateSessionStrategyOptions<Identity extends EditableShape<UserShape>> {
config?: SessionConfig
jose: JoseInstance<DefaultUser>
jose: JoseInstance<ShapeToObject<Identity> & User>
cookies: () => CookieStoreConfig
logger?: InternalLogger
identity: IdentityConfig
}

export interface JWTStrategyOptions<DefaultUser extends User = User> {
config?: StatelessStrategyConfig
jose: JoseInstance<DefaultUser>
logger?: InternalLogger
cookies: () => CookieStoreConfig
identity: IdentityConfig
}

export interface SignInOptions {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/@types/utility.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
import type { User } from "@/@types/session.ts"
import type { AuthInstance } from "@/@types/config.ts"
import type { ZodObject, ZodRawShape, ZodTypeAny, infer as Infer } from "zod/v4"

export type Prettify<T> = { [K in keyof T]: T[K] }

export type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>)

export type EditableShape<T extends ZodRawShape> = {
[K in keyof T]: T[K] extends ZodObject<infer Inner extends ZodRawShape> ? ZodObject<EditableShape<Inner>> : ZodTypeAny
}

export type Merge<A, B> = Omit<A, keyof B> & B

export type ShapeToObject<S extends ZodRawShape = ZodRawShape> = Merge<
{
[K in keyof S]: Infer<S[K]>
},
User
>

export type DeepRequired<T> = {
[K in keyof T]-?: T[K] extends object ? DeepRequired<T[K]> : T[K]
}

export type InferAuthIdentity<Config> = Config extends AuthInstance<infer Identity> ? Prettify<Identity> : User

export type InferShape<T extends ZodObject> = T["shape"]
export type InferIdentity<T extends ZodObject> = ShapeToObject<InferShape<T>>
6 changes: 5 additions & 1 deletion packages/core/src/actions/callback/userinfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const getDefaultUserInfo = (profile: Record<string, string>): User => {
*
* @param oauthConfig - OAuth provider configuration
* @param accessToken - Access Token to access the userinfo endpoint
* @param logger - Optional logger instance
* @returns The user information retrieved from the userinfo endpoint
*/
export const getUserInfo = async (oauthConfig: OAuthProviderCredentials, accessToken: string, logger?: InternalLogger) => {
Expand Down Expand Up @@ -70,7 +71,10 @@ export const getUserInfo = async (oauthConfig: OAuthProviderCredentials, accessT
throw new OAuthProtocolError("INVALID_REQUEST", "An error was received from the OAuth userinfo endpoint.")
}
logger?.log("OAUTH_USERINFO_SUCCESS")
return oauthConfig?.profile ? oauthConfig.profile(json) : getDefaultUserInfo(json)

const userInfo = oauthConfig?.profile ? oauthConfig.profile(json) : getDefaultUserInfo(json)

return userInfo
} catch (error) {
if (isOAuthProtocolError(error)) {
throw error
Expand Down
57 changes: 31 additions & 26 deletions packages/core/src/actions/updateSession/updateSession.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
import { z } from "zod/v4"
import { updateSession } from "@/api/updateSession.ts"
import { createEndpoint, createEndpointConfig } from "@aura-stack/router"
import { updateSession } from "@/api/updateSession.ts"
import type { User } from "@/@types/session.ts"
import type { IdentityConfig } from "@/@types/config.ts"

export const config = createEndpointConfig({
schemas: {
body: z.object({
name: z.string().optional(),
email: z.email().optional(),
image: z.string().optional(),
expires: z.string().optional(),
}),
},
})
export const config = (identity: IdentityConfig) => {
return createEndpointConfig({
schemas: {
body: z.object({
user: identity.schema?.partial().optional(),
expires: z.coerce.date().optional(),
}),
Comment thread
halvaradop marked this conversation as resolved.
},
})
Comment thread
halvaradop marked this conversation as resolved.
}

export const updateSessionAction = createEndpoint(
"PATCH",
"/session",
async (ctx) => {
const updated = await updateSession({
ctx: ctx.context,
headers: ctx.request.headers,
session: {
user: ctx.body,
},
})
return Response.json(updated, { status: updated.updated ? 200 : 401 })
},
config
)
export const updateSessionAction = (identity: IdentityConfig) => {
return createEndpoint(
"PATCH",
"/session",
async (ctx) => {
const updated = await updateSession({
ctx: ctx.context,
headers: ctx.request.headers,
session: {
user: ctx.body.user as User,
expires: ctx.body.expires?.toISOString(),
},
})
return Response.json(updated, { status: updated.updated ? 200 : 401 })
},
config(identity)
)
}
3 changes: 2 additions & 1 deletion packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ export const createAuthClient = <DefaultUser extends User = User>(options: AuthC
const { sub: _sub, ...spread } = (session.user ?? {}) as DefaultUser
const response = await client.patch("/session", {
body: {
...spread,
/** @todo: Remove the `as any` cast once the session update endpoint is properly typed to accept partial session updates. */
...(spread as any),
expires: session.expires,
},
headers: {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ export const getCookie = (request: Request | Headers, cookieName: string) => {
* @param cookieName Cookie name to retrieve
* @returns The value of the Set-Cookie header or throw an error if not found
*/
export const getSetCookie = (response: Response, cookieName: string) => {
const cookies = response.headers.getSetCookie()
export const getSetCookie = (response: Response | Headers, cookieName: string) => {
const cookies = response instanceof Response ? response.headers.getSetCookie() : response.getSetCookie()
if (!cookies) {
throw new AuthInternalError("COOKIE_NOT_FOUND", "No cookies found in response.")
}
Expand Down
Loading
Loading