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
333 changes: 156 additions & 177 deletions bun.lock

Large diffs are not rendered by default.

731 changes: 46 additions & 685 deletions deno.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@
"devDependencies": {
"@types/node": "catalog:node",
"@vitest/coverage-v8": "catalog:vitest",
"eslint": "^9.35.0",
"oxfmt": "^0.41.0",
"oxlint": "^1.56.0",
"tsdown": "^0.21.8",
"turbo": "^2.7.5",
"typescript": "catalog:typescript",
Expand Down Expand Up @@ -123,5 +120,9 @@
},
"engines": {
"node": ">=22"
},
"dependencies": {
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0"
}
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"license": "MIT",
"dependencies": {
"@aura-stack/jose": "workspace:*",
"@aura-stack/router": "^0.6.0",
"@aura-stack/router": "^0.7.0",
Comment thread
halvaradop marked this conversation as resolved.
"arktype": "^2.2.0",
"valibot": "^1.3.1",
"zod": "catalog:zod-v4"
Expand Down
21 changes: 11 additions & 10 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { createJoseInstance } from "@/jose.ts"
import { createAuthAPI } from "@/api/createApi.ts"
import { createLogEntry } from "@/shared/logger.ts"
import { Identities, UserIdentity } from "@/shared/identity.ts"
import type { ZodObject } from "zod/v4"
import { createSchemaRegistry } from "@/validator/registry.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 { ObjectSchema } from "valibot"
import { Type } from "arktype"

/**
* Main configuration interface for Aura Auth.
Expand Down Expand Up @@ -285,8 +283,9 @@ export interface InternalLogger {
* Identity validation settings used when building session strategy and OAuth profile mapping.
* Controls the Zod schema and how unknown keys are handled on user objects.
*/
export interface IdentityConfig<Schema extends ZodObject<any> | ObjectSchema<any, undefined | Type<any>> = typeof UserIdentity> {
export interface IdentityConfig<Schema extends SchemaTypes = typeof UserIdentity> {
schema?: Schema
schemaAsPartial?: Schema
skipValidation?: boolean
unknownKeys?: "passthrough" | "strict" | "strip"
}
Expand Down Expand Up @@ -347,11 +346,13 @@ 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
}
identity: SchemaRegistryContext
}

export interface SchemaRegistryContext {
schemaRegistry: ReturnType<typeof createSchemaRegistry>
skipValidation?: boolean
unknownKeys: "passthrough" | "strict" | "strip"
}

/**
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { JWK } from "@aura-stack/jose/jose"
import type { infer as Infer } from "zod/v4/core"
import type { TypedJWTPayload } from "@aura-stack/jose"
import type { Identities, UserIdentity } from "@/shared/identity.ts"
import type { DeepPartial, FromShapeToObject, Prettify } from "@/@types/utility.ts"
import type { CookieStoreConfig, IdentityConfig, InternalLogger, JoseInstance } from "@/@types/config.ts"
import type { JWK } from "@aura-stack/jose/jose"
import type { CookieStoreConfig, InternalLogger, JoseInstance, SchemaRegistryContext } from "@/@types/config.ts"

/** Application user type, inferred from the configured identity schema (defaults to the built-in user shape). */
export type User = Infer<typeof UserIdentity>
Expand Down Expand Up @@ -231,7 +231,7 @@ export interface CreateSessionStrategyOptions<Identity extends Identities> {
jose: JoseInstance<FromShapeToObject<Identity> & User>
cookies: () => CookieStoreConfig
logger?: InternalLogger
identity: IdentityConfig
identity: SchemaRegistryContext
}

/** Options specialized for the JWT-backed session strategy. */
Expand All @@ -240,7 +240,7 @@ export interface JWTStrategyOptions<DefaultUser extends User = User> {
jose: JoseInstance<DefaultUser>
logger?: InternalLogger
cookies: () => CookieStoreConfig
identity: IdentityConfig
identity: SchemaRegistryContext
}

/** Minimal token issue/verify surface used by session code paths. */
Expand Down
14 changes: 10 additions & 4 deletions packages/core/src/actions/callback/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,31 @@ import { z } from "zod/v4"
import { createEndpoint, createEndpointConfig, HeadersBuilder } from "@aura-stack/router"
import { createCSRF } from "@/shared/crypto.ts"
import { cacheControl } from "@/shared/headers.ts"
import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts"
import { timingSafeEqual } from "@/shared/utils.ts"
import { getUserInfo } from "@/actions/callback/userinfo.ts"
import { OAuthAuthorizationErrorResponse } from "@/schemas.ts"
import { getCookie, expiredCookieAttributes } from "@/cookie.ts"
import { createAccessToken } from "@/actions/callback/access-token.ts"
import { AuthSecurityError, OAuthProtocolError } from "@/shared/errors.ts"
import { isRelativeURL, isSameOrigin, isTrustedOrigin } from "@/shared/assert.ts"
import { getOriginURL, getTrustedOrigins } from "@/actions/signIn/authorization.ts"
import { createAccessToken } from "@/actions/callback/access-token.ts"
import { getCookie, expiredCookieAttributes } from "@/cookie.ts"
import type { OAuthProviderRecord } from "@/@types/index.ts"
import { timingSafeEqual } from "@/shared/utils.ts"

const callbackConfig = (oauth: OAuthProviderRecord) => {
// @ts-ignore
return createEndpointConfig("/callback/:oauth", {
/**
* @todo Add support to any schema (zod, arktype and valibot)
*/
schemas: {
// @ts-ignore
params: z.object({
oauth: z.enum(
Object.keys(oauth) as (keyof OAuthProviderRecord)[],
"The OAuth provider is not supported or invalid."
),
}),
// @ts-ignore
searchParams: z.object({
code: z.string("Missing code parameter in the OAuth authorization response."),
state: z.string("Missing state parameter in the OAuth authorization response."),
Expand Down
17 changes: 5 additions & 12 deletions packages/core/src/actions/updateSession/updateSession.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import { z } from "zod/v4"
import { createEndpoint, createEndpointConfig } from "@aura-stack/router"
import { updateSession } from "@/api/updateSession.ts"
import { getFullSchema } from "@/validator/registry.ts"
import type { User } from "@/@types/session.ts"
import type { IdentityConfig } from "@/@types/config.ts"
import { UserIdentity } from "@/shared/identity.ts"
import type { SchemaRegistryContext } from "@/@types/config.ts"

export const config = (_identity: IdentityConfig) => {
export const config = (identity: SchemaRegistryContext) => {
return createEndpointConfig({
schemas: {
body: z.object({
/**
* @todo add support for valibot schemas in the body as well, currently only Zod is supported
*/
user: UserIdentity.partial().optional(),
expires: z.coerce.date().optional(),
}),
body: getFullSchema(identity.schemaRegistry.schemaAsPartial),
},
Comment thread
halvaradop marked this conversation as resolved.
})
}

export const updateSessionAction = (identity: IdentityConfig) => {
export const updateSessionAction = (identity: SchemaRegistryContext) => {
return createEndpoint(
"PATCH",
"/session",
Expand Down
12 changes: 5 additions & 7 deletions packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ import type {
UpdateSessionReturn,
SignInCredentialsReturn,
SignInAPIReturn,
SignOutAPIReturn,
SignInCredentialsAPIReturn,
UpdateSessionAPIReturn,
SignInCredentialsOptions,
} from "@/@types/index.ts"

Expand Down Expand Up @@ -55,7 +52,7 @@ export const createAuthClient = <DefaultUser extends User = User>(options: AuthC
if (!response.ok) return null
const session = await response.json()
if (!session.success) return null
return session.session
return session.session as Session<DefaultUser>
} catch (error) {
console.error("Error fetching session:", error)
return null
Expand Down Expand Up @@ -97,7 +94,7 @@ export const createAuthClient = <DefaultUser extends User = User>(options: AuthC
redirectTo: options?.redirectTo,
},
})
const json: SignInCredentialsAPIReturn = await response.json()
const json = await response.json()
if ((options?.redirect ?? true) && typeof window !== "undefined" && json?.redirectURL) {
window.location.assign(json.redirectURL)
}
Expand All @@ -122,6 +119,7 @@ export const createAuthClient = <DefaultUser extends User = User>(options: AuthC
}
const user = session.user ?? {}
const response = await client.patch("/session", {
// @ts-ignore - Fixing the type here - go to @aura-stack/router.
body: {
user,
expires: session.expires ? new Date(session.expires) : undefined,
Expand All @@ -130,7 +128,7 @@ export const createAuthClient = <DefaultUser extends User = User>(options: AuthC
"X-CSRF-Token": csrfToken,
},
})
const json: UpdateSessionAPIReturn<DefaultUser> = await response.json()
const json = await response.json()
if ((options.redirect ?? true) && typeof window !== "undefined" && json?.redirectURL) {
window.location.assign(json.redirectURL)
}
Expand All @@ -157,7 +155,7 @@ export const createAuthClient = <DefaultUser extends User = User>(options: AuthC
"X-CSRF-Token": csrfToken,
},
})
const json: SignOutAPIReturn = await response.json()
const json = await response.json()
if ((options?.redirect ?? true) && typeof window !== "undefined" && json?.redirectURL) {
window.location.assign(json.redirectURL)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/createAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
updateSessionAction,
} from "@/actions/index.ts"
import type { EditableShape, Identities, UserShape } from "@/shared/identity.ts"
import type { AuthConfig, AuthInstance, FromShapeToObject } from "@/@types/index.ts"
import type { AuthConfig, AuthInstance, FromShapeToObject, SchemaRegistryContext } from "@/@types/index.ts"

const createInternalConfig = <Identity extends Identities>(config?: AuthConfig<Identity>): RouterConfig => {
const context = createContext<Identity>(config)
Expand Down Expand Up @@ -41,7 +41,7 @@ export const createAuthInstance = <Identity extends Identities>(authConfig: Auth
sessionAction,
signOutAction,
csrfTokenAction,
updateSessionAction(config.context.identity),
updateSessionAction(config.context.identity as SchemaRegistryContext),
],
config
)
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/router/context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createJoseInstance } from "@/jose.ts"
import { createCookieStore } from "@/cookie.ts"
import { Identities, UserIdentity } from "@/shared/identity.ts"
import { Identities } from "@/shared/identity.ts"
import { createProxyLogger } from "@/shared/logger.ts"
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 { AuthConfig, InternalContext, FromShapeToObject } from "@/@types/index.ts"
Expand All @@ -18,6 +19,15 @@ export const createContext = <Identity extends Identities>(config?: AuthConfig<I
const standardCookieStore = createCookieStore(false, cookiePrefix, cookieOverrides, logger)
const jose = createJoseInstance<FromShapeToObject<Identity>>(config?.secret, config?.session)

const unknownKeys = config?.identity?.unknownKeys ?? "strip"
const skipValidation = config?.identity?.skipValidation ?? false

const schemaRegistry = createSchemaRegistry({
schema: config?.identity?.schema,
unknownKeys,
skipValidation,
})

const ctx = {
oauth: createBuiltInOAuthProviders(config?.oauth),
credentials: config?.credentials,
Expand All @@ -31,9 +41,9 @@ export const createContext = <Identity extends Identities>(config?: AuthConfig<I
cookieConfig: { secure: secureCookieStore, standard: standardCookieStore },
baseURL: config?.baseURL,
identity: {
schema: config?.identity?.schema ?? UserIdentity,
unknownKeys: config?.identity?.unknownKeys ?? "strip",
skipValidation: config?.identity?.skipValidation ?? false,
schemaRegistry,
unknownKeys,
skipValidation,
},
} as InternalContext<Identity>
ctx.sessionStrategy = createSessionStrategy<Identity>({
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/schema-registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* This file will be replaced by `validator/registry.ts`
*
*/
import { isArkType, isValibotSchema, isZodSchema } from "@/shared/assert.ts"
import { formatZodError } from "@/shared/utils.ts"
import { UserIdentity } from "@/shared/identity.ts"
Expand Down
54 changes: 24 additions & 30 deletions packages/core/src/session/stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type {
GetStatelessSessionReturn,
DeepPartial,
} from "@/@types/index.ts"
import { createSchemaRegistry } from "@/schema-registry.ts"

export const createStatelessStrategy = <DefaultUser extends User = User>({
config,
Expand All @@ -26,7 +25,6 @@ export const createStatelessStrategy = <DefaultUser extends User = User>({
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
Expand Down Expand Up @@ -114,42 +112,35 @@ export const createStatelessStrategy = <DefaultUser extends User = User>({
const { sessionToken } = cookieConfig.getCookie(headers)
if (!sessionToken) return { session: null, headers: newHeaders }

const {
exp,
iat: _iat,
jti: _jti,
nbf: _nbf,
aud: _aud,
iss: _iss,
mexp,
...user
} = await jwt.verifyToken(sessionToken)
if (!user.sub) return { session: null, headers: newHeaders }
const claims = await jwt.verifyToken(sessionToken)
const parsedClaims = identity.skipValidation ? claims : await identity.schemaRegistry.parseWithJWT(claims)
const { exp, iat: _iat, mexp: _mexp, ...defaultPayload } = parsedClaims
const userClaims = await identity.schemaRegistry.parse(defaultPayload)
if (!userClaims.sub) return { session: null, headers: newHeaders }

const session: Session<DefaultUser> = {
user: user as DefaultUser,
expires: exp ? new Date(exp * 1000).toISOString() : "",
user: userClaims as DefaultUser,
expires: parsedClaims.exp ? new Date(exp * 1000).toISOString() : "",
}

const expiresAt = updateExpires({ exp })
if (!expiresAt) {
const userSession = identity.skipValidation ? session.user : await schema.parse(session.user)
return { session: { expires: session.expires, user: userSession }, headers }
return { session: { expires: session.expires, user: userClaims }, headers }
}

const newSessionPayload = identity.skipValidation ? session.user : await schema.parse(session.user)
const newSession = { user: newSessionPayload, expires: expiresAt.toISOString() }

const issuedAt = strategy === "absolute" ? _iat : Math.floor(Date.now() / 1000)
const issuedAt = strategy === "absolute" ? parsedClaims.iat : Math.floor(Date.now() / 1000)
const newSessionToken = await jwt.createToken({
...newSessionPayload,
...userClaims,
exp: Math.floor(expiresAt.getTime() / 1000),
iat: issuedAt,
mexp,
mexp: parsedClaims.mexp,
})
logger?.log("SESSION_REFRESHED", { structuredData: { strategy: "stateless", expiresAt: expiresAt.toISOString() } })
return {
session: newSession as unknown as Session<DefaultUser>,
session: {
user: userClaims,
expires: expiresAt.toISOString(),
} as unknown as Session<DefaultUser>,
headers: cookieConfig.setCookie({ sessionToken: newSessionToken }),
}
} catch (error) {
Expand All @@ -166,8 +157,8 @@ export const createStatelessStrategy = <DefaultUser extends User = User>({
},
})
}
const payload = identity.skipValidation ? session : await schema.parse(session)
return jwt.createToken(payload)
const payload = identity.skipValidation ? session : await identity.schemaRegistry.parse(session)
return jwt.createToken(payload as unknown as DefaultUser)
}

const refreshSession = async (
Expand All @@ -187,10 +178,13 @@ export const createStatelessStrategy = <DefaultUser extends User = User>({
if (!isValidToken) {
return { session: null, headers: cookieConfig.clear() }
}
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 claims = await jwt.verifyToken(sessionToken)

const defaultPayload = identity.skipValidation ? claims : await identity.schemaRegistry.parse(claims)
const { exp, mexp, sub, iat } = defaultPayload
const sessionPayload = identity.skipValidation
? session.user
: await identity.schemaRegistry.parseAsPartial(session.user)
Comment thread
halvaradop marked this conversation as resolved.

const expiresAt = session.expires
? new Date(session.expires)
Expand Down
Loading
Loading