diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 096db682..b40d5d34 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 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) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index c9a7c194..eada3b8c 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -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 { +export interface AuthConfig = EditableShape> { /** * 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", @@ -37,8 +42,10 @@ export interface AuthConfig { * clientSecret: process.env.AURA_AUTH_PROVIDER_CLIENT_SECRET, * } * ] + * ``` */ - oauth: (BuiltInOAuthProvider | OAuthProviderCredentials)[] + // @todo: add type inference for built-in providers + oauth: (BuiltInOAuthProvider | OAuthProviderCredentials>)[] /** * Cookie options defines the configuration for cookies used in Aura Auth. * It includes a prefix for cookie names and flag options to determine @@ -115,6 +122,33 @@ export interface AuthConfig { * 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 + unknownKeys: "passthrough" | "strict" | "strip" + }> } /** @@ -212,6 +246,12 @@ export interface InternalLogger { log: typeof createLogEntry } +export interface IdentityConfig = typeof UserIdentity> { + schema?: Schema + skipValidation?: boolean + unknownKeys?: "passthrough" | "strict" | "strip" +} + export interface RouterGlobalContext { oauth: OAuthProviderRecord cookies: CookieStoreConfig @@ -223,6 +263,11 @@ export interface RouterGlobalContext { trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) logger?: InternalLogger sessionStrategy: SessionStrategy + identity: { + unknownKeys: "passthrough" | "strict" | "strip" + schema: ZodObject + skipValidation?: boolean + } } /** @@ -242,7 +287,7 @@ export interface AuthInstance { } } -export type InternalContext = RouterGlobalContext & { +export type InternalContext> = RouterGlobalContext & User> & { cookieConfig: { secure: CookieStoreConfig standard: CookieStoreConfig diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 9c34c807..700e6f20 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -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. diff --git a/packages/core/src/@types/oauth.ts b/packages/core/src/@types/oauth.ts index 5bcc8475..816e131a 100644 --- a/packages/core/src/@types/oauth.ts +++ b/packages/core/src/@types/oauth.ts @@ -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, DefaultUser extends User = User> { +export interface OAuthProviderConfig, DefaultUser = User> { id: string name: string /** diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 622c59ea..e8018116 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -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 { - 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. @@ -216,11 +210,12 @@ export interface GetSessionReturn { headers: Headers } -export interface CreateSessionStrategyOptions { +export interface CreateSessionStrategyOptions> { config?: SessionConfig - jose: JoseInstance + jose: JoseInstance & User> cookies: () => CookieStoreConfig logger?: InternalLogger + identity: IdentityConfig } export interface JWTStrategyOptions { @@ -228,6 +223,7 @@ export interface JWTStrategyOptions { jose: JoseInstance logger?: InternalLogger cookies: () => CookieStoreConfig + identity: IdentityConfig } export interface SignInOptions { diff --git a/packages/core/src/@types/utility.ts b/packages/core/src/@types/utility.ts index f40318db..99797ed8 100644 --- a/packages/core/src/@types/utility.ts +++ b/packages/core/src/@types/utility.ts @@ -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 = { [K in keyof T]: T[K] } export type LiteralUnion = T | (U & Record) + +export type EditableShape = { + [K in keyof T]: T[K] extends ZodObject ? ZodObject> : ZodTypeAny +} + +export type Merge = Omit & B + +export type ShapeToObject = Merge< + { + [K in keyof S]: Infer + }, + User +> + +export type DeepRequired = { + [K in keyof T]-?: T[K] extends object ? DeepRequired : T[K] +} + +export type InferAuthIdentity = Config extends AuthInstance ? Prettify : User + +export type InferShape = T["shape"] +export type InferIdentity = ShapeToObject> diff --git a/packages/core/src/actions/callback/userinfo.ts b/packages/core/src/actions/callback/userinfo.ts index bac286c6..dee59df5 100644 --- a/packages/core/src/actions/callback/userinfo.ts +++ b/packages/core/src/actions/callback/userinfo.ts @@ -29,6 +29,7 @@ const getDefaultUserInfo = (profile: Record): 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) => { @@ -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 diff --git a/packages/core/src/actions/updateSession/updateSession.ts b/packages/core/src/actions/updateSession/updateSession.ts index 2a2b87b7..d2f40fd1 100644 --- a/packages/core/src/actions/updateSession/updateSession.ts +++ b/packages/core/src/actions/updateSession/updateSession.ts @@ -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(), + }), + }, + }) +} -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) + ) +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 31c7686a..548cd2c8 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -111,7 +111,8 @@ export const createAuthClient = (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: { diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index b355c870..9ecdb326 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -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.") } diff --git a/packages/core/src/createAuth.ts b/packages/core/src/createAuth.ts index a31909a2..fccd8b8d 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -11,12 +11,12 @@ import { csrfTokenAction, updateSessionAction, } from "@/actions/index.ts" -import type { AuthConfig, AuthInstance, User } from "@/@types/index.ts" +import type { AuthConfig, AuthInstance, EditableShape, ShapeToObject, UserShape } from "@/@types/index.ts" -const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => { - const context = createContext(authConfig) +const createInternalConfig = >(config?: AuthConfig): RouterConfig => { + const context = createContext(config) return { - basePath: authConfig?.basePath ?? "/auth", + basePath: config?.basePath ?? "/auth", onError: createErrorHandler(context.logger), context: context as unknown as RouterConfig["context"], use: [ @@ -29,6 +29,27 @@ const createInternalConfig = (authConfig?: Auth } } +export const createAuthInstance = >(authConfig: AuthConfig) => { + const config = createInternalConfig(authConfig) + const router = createRouter( + [ + signInAction(config.context.oauth), + callbackAction(config.context.oauth), + sessionAction, + signOutAction, + csrfTokenAction, + updateSessionAction(config.context.identity), + ], + config + ) + + return { + handlers: router, + jose: config.context.jose, + api: createAuthAPI(config.context), + } +} + /** * Creates the authentication instance with the configuration provided for OAuth provider. * > NOTE: The handlers returned by this function should be used in the server to handle the authentication routes @@ -41,39 +62,19 @@ const createInternalConfig = (authConfig?: Auth * oauth: ["github", { * id: "custom-oauth", * name: "custom-oauth", - * authorizationURL: "https://custom-oauth.com/oauth/authorize", + * authorize: { + * url: "https://custom-oauth.com/oauth/authorize", + * params: { responseType: "code", scope: "profile email" }, + * }, * accessToken: "https://custom-oauth.com/oauth/token", - * scope: "profile email", - * responseType: "code", * userInfo: "https://custom-oauth.com/api/userinfo", * clientId: process.env.AURA_AUTH_CUSTOM_OAUTH_CLIENT_ID!, * clientSecret: process.env.AURA_AUTH_CUSTOM_OAUTH_CLIENT_SECRET!, * }] * }) */ -export const createAuthInstance = (authConfig: AuthConfig) => { - const config = createInternalConfig(authConfig) - const router = createRouter( - [ - signInAction(config.context.oauth), - callbackAction(config.context.oauth), - sessionAction, - signOutAction, - csrfTokenAction, - updateSessionAction, - ], - config - ) - - return { - handlers: router, - jose: config.context.jose, - api: createAuthAPI(config.context), - } -} - -export const createAuth = (config: AuthConfig) => { - const authInstance = createAuthInstance(config) as unknown as AuthInstance +export const createAuth = >(config: AuthConfig) => { + const authInstance = createAuthInstance(config) as AuthInstance> authInstance.handlers.ALL = async (request: Request) => { const method = request.method.toUpperCase() const methodHandlers = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d95a3891..ef87c7c1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export { createJoseInstance } from "@/jose.ts" export { createSyslogMessage } from "@/shared/logger.ts" export { builtInOAuthProviders } from "@/oauth/index.ts" export { createAuthClient, createClient } from "@/client/index.ts" +export { createIdentity, UserIdentity, StrippedUserIdentity } from "@/shared/identity.ts" export type * from "@/client/index.ts" export type { @@ -40,4 +41,9 @@ export type { StatelessStrategyConfig, SessionConfig, SessionStrategy, + UserIdentityType, + UserShape } from "@/@types/index.ts" + + +export type { Merge, ShapeToObject, EditableShape, InferShape, InferIdentity, InferAuthIdentity } from "@/@types/utility.ts" \ No newline at end of file diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index ccb3aeda..7e5698f6 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -1,12 +1,13 @@ import { createJoseInstance } from "@/jose.ts" import { createCookieStore } from "@/cookie.ts" +import { UserIdentity } from "@/shared/identity.ts" import { createProxyLogger } from "@/shared/logger.ts" import { createSessionStrategy } from "@/session/strategy.ts" import { createBuiltInOAuthProviders } from "@/oauth/index.ts" import { getEnv, getEnvArray, getEnvBoolean } from "@/shared/env.ts" -import type { AuthConfig, InternalContext, User } from "@/@types/index.ts" +import type { AuthConfig, EditableShape, InternalContext, ShapeToObject, UserShape } from "@/@types/index.ts" -export const createContext = (config?: AuthConfig): InternalContext => { +export const createContext = >(config?: AuthConfig) => { const trustedProxyHeadersEnv = getEnv("TRUSTED_PROXY_HEADERS") const useProxyHeaders = trustedProxyHeadersEnv === undefined ? (config?.trustedProxyHeaders ?? false) : getEnvBoolean("TRUSTED_PROXY_HEADERS") @@ -15,7 +16,7 @@ export const createContext = (config?: AuthConf const cookieOverrides = config?.cookies?.overrides ?? {} const secureCookieStore = createCookieStore(true, cookiePrefix, cookieOverrides, logger) const standardCookieStore = createCookieStore(false, cookiePrefix, cookieOverrides, logger) - const jose = createJoseInstance(config?.secret, config?.session) + const jose = createJoseInstance>(config?.secret, config?.session) const ctx = { oauth: createBuiltInOAuthProviders(config?.oauth), @@ -28,12 +29,18 @@ export const createContext = (config?: AuthConf logger, cookieConfig: { secure: secureCookieStore, standard: standardCookieStore }, baseURL: config?.baseURL, - } as InternalContext - ctx.sessionStrategy = createSessionStrategy({ + identity: { + schema: config?.identity?.schema ?? UserIdentity, + unknownKeys: config?.identity?.unknownKeys ?? "strip", + skipValidation: config?.identity?.skipValidation ?? false, + }, + } as InternalContext + ctx.sessionStrategy = createSessionStrategy({ cookies: () => ctx.cookies, - jose, + jose: ctx.jose, config: config?.session, logger: ctx.logger, + identity: ctx.identity, }) return ctx } diff --git a/packages/core/src/router/errorHandler.ts b/packages/core/src/router/errorHandler.ts index 2313f119..6d130e64 100644 --- a/packages/core/src/router/errorHandler.ts +++ b/packages/core/src/router/errorHandler.ts @@ -1,5 +1,5 @@ import { isInvalidZodSchemaError, isRouterError, type RouterConfig } from "@aura-stack/router" -import { isAuthInternalError, isAuthSecurityError, isOAuthProtocolError } from "@/shared/errors.ts" +import { isAuthInternalError, isAuthSecurityError, isAuthValidationError, isOAuthProtocolError } from "@/shared/errors.ts" import type { InternalLogger } from "@/@types/index.ts" export const createErrorHandler = (logger?: InternalLogger): RouterConfig["onError"] => { @@ -63,6 +63,11 @@ export const createErrorHandler = (logger?: InternalLogger): RouterConfig["onErr { status: 400 } ) } + if (isAuthValidationError(error)) { + const { type, code, message } = error + logger?.log("IDENTITY_VALIDATION_FAILED", { structuredData: { error: code, error_description: message } }) + return Response.json({ type, code, message }, { status: 422 }) + } logger?.log("SERVER_ERROR", { structuredData: { error_type: error.name, error_message: error.message } }) return Response.json( { type: "SERVER_ERROR", code: "SERVER_ERROR", message: "An unexpected error occurred" }, diff --git a/packages/core/src/schema-registry.ts b/packages/core/src/schema-registry.ts new file mode 100644 index 00000000..1600598b --- /dev/null +++ b/packages/core/src/schema-registry.ts @@ -0,0 +1,45 @@ +import { formatZodError } from "@/shared/utils.ts" +import { UserIdentity } from "@/shared/identity.ts" +import { infer as Infer, type ZodObject } from "zod/v4" +import { AuthValidationError } from "@/shared/errors.ts" +import type { IdentityConfig } from "@/@types/config.ts" + +export const stripUnknownKeys = >(schema: T, unknownKeys: "strip" | "passthrough" | "strict") => { + switch (unknownKeys) { + case "strip": + return schema.strip() + case "passthrough": + return schema.loose() + case "strict": + return schema.strict() + } +} + +export const createSchemaRegistry = >(config: IdentityConfig) => { + const schema = stripUnknownKeys(config.schema ?? UserIdentity, config.unknownKeys ?? "strip") + const partialSchema = schema.partial() + + const parse = async >(data: unknown = {}) => { + const parsed = await schema.safeParseAsync(data) + if (!parsed.success) { + const details = JSON.stringify(formatZodError(parsed.error), null, 2) + throw new AuthValidationError("INVALID_IDENTITY_VALIDATION_FAILED", details, { + cause: parsed.error, + }) + } + return parsed.data as T + } + + const parseAsPartial = async >>(data: unknown = {}) => { + const parsed = await partialSchema.safeParseAsync(data) + if (!parsed.success) { + const details = JSON.stringify(formatZodError(parsed.error), null, 2) + throw new AuthValidationError("INVALID_IDENTITY_VALIDATION_FAILED", details, { + cause: parsed.error, + }) + } + return parsed.data as T + } + + return { parse, parseAsPartial } +} diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index 80b150ee..6dc9310e 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -13,17 +13,20 @@ import type { GetSessionReturn, DeepPartial, } from "@/@types/index.ts" +import { createSchemaRegistry } from "@/schema-registry.ts" export const createStatelessStrategy = ({ config, jose, logger, cookies, + identity, }: JWTStrategyOptions): SessionStrategy => { const jwt = createJoseManager(config?.jwt, jose) const cookieConfig = createCookieManager(cookies) const maxAge = config?.jwt?.maxAge ?? 60 * 60 * 24 * 15 const strategy = config?.jwt?.expirationStrategy ?? "absolute" + const schema = createSchemaRegistry(identity) const updateExpires = ({ exp }: { exp: number | undefined }): Date | null => { if (!exp) return null @@ -122,23 +125,35 @@ export const createStatelessStrategy = ({ ...user } = await jwt.verifyToken(sessionToken) if (!user.sub) return { session: null, headers: newHeaders } + const session: Session = { user: user as DefaultUser, expires: exp ? new Date(exp * 1000).toISOString() : "", } const expiresAt = updateExpires({ exp }) - if (!expiresAt) return { session, headers: newHeaders } + if (!expiresAt) { + const userSession = identity.skipValidation + ? session.user + : await schema.parse>(session.user) + return { session: { expires: session.expires, user: userSession }, headers } + } + + const newSessionPayload = identity.skipValidation + ? session.user + : await schema.parse>(session.user) + const newSession = { user: newSessionPayload, expires: expiresAt.toISOString() } - const newSession = { ...session, expires: expiresAt.toISOString() } + const issuedAt = strategy === "absolute" ? _iat : Math.floor(Date.now() / 1000) const newSessionToken = await jwt.createToken({ - ...(user as DefaultUser), + ...newSessionPayload, exp: Math.floor(expiresAt.getTime() / 1000), + iat: issuedAt, mexp, }) logger?.log("SESSION_REFRESHED", { structuredData: { strategy: "stateless", expiresAt: expiresAt.toISOString() } }) return { - session: newSession, + session: newSession as unknown as Session, headers: cookieConfig.setCookie({ sessionToken: newSessionToken }), } } catch (error) { @@ -147,7 +162,17 @@ export const createStatelessStrategy = ({ } } - const createSession = async (session: TypedJWTPayload) => jwt.createToken(session) + const createSession = async (session: TypedJWTPayload) => { + if (identity.skipValidation) { + logger?.log("IDENTITY_VALIDATION_DISABLED", { + structuredData: { + identity_validation_disabled: true, + }, + }) + } + const payload = identity.skipValidation ? session : await schema.parse>(session) + return jwt.createToken(payload) + } const refreshSession = async ( headers: Headers, @@ -166,24 +191,18 @@ export const createStatelessStrategy = ({ if (!isValidToken) { return { session: null, headers: cookieConfig.clear() } } - const { - exp, - mexp, - sub, - iat, - jti: _jti, - nbf: _nbf, - aud: _aud, - iss: _iss, - ...user - } = await jwt.verifyToken(sessionToken) + const verifiedToken = await jwt.verifyToken(sessionToken) + const { exp, mexp, sub, iat } = verifiedToken + const defaultPayload = identity.skipValidation ? verifiedToken : await schema.parse(verifiedToken) + const sessionPayload = identity.skipValidation ? session.user : await schema.parseAsPartial(session.user) + const expiresAt = session.expires ? new Date(session.expires) : (updateExpires({ exp }) ?? new Date(Date.now() + maxAge * 1000)) const updatedSession: Session = { user: { - ...user, - ...session.user, + ...defaultPayload, + ...sessionPayload, sub, } as DefaultUser, expires: expiresAt.toISOString(), diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index 266939d5..0061b89f 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -1,13 +1,15 @@ import { AuthInvalidConfigurationError } from "@/shared/errors.ts" import { createStatelessStrategy } from "@/session/stateless.ts" -import type { CreateSessionStrategyOptions, SessionStrategy, User } from "@/@types/session.ts" +import type { CreateSessionStrategyOptions, SessionStrategy, User, UserShape } from "@/@types/session.ts" +import { EditableShape, ShapeToObject } from "@/@types/utility.ts" -export const createSessionStrategy = ({ +export const createSessionStrategy = >({ config, jose, cookies, logger, -}: CreateSessionStrategyOptions): SessionStrategy => { + identity, +}: CreateSessionStrategyOptions): SessionStrategy & User> => { const strategy = config?.strategy ?? "jwt" switch (strategy) { @@ -17,6 +19,7 @@ export const createSessionStrategy = ({ config, cookies, logger, + identity, }) default: throw new AuthInvalidConfigurationError(`[auth] unknown session strategy "${strategy}". Valid options are: "jwt".`) diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 2ef05684..ca908043 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -70,6 +70,8 @@ export class AuthClientError extends Error { } export class AuthInvalidConfigurationError extends Error { + readonly type = "AUTH_INVALID_CONFIGURATION_ERROR" + constructor(message?: string, options?: ErrorOptions) { super(message, options) this.name = new.target.name @@ -77,6 +79,18 @@ export class AuthInvalidConfigurationError extends Error { } } +export class AuthValidationError extends Error { + readonly type = "AUTH_VALIDATION_ERROR" + readonly code: string + + constructor(code: string, message?: string, options?: ErrorOptions) { + super(message, options) + this.code = code + this.name = new.target.name + Error?.captureStackTrace?.(this, new.target) + } +} + export const isNativeError = (error: unknown): error is Error => { return error instanceof Error } @@ -100,3 +114,7 @@ export const isAuthClientError = (error: unknown): error is AuthClientError => { export const isAuthInvalidConfigurationError = (error: unknown): error is AuthInvalidConfigurationError => { return error instanceof AuthInvalidConfigurationError } + +export const isAuthValidationError = (error: unknown): error is AuthValidationError => { + return error instanceof AuthValidationError +} diff --git a/packages/core/src/shared/identity.ts b/packages/core/src/shared/identity.ts new file mode 100644 index 00000000..e3d7900b --- /dev/null +++ b/packages/core/src/shared/identity.ts @@ -0,0 +1,16 @@ +import { string, z } from "zod/v4" +import { EditableShape } from "@/@types/utility.ts" + +export const UserIdentity = z.object({ + sub: string(), + name: string().nullable().optional(), + image: string().nullable().optional(), + email: string().nullable().optional(), +}) + +export const StrippedUserIdentity = UserIdentity.omit({ sub: true }) + +export type UserShape = (typeof UserIdentity)["shape"] +export type UserIdentityType = z.infer + +export const createIdentity = >(shape: S) => z.object(shape) diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index b9472818..27b96e76 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 { AuthConfig, InternalLogger, Logger, LogLevel, SyslogOptions } from "@/@types/index.ts" +import type { AuthConfig, EditableShape, InternalLogger, Logger, LogLevel, SyslogOptions, UserShape } from "@/@types/index.ts" /** * Log message definitions organized by category. @@ -278,6 +278,18 @@ export const logMessages = { msgId: "CSRF_TOKEN_VERIFIED", message: "CSRF token verification succeeded", }, + IDENTITY_VALIDATION_DISABLED: { + facility: 4, + severity: "warning", + msgId: "IDENTITY_VALIDATION_DISABLED", + message: "Identity validation is disabled. User data will not be validated against a schema.", + }, + IDENTITY_VALIDATION_FAILED: { + facility: 4, + severity: "error", + msgId: "IDENTITY_VALIDATION_FAILED", + message: "User identity validation against the schema failed", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { @@ -359,7 +371,7 @@ 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/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 91512099..f30ae53f 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -2,7 +2,7 @@ import { getEnv } from "@/shared/env.ts" import { encoder } from "@aura-stack/jose/crypto" import { AuthInternalError } from "@/shared/errors.ts" import { isRelativeURL, isValidURL } from "@/shared/assert.ts" -import type { ZodError } from "zod" +import type { ZodError } from "zod/v4" import type { APIErrorMap } from "@/@types/index.ts" export const AURA_AUTH_VERSION = "0.5.0" diff --git a/packages/core/test/actions/callback/callback.test.ts b/packages/core/test/actions/callback/callback.test.ts index e1c236c7..6b21d850 100644 --- a/packages/core/test/actions/callback/callback.test.ts +++ b/packages/core/test/actions/callback/callback.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest" -import { GET } from "@test/presets.ts" +import { GET, jose, oauthCustomService } from "@test/presets.ts" import { createPKCE } from "@/shared/security.ts" import { setCookie, getSetCookie } from "@/cookie.ts" import { AURA_AUTH_VERSION } from "@/shared/utils.ts" +import { createAuth } from "@/createAuth.ts" beforeEach(() => { vi.stubEnv("BASE_URL", undefined) @@ -117,6 +118,9 @@ describe("callbackAction", () => { email: "john.doe@example.com", name: "John Doe", picture: "https://example.com/john-doe.jpg", + // Additional fields that may be returned by the user info endpoint but don't included in identity schema + extra_info: "extra_value", + email_verified: true, } mockFetch.mockResolvedValueOnce({ @@ -183,5 +187,230 @@ describe("callbackAction", () => { expect(getSetCookie(response, "__Secure-aura-auth.state")).toEqual("") expect(getSetCookie(response, "__Secure-aura-auth.redirect_to")).toEqual("") expect(getSetCookie(response, "__Secure-aura-auth.redirect_uri")).toEqual("") + + const session = await jose.decodeJWT(getSetCookie(response, "__Secure-aura-auth.session_token")!) + expect(session).toMatchObject({ + sub: "user_123", + email: "john.doe@example.com", + name: "John Doe", + image: "https://example.com/john-doe.jpg", + }) + expect(session).not.toHaveProperty("extra_info") + expect(session).not.toHaveProperty("email_verified") + }) + + test("callback action workflow with strict schema validation", async () => { + const mockFetch = vi.fn() + + vi.stubGlobal("fetch", mockFetch) + + const accessTokenMock = { + access_token: "access_123", + token_type: "Bearer", + } + + const userInfoMock = { + id: "user_123", + email: "john.doe@example.com", + name: "John Doe", + picture: "https://example.com/john-doe.jpg", + // Additional fields that may be returned by the user info endpoint but don't included in identity schema + extra_info: "extra_value", + email_verified: true, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => accessTokenMock, + }) + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => userInfoMock, + }) + + const state = setCookie("__Secure-aura-auth.state", "abc") + const redirectURI = setCookie("__Secure-aura-auth.redirect_uri", "https://example.com/auth/callback/oauth-provider") + const redirectTo = setCookie("__Secure-aura-auth.redirect_to", "/auth") + const { codeVerifier } = await createPKCE() + const codeVerifierCookie = setCookie("__Secure-aura-auth.code_verifier", codeVerifier) + // @todo: fix oauth types + const GET = createAuth({ + oauth: [ + { + ...oauthCustomService, + profile: (profile) => ({ + sub: profile.id, + email: profile.email, + name: profile.name, + image: profile.picture, + extra_info: profile.extra_info, + email_verified: profile.email_verified, + }), + }, + ], + identity: { unknownKeys: "strict" }, + }).handlers.GET + const response = await GET( + new Request("https://example.com/auth/callback/oauth-provider?code=auth_code_123&state=abc", { + headers: { + Cookie: [state, redirectURI, redirectTo, codeVerifierCookie].join("; "), + }, + }) + ) + + expect(fetch).toHaveBeenCalledWith( + "https://example.com/oauth/access_token", + expect.objectContaining({ + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: "oauth_client_id", + client_secret: "oauth_client_secret", + code: "auth_code_123", + redirect_uri: "https://example.com/auth/callback/oauth-provider", + grant_type: "authorization_code", + code_verifier: codeVerifier, + }).toString(), + signal: expect.any(AbortSignal), + }) + ) + + expect(fetch).toHaveBeenCalledWith( + "https://example.com/oauth/userinfo", + expect.objectContaining({ + method: "GET", + headers: { + "User-Agent": `Aura Auth/${AURA_AUTH_VERSION}`, + Accept: "application/json", + Authorization: "Bearer access_123", + }, + signal: expect.any(AbortSignal), + }) + ) + + expect(fetch).toHaveBeenCalledTimes(2) + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + type: "AUTH_VALIDATION_ERROR", + code: "INVALID_IDENTITY_VALIDATION_FAILED", + message: expect.any(String), + }) + }) + + test("callback action workflow with passthrough schema validation", async () => { + const mockFetch = vi.fn() + + vi.stubGlobal("fetch", mockFetch) + + const accessTokenMock = { + access_token: "access_123", + token_type: "Bearer", + } + + const userInfoMock = { + id: "user_123", + email: "john.doe@example.com", + name: "John Doe", + picture: "https://example.com/john-doe.jpg", + // Additional fields that may be returned by the user info endpoint but don't included in identity schema + extra_info: "extra_value", + email_verified: true, + } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => accessTokenMock, + }) + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => userInfoMock, + }) + + const state = setCookie("__Secure-aura-auth.state", "abc") + const redirectURI = setCookie("__Secure-aura-auth.redirect_uri", "https://example.com/auth/callback/oauth-provider") + const redirectTo = setCookie("__Secure-aura-auth.redirect_to", "/auth") + const { codeVerifier } = await createPKCE() + const codeVerifierCookie = setCookie("__Secure-aura-auth.code_verifier", codeVerifier) + // @todo: fix oauth types + const GET = createAuth({ + oauth: [ + { + ...oauthCustomService, + profile: (profile) => ({ + sub: profile.id, + email: profile.email, + name: profile.name, + image: profile.picture, + extra_info: profile.extra_info, + email_verified: profile.email_verified, + }), + }, + ], + identity: { unknownKeys: "passthrough" }, + }).handlers.GET + const response = await GET( + new Request("https://example.com/auth/callback/oauth-provider?code=auth_code_123&state=abc", { + headers: { + Cookie: [state, redirectURI, redirectTo, codeVerifierCookie].join("; "), + }, + }) + ) + + expect(fetch).toHaveBeenCalledWith( + "https://example.com/oauth/access_token", + expect.objectContaining({ + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: "oauth_client_id", + client_secret: "oauth_client_secret", + code: "auth_code_123", + redirect_uri: "https://example.com/auth/callback/oauth-provider", + grant_type: "authorization_code", + code_verifier: codeVerifier, + }).toString(), + signal: expect.any(AbortSignal), + }) + ) + + expect(fetch).toHaveBeenCalledWith( + "https://example.com/oauth/userinfo", + expect.objectContaining({ + method: "GET", + headers: { + "User-Agent": `Aura Auth/${AURA_AUTH_VERSION}`, + Accept: "application/json", + Authorization: "Bearer access_123", + }, + signal: expect.any(AbortSignal), + }) + ) + + expect(fetch).toHaveBeenCalledTimes(2) + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/auth") + + expect(getSetCookie(response, "__Secure-aura-auth.session_token")).toBeDefined() + expect(getSetCookie(response, "__Secure-aura-auth.state")).toEqual("") + expect(getSetCookie(response, "__Secure-aura-auth.redirect_to")).toEqual("") + expect(getSetCookie(response, "__Secure-aura-auth.redirect_uri")).toEqual("") + + const session = await jose.decodeJWT(getSetCookie(response, "__Secure-aura-auth.session_token")!) + expect(session).toMatchObject({ + sub: "user_123", + email: "john.doe@example.com", + name: "John Doe", + image: "https://example.com/john-doe.jpg", + extra_info: "extra_value", + email_verified: true, + }) }) }) diff --git a/packages/core/test/actions/session/session.test.ts b/packages/core/test/actions/session/session.test.ts index 2291d30e..3e0eaa55 100644 --- a/packages/core/test/actions/session/session.test.ts +++ b/packages/core/test/actions/session/session.test.ts @@ -213,11 +213,11 @@ describe("sessionAction", () => { }) ) const session = await requestSession.json() - const { id, ...rest } = userInfoMock + const { id, name, image, email } = userInfoMock expect(session).toEqual({ authenticated: true, session: { - user: { sub: id, ...rest }, + user: { sub: id, name, image, email }, expires: expect.any(String), }, }) diff --git a/packages/core/test/actions/updateSession/updateSession.test.ts b/packages/core/test/actions/updateSession/updateSession.test.ts index e6e4f0ac..4315e018 100644 --- a/packages/core/test/actions/updateSession/updateSession.test.ts +++ b/packages/core/test/actions/updateSession/updateSession.test.ts @@ -40,7 +40,7 @@ describe("updateSession action", () => { "X-CSRF-Token": csrfToken, Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, - body: JSON.stringify(newUser), + body: JSON.stringify({ user: newUser }), }) ) expect(response.status).toBe(200) @@ -78,7 +78,7 @@ describe("updateSession action", () => { headers: { Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, - body: JSON.stringify(newUser), + body: JSON.stringify({ user: newUser }), }) ) expect(response.status).toBe(401) @@ -88,4 +88,46 @@ describe("updateSession action", () => { updated: false, }) }) + + test("updates user session with stripped fields", async () => { + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }) + const csrfToken = await createCSRF(jose) + + const newUser = { + name: "Alice Smith", + email: "alicesmith@example.com", + image: "https://example.com/alicesmith-avatar.jpg", + role: "admin", + permissions: ["read", "write", "delete"], + } + + const response = await PATCH( + new Request("http://localhost:3000/auth/session", { + method: "PATCH", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + body: JSON.stringify({ user: newUser }), + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + session: expect.objectContaining({ + user: { + sub: "1234567890", + name: "Alice Smith", + email: "alicesmith@example.com", + image: "https://example.com/alicesmith-avatar.jpg", + }, + }), + headers: expect.any(Object), + updated: true, + }) + }) }) diff --git a/packages/core/test/api/getSession.test.ts b/packages/core/test/api/getSession.test.ts new file mode 100644 index 00000000..9204cb03 --- /dev/null +++ b/packages/core/test/api/getSession.test.ts @@ -0,0 +1,143 @@ +import { getCookie, getSetCookie } from "@/cookie.ts" +import { createAuth } from "@/createAuth.ts" +import { api, jose } from "@test/presets.ts" +import { describe, test, expect } from "vitest" + +describe("getSession", () => { + test("getSession with no session token", async () => { + const session = await api.getSession({ headers: new Headers() }) + expect(session).toMatchObject({ + session: null, + headers: {}, + authenticated: false, + }) + }) + + test("getSession with invalid session token", async () => { + const session = await api.getSession({ + headers: { Cookie: `aura-auth.session_token=invalidtoken` }, + }) + expect(session).toMatchObject({ + session: null, + headers: {}, + authenticated: false, + }) + }) + + test("getSession with valid session token", async () => { + const jwt = await jose.encodeJWT({ + sub: "123", + name: "Alice", + email: "alice@example.com", + }) + const session = await api.getSession({ + headers: { Cookie: `aura-auth.session_token=${jwt}` }, + }) + expect(session.session).toMatchObject({ + user: { + sub: "123", + name: "Alice", + email: "alice@example.com", + }, + expires: expect.any(String), + }) + }) + + test("getSession with expired session token", async () => { + const jwt = await jose.encodeJWT({ + sub: "123", + name: "Alice", + email: "", + exp: Math.floor(Date.now() / 1000) - 60, + }) + const session = await api.getSession({ + headers: { Cookie: `aura-auth.session_token=${jwt}` }, + }) + expect(session).toMatchObject({ + session: null, + headers: {}, + authenticated: false, + }) + }) + + test("getSession with session token missing sub claim", async () => { + const jwt = await jose.encodeJWT({ + name: "Alice", + email: "alice@example.com", + }) + const session = await api.getSession({ + headers: { Cookie: `aura-auth.session_token=${jwt}` }, + }) + expect(session).toMatchObject({ + session: null, + headers: {}, + authenticated: false, + }) + }) + + test("getSession with extra claims in session token", async () => { + const jwt = await jose.encodeJWT({ + sub: "123", + name: "Alice", + email: "alice@example.com", + role: "admin", + permissions: ["read", "write"], + }) + const session = await api.getSession({ + headers: { Cookie: `aura-auth.session_token=${jwt}` }, + }) + expect(session.session).toMatchObject({ + user: { + sub: "123", + name: "Alice", + email: "alice@example.com", + }, + expires: expect.any(String), + }) + expect(session.session?.user).not.toHaveProperty("role") + expect(session.session?.user).not.toHaveProperty("permissions") + const decodeSession = await jose.decodeJWT(getCookie(session.headers, "aura-auth.session_token")!) + expect(decodeSession).toMatchObject({ + sub: "123", + name: "Alice", + email: "alice@example.com", + }) + expect(session.session?.user).not.toHaveProperty("role") + expect(session.session?.user).not.toHaveProperty("permissions") + }) + + test("getSession refreshes session token if exp is close", async () => { + const auth = createAuth({ oauth: [], session: { jwt: { expirationStrategy: "rolling" } } }) + + const jwt = await auth.jose.encodeJWT({ + sub: "123", + name: "Alice", + email: "alice@example.com", + iat: Math.floor(Date.now() / 1000) - 3600, + exp: Math.floor(Date.now() / 1000) + 10, + role: "admin", + permissions: ["read", "write"], + }) + const session = await auth.api.getSession({ + headers: { Cookie: `aura-auth.session_token=${jwt}` }, + }) + expect(session.session).toMatchObject({ + user: { + sub: "123", + name: "Alice", + email: "alice@example.com", + }, + }) + expect(session.session?.user).not.toHaveProperty("role") + expect(session.session?.user).not.toHaveProperty("permissions") + + const decodeSession = await jose.decodeJWT(getSetCookie(session.headers, "aura-auth.session_token")!) + expect(decodeSession).toMatchObject({ + sub: "123", + name: "Alice", + email: "alice@example.com", + }) + expect(session.session?.user).not.toHaveProperty("role") + expect(session.session?.user).not.toHaveProperty("permissions") + }) +}) diff --git a/packages/core/test/api/updateSession.test.ts b/packages/core/test/api/updateSession.test.ts index 56896012..30630fce 100644 --- a/packages/core/test/api/updateSession.test.ts +++ b/packages/core/test/api/updateSession.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect } from "vitest" +import { z } from "zod/v4" import { createAuth } from "@/createAuth.ts" import { api, jose } from "@test/presets.ts" import { createCSRF } from "@/shared/security.ts" -import type { User } from "@/index.ts" +import { UserIdentity } from "@/shared/identity.ts" describe("updateSession API", () => { test("invalid session", async () => { @@ -93,8 +94,14 @@ describe("updateSession API", () => { }) test("updates user session with generic user type", async () => { - const { jose, api } = createAuth({ + const { jose, api } = createAuth({ oauth: [], + identity: { + schema: UserIdentity.extend({ + role: z.string(), + department: z.string(), + }), + }, }) const sessionToken = await jose.encodeJWT({ @@ -133,6 +140,43 @@ describe("updateSession API", () => { }) }) + test("updates user session with invalid user data", async () => { + const { jose, api } = createAuth({ + oauth: [], + }) + + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }) + + const csrfToken = await createCSRF(jose) + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }), + session: { + user: { role: "superadmin", money: "100000" } as any, + }, + skipCSRFCheck: true, + }) + expect(updated).toEqual({ + session: { + user: { + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + updated: true, + }) + }) + test("updates expiry on valid session", async () => { const sessionToken = await jose.encodeJWT({ sub: "1234567890", diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 16844457..5367554b 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -1,6 +1,6 @@ import { createAuth } from "@/createAuth.ts" -import type { OAuthProviderCredentials } from "@/@types/index.ts" import type { JWTPayload } from "@/jose.ts" +import type { OAuthProviderCredentials } from "@/@types/index.ts" export const oauthCustomService: OAuthProviderCredentials = { id: "oauth-provider", @@ -14,7 +14,7 @@ export const oauthCustomService: OAuthProviderCredentials = { clientSecret: "oauth_client_secret", } -export const oauthCustomServiceProfile: OAuthProviderCredentials> = { +export const oauthCustomServiceProfile: OAuthProviderCredentials = { ...oauthCustomService, id: "oauth-profile", profile(profile) { @@ -23,7 +23,6 @@ export const oauthCustomServiceProfile: OAuthProviderCredentials { - expectTypeOf(createAuth).parameter(0).toEqualTypeOf() expectTypeOf(createAuth).toEqualTypeOf< - (config: AuthConfig) => AuthInstance + >(config: AuthConfig) => AuthInstance> >() - expectTypeOf(createAuth).returns.toEqualTypeOf>() - expectTypeOf(createAuth({ oauth: [] })["jose"]).toEqualTypeOf< - JoseInstance + expectTypeOf(createAuth({ oauth: [] }).api.getSession).toEqualTypeOf< + (options: GetSessionAPIOptions) => Promise>> >() - expectTypeOf(createAuth({ oauth: [] })["api"].getSession).toEqualTypeOf< - (options: GetSessionAPIOptions) => Promise> + expectTypeOf(createAuth({ oauth: [] }).api.updateSession).toEqualTypeOf< + (options: UpdateSessionAPIOptions) => Promise>> >() - expectTypeOf(createAuth({ oauth: [] })["api"].updateSession).toEqualTypeOf< - (options: UpdateSessionAPIOptions) => Promise> + + expectTypeOf(createAuth({ oauth: [] }).jose.signJWS).toEqualTypeOf< + (payload: TypedJWTPayload>, options?: JWTHeaderParameters) => Promise + >() + expectTypeOf(createAuth({ oauth: [] }).jose.verifyJWS).toEqualTypeOf< + (token: string, options?: JWTVerifyOptions) => Promise>> >() - expectTypeOf(createAuth({ oauth: [] })["api"].getSession).toEqualTypeOf< - (options: GetSessionAPIOptions) => Promise> + expectTypeOf( + createAuth({ oauth: [], identity: { schema: UserIdentity.extend({ role: z.string() }) } }).jose.signJWS + ).toEqualTypeOf< + ( + payload: TypedJWTPayload< + Partial< + { + sub: string + role: string + name?: string | null | undefined + image?: string | null | undefined + email?: string | null | undefined + } & { + sub: string + name?: string | null | undefined + image?: string | null | undefined + email?: string | null | undefined + } + > + >, + options?: JWTHeaderParameters + ) => Promise >() - expectTypeOf(createAuth({ oauth: [] })["api"].updateSession).toEqualTypeOf< - (options: UpdateSessionAPIOptions) => Promise> + expectTypeOf( + createAuth({ oauth: [], identity: { schema: UserIdentity.extend({ role: z.string() }) } }).jose.verifyJWS + ).toEqualTypeOf< + (token: string, options?: JWTVerifyOptions) => Promise>> + >() + + expectTypeOf( + createAuth({ oauth: [], identity: { schema: UserIdentity.extend({ role: z.string() }) } }).api.getSession + ).toEqualTypeOf<(options: GetSessionAPIOptions) => Promise>>>() + expectTypeOf( + createAuth({ oauth: [], identity: { schema: UserIdentity.extend({ role: z.string() }) } }).api.updateSession + ).toEqualTypeOf< + ( + options: UpdateSessionAPIOptions> + ) => Promise>> >() })