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
158 changes: 118 additions & 40 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { JWTKey, SessionConfig, SessionStrategy, User } from "@/@types/sess
* Main configuration interface for Aura Auth.
* This is the user-facing configuration object passed to `createAuth()`.
*/
export interface AuthConfig<Identity extends Identities> {
export type AuthConfig<Identity extends Identities> = {
/**
* 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
Expand Down Expand Up @@ -100,45 +100,11 @@ export interface AuthConfig<Identity extends Identities> {
* Base path for all authentication routes. Default is `/auth`.
*/
basePath?: `/${string}`
/**
* Enable trusted proxy headers for scenarios where the application is behind a reverse proxy or load balancer.
* This setting allows Aura Auth to correctly interpret headers like `X-Forwarded-For` and `X-Forwarded-Proto`
* to determine the original client IP address and protocol.
*
* Default is `false`. Enable this option only if you are certain that your application is behind a trusted proxy.
* Misconfiguration can lead to security vulnerabilities, such as incorrect handling of secure cookies or
* inaccurate client IP logging.
*
* This value can also be set via environment variable as `AURA_AUTH_TRUSTED_PROXY_HEADERS`
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
* @experimental
*/
trustedProxyHeaders?: boolean
/**
* Logger configuration for handling authentication-related logs and errors. It can be set to `true`,
* `DEBUG=true`, `LOG_LEVEL=debug`, or a custom logger. It implements the syslog format.
*/
logger?: boolean | Logger
/**
* Defines trusted origins for your application to prevent open redirect attacks.
* URLs from the Referer header, Origin header, request URL, and redirectTo option
* are validated against this list before redirecting.
*
* - **Exact URL**: `https://example.com` matches only that origin.
* - **Subdomain wildcard**: `https://*.example.com` matches `https://app.example.com`, `https://api.example.com`, etc.
* @example
* trustedOrigins: ["https://example.com", "https://*.example.com", "http://localhost:3000"]
*
*
* trustedOrigins: async (request) => {
* const origin = new URL(request.url).origin
* return [origin, "https://admin.example.com"]
* }
*/
trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise<TrustedOrigin[]> | TrustedOrigin[])
/**
* Defines the session management strategy for Aura Auth. It determines how sessions are created, stored, and validated.
*/
Expand Down Expand Up @@ -166,15 +132,115 @@ export interface AuthConfig<Identity extends Identities> {
* }
*/
identity?: Partial<{
/**
* Skip schema validation for session data, JWT payloads, and OAuth profiles.
* This can be useful for performance optimization if you are certain that the
* data is valid, but it can lead to security vulnerabilities if misused.
* > ⚠️ WARNING: Use this option with caution.
*/
skipValidation: boolean
/**
* Custom schema validation for user identity data. It supports any Zod, Arktype,
* Valibot or Typebox schema. Use `createIdentity` helper function to create a schema
* with the correct shape and inference.
*/
schema: ConfigSchema<Identity>
/**
* Defines how unknown keys are handled during schema validation. It can be set to:
* - `passthrough`: Unknown keys are allowed and included in the validated data.
* - `strict`: Unknown keys will cause validation to fail with an error.
* - `strip`: Unknown keys are removed from the validated data.
*/
unknownKeys: "passthrough" | "strict" | "strip"
}>
/**
* Credentials provider for username/password or similar authentication.
*/
credentials?: CredentialsProvider<Identity>
}
} & TrustedProxyHeadersConfig

// @todo Should trustedOrigins support subdomain wildcards like `https://*.example.com`?
// This option could introduce security risks if misconfigured.
export type TrustedProxyHeadersConfig =
| {
/**
* Enable trusted proxy headers for scenarios where the application is behind a reverse proxy or load balancer.
* This setting allows Aura Auth to correctly interpret headers like `X-Forwarded-For` and `X-Forwarded-Proto`
* to determine the original client IP address and protocol.
*
* Default is `false`. Enable this option only if you are certain that your application is behind a trusted proxy.
* Misconfiguration can lead to security vulnerabilities, such as incorrect handling of secure cookies or
* inaccurate client IP logging.
*
* This value can also be set via environment variable as `AURA_AUTH_TRUSTED_PROXY_HEADERS`
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
* @experimental
*/
trustedProxyHeaders: true
/**
* Defines trusted origins for your application to prevent open redirect attacks.
* URLs from the Referer header, Origin header, request URL, and redirectTo option
* are validated against this list before redirecting.
*
* - **Exact URL**: `https://example.com` matches only that origin.
* - **Subdomain wildcard**: `https://*.example.com` matches `https://app.example.com`, `https://api.example.com`, etc.
*
* > **⚠️ WARNING:** Ensure that the trusted origins are configured correctly to prevent open redirect vulnerabilities.
* Only include origins that you control and trust.
*
* @example
* trustedOrigins: ["https://example.com", "https://*.example.com", "http://localhost:3000"]
*
* trustedOrigins: async (request) => {
* const origin = new URL(request.url).origin
* return [origin, "https://admin.example.com"]
* }
*/
trustedOrigins: TrustedOrigin[] | ((request: Request) => Promise<TrustedOrigin[]> | TrustedOrigin[])
}
| {
/**
* Enable trusted proxy headers for scenarios where the application is behind a reverse proxy or load balancer.
* This setting allows Aura Auth to correctly interpret headers like `X-Forwarded-For` and `X-Forwarded-Proto`
* to determine the original client IP address and protocol.
*
* Default is `false`. Enable this option only if you are certain that your application is behind a trusted proxy.
* Misconfiguration can lead to security vulnerabilities, such as incorrect handling of secure cookies or
* inaccurate client IP logging.
*
* This value can also be set via environment variable as `AURA_AUTH_TRUSTED_PROXY_HEADERS`
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
* @experimental
*/
trustedProxyHeaders?: false
/**
* Defines trusted origins for your application to prevent open redirect attacks.
* URLs from the Referer header, Origin header, request URL, and redirectTo option
* are validated against this list before redirecting.
*
* - **Exact URL**: `https://example.com` matches only that origin.
* - **Subdomain wildcard**: `https://*.example.com` matches `https://app.example.com`, `https://api.example.com`, etc.
*
* > **⚠️ WARNING:** Ensure that the trusted origins are configured correctly to prevent open redirect vulnerabilities.
* Only include origins that you control and trust.
*
* @example
* trustedOrigins: ["https://example.com", "https://*.example.com", "http://localhost:3000"]
*
* trustedOrigins: async (request) => {
* const origin = new URL(request.url).origin
* return [origin, "https://admin.example.com"]
* }
*
*/
trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise<TrustedOrigin[]> | TrustedOrigin[])
Comment thread
halvaradop marked this conversation as resolved.
}

/**
* Cookie type with __Secure- prefix, must be Secure.
Expand Down Expand Up @@ -207,10 +273,9 @@ export type CookieStrategyAttributes = StandardCookie | SecureCookie | HostCooki
* - `sessionToken`: User session JWT
* - `csrfToken`: CSRF protection token
* - `state`: OAuth state parameter for CSRF protection
* - `code_verifier`: PKCE code verifier for authorization code flow
* - `redirect_uri`: OAuth callback URI
* - `redirect_to`: Post-authentication redirect path
* - `nonce`: OpenID Connect nonce parameter
* - `codeVerifier`: PKCE code verifier for authorization code flow
* - `redirectURI`: OAuth callback URI
* - `redirectTo`: Post-authentication redirect path
*/
export type CookieName = "sessionToken" | "csrfToken" | "state" | "codeVerifier" | "redirectTo" | "redirectURI"

Expand All @@ -222,6 +287,10 @@ export interface CookieConfig {
* Prefix to be added to all cookie names. By default "aura-stack".
*/
prefix?: string
/**
* Overrides for individual cookie configurations.
* @see {@link CookieStoreConfig} for the structure of each cookie configuration.
*/
overrides?: Partial<CookieStoreConfig>
}

Expand Down Expand Up @@ -365,8 +434,17 @@ export type AuthRuntimeConfig<DefaultUser extends User = User> = RouterGlobalCon
* Public auth instance: programmatic {@link AuthAPI}, {@link JoseInstance}, and HTTP {@link AuthClient} handlers.
*/
export interface AuthInstance<DefaultUser extends User = User> {
/**
* Programmatic API for authentication actions (getSession, signIn, signOut, etc.) that can be used in server-side contexts or API routes.
*/
api: AuthAPI<DefaultUser>
/**
* JOSE helper functions for signin, encryption and verification of JWTs.
*/
jose: JoseInstance<DefaultUser>
/**
* HTTP handlers for mounting on a router or server.
*/
handlers: {
GET: (request: Request) => Response | Promise<Response>
POST: (request: Request) => Response | Promise<Response>
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/actions/callback/access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ export const createAccessToken = async (
logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS")
return token.data
} catch (error) {
logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_FAILED")
if (error instanceof Error) {
throw new OAuthProtocolError("server_error", "Failed to communicate with OAuth provider", "", { cause: error })
if (error instanceof OAuthProtocolError) {
throw error
}
throw error
logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_FAILED")
throw new OAuthProtocolError("server_error", "Failed to communicate with OAuth provider", "", { cause: error })
}
}
24 changes: 15 additions & 9 deletions packages/core/src/actions/callback/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { cacheControl } from "@/shared/headers.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 { getCookie, getExpiredCookie } 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"
Expand Down Expand Up @@ -77,15 +77,25 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {
const cookieRedirectTo = getCookie(request, cookies.redirectTo.name)
const cookieRedirectURI = getCookie(request, cookies.redirectURI.name)

const clearCookieHeaders = new HeadersBuilder(cacheControl)
.setCookie(cookies.state.name, "", getExpiredCookie(cookies.state.attributes))
.setCookie(cookies.redirectURI.name, "", getExpiredCookie(cookies.redirectURI.attributes))
.setCookie(cookies.redirectTo.name, "", getExpiredCookie(cookies.redirectTo.attributes))
.setCookie(cookies.codeVerifier.name, "", getExpiredCookie(cookies.codeVerifier.attributes))

if (!timingSafeEqual(cookieState, state)) {
logger?.log("MISMATCHING_STATE", {
structuredData: {
oauth_provider: oauth,
},
})
throw new AuthSecurityError(
"MISMATCHING_STATE",
"The provided state passed in the OAuth response does not match the stored state."
return Response.json(
{
type: "AUTH_SECURITY_ERROR",
code: "MISMATCHING_STATE",
message: "The provided state passed in the OAuth response does not match the stored state.",
},
{ headers: clearCookieHeaders.toHeaders(), status: 400 }
)
}

Expand Down Expand Up @@ -124,14 +134,10 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {
},
})

const headers = new HeadersBuilder(cacheControl)
const headers = clearCookieHeaders
.setHeader("Location", cookieRedirectTo)
.setCookie(cookies.sessionToken.name, session, cookies.sessionToken.attributes)
.setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes)
.setCookie(cookies.state.name, "", expiredCookieAttributes)
.setCookie(cookies.redirectURI.name, "", expiredCookieAttributes)
.setCookie(cookies.redirectTo.name, "", expiredCookieAttributes)
.setCookie(cookies.codeVerifier.name, "", expiredCookieAttributes)
.toHeaders()
return Response.json({ oauth }, { status: 302, headers: headers })
},
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/actions/callback/userinfo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { fetchAsync } from "@/shared/fetch-async.ts"
import { createSecretValue } from "@/shared/crypto.ts"
import { AURA_AUTH_VERSION } from "@/shared/utils.ts"
import { OAuthErrorResponse } from "@/schemas.ts"
import { isNativeError, isOAuthProtocolError, OAuthProtocolError } from "@/shared/errors.ts"
Expand All @@ -12,10 +11,13 @@ import type { InternalLogger, OAuthProviderCredentials, User } from "@/@types/in
* @returns The standardized OAuth user profile
*/
const getDefaultUserInfo = (profile: Record<string, string>): User => {
const sub = createSecretValue(16)
const sub = profile?.id ?? profile?.sub ?? profile?.uid ?? profile?.user_id ?? profile?.account_id
if (!sub) {
throw new OAuthProtocolError("invalid_userinfo", "OAuth provider did not return a stable user identifier (id/sub/uid).")
}

return {
sub: profile?.id ?? profile?.sub ?? sub,
sub,
email: profile?.email,
name: profile?.name ?? profile?.username ?? profile?.nickname,
image: profile?.image ?? profile?.picture,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/actions/signIn/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { getEnv } from "@/shared/env.ts"
import { Identities } from "@/shared/identity.ts"
import { AuthInternalError } from "@/shared/errors.ts"
import { equals, extractPath, patternToRegex } from "@/shared/utils.ts"
import { isRelativeURL, isSameOrigin, isValidURL, isTrustedOrigin } from "@/shared/assert.ts"
import type { AuthConfig } from "@/@types/index.ts"
import type { GlobalContext } from "@aura-stack/router"
import { Identities } from "@/shared/identity.ts"

/**
* Resolves trusted origins from config (array or function).
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/api/getSession.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getErrorName, toUnionHeaders } from "@/shared/utils.ts"
import { HeadersBuilder } from "@aura-stack/router"
import { secureApiHeaders } from "@/shared/headers.ts"
import { expiredCookieAttributes } from "@/cookie.ts"
import { getExpiredCookie } from "@/cookie.ts"
import type { User } from "@/@types/session.ts"
import type { FunctionAPIContext, GetSessionAPIOptions, GetSessionAPIReturn } from "@/@types/api.ts"

Expand All @@ -10,8 +10,8 @@ export const getSession = async <DefaultUser extends User = User>({
headers: headersInit,
}: FunctionAPIContext<GetSessionAPIOptions>): Promise<GetSessionAPIReturn<DefaultUser>> => {
const headers = new HeadersBuilder(secureApiHeaders)
.setCookie(ctx.cookies.sessionToken.name, "", { ...ctx.cookies.sessionToken.attributes, ...expiredCookieAttributes })
.setCookie(ctx.cookies.csrfToken.name, "", { ...ctx.cookies.csrfToken.attributes, ...expiredCookieAttributes })
.setCookie(ctx.cookies.sessionToken.name, "", getExpiredCookie(ctx.cookies.sessionToken.attributes))
.setCookie(ctx.cookies.csrfToken.name, "", getExpiredCookie(ctx.cookies.csrfToken.attributes))
.toHeaders()
const unauthorizedError = {
code: "GET_SESSION_FAILED",
Expand All @@ -32,7 +32,7 @@ export const getSession = async <DefaultUser extends User = User>({
session,
headers: newHeaders,
success: true,
toResponse: () => Response.json({ success: true, session, error: unauthorizedError }, { headers: newHeaders }),
toResponse: () => Response.json({ success: true, session }, { headers: newHeaders }),
} as GetSessionAPIReturn<DefaultUser>
} catch (error) {
ctx?.logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } })
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@ export const setCookie = (cookieName: string, value: string, options?: Serialize
return serialize(cookieName, value, options)
}

export const expiredCookieAttributes: SerializeOptions = {
...defaultCookieOptions,
expires: new Date(0),
maxAge: 0,
secure: true,
export const getExpiredCookie = (options?: SerializeOptions) => {
return {
...options,
expires: new Date(0),
maxAge: 0,
secure: options?.secure ?? true,
}
Comment thread
halvaradop marked this conversation as resolved.
}

/**
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import {
isSealedMode,
isSignedMode,
} from "@/shared/assert.ts"
import { importPEMKeyPair } from "@/shared/crypto.ts"
export { encoder, getRandomBytes, getSubtleCrypto } from "@aura-stack/jose/crypto"
import type { User, SessionConfig, JWTKey, AsymmetricKeyPairFromEnv } from "@/@types/index.ts"
import { importPEMKeyPair } from "./shared/crypto.ts"

const getJWTConfig = (config?: SessionConfig) => {
return config?.jwt
Expand Down Expand Up @@ -285,7 +285,6 @@ export const createJoseInstance = <DefaultUser extends User = User>(secret?: JWT
jwe: createJWE<DefaultUser>(jweSecret),
}
})()
jose.catch(() => {})

return {
signJWS: async (payload: TypedJWTPayload<Partial<DefaultUser>>, options?: JWTHeaderParameters) => {
Expand Down
Loading
Loading