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
38 changes: 37 additions & 1 deletion docs/src/content/docs/configuration/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,43 @@ export const auth = createAuth({

#### Wildcard Patterns

The `*` wildcard is supported for flexible matching of subdomains and ports. The wildcard can only be used at the start of a subdomain (e.g., `*.example.com`) or as an entire port (e.g., `:3000`).
The `*` wildcard is supported for flexible matching of subdomains and ports. The wildcard can only be used at the start of a subdomain or as an entire port.

<Callout type="warning">
Use wildcards with caution. Overly permissive patterns can introduce security risks. Always prefer specific origins when
possible. It's recommended to be as restrictive as possible with allowed origins to minimize attacks and risks. Use
`trustedOrigins` carefully and only allow known origins.
</Callout>

##### Subdomain Wildcards [!toc]

Trust one level of subdomains under a specific domain.

```ts title="@/auth" lineNumbers
import { createAuth } from "@aura-stack/auth"

export const auth = createAuth({
oauth: [],
trustedOrigins: ["https://*.example.com"],
})
```

It matches any origin that starts with `https://` and ends with `.example.com`, allowing for any single subdomain level (e.g., `https://app.example.com`, `https://www.example.com`), but not the root domain itself (`https://example.com`) or multiple subdomain levels (`https://sub.app.example.com`).

##### Port Wildcards [!toc]

Trust any port on a specific domain.

```ts title="@/auth" lineNumbers
import { createAuth } from "@aura-stack/auth"

export const auth = createAuth({
oauth: [],
trustedOrigins: ["https://example.com:*"],
})
```

It matches any port on `https://example.com` (e.g., `https://example.com:3000`, `https://example.com:3001`, `https://example.com:8080`), but not the domain without a port (`https://example.com`) or with a different protocol (`http://example.com:3000`).

| Pattern | Matches | Does Not Match |
| ---------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------- |
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export type AuthInternalErrorCode =
| "INVALID_ENVIRONMENT_CONFIGURATION"
| "INVALID_URL"
| "INVALID_SALT_SECRET_VALUE"
| "UNTRUSTED_ORIGIN"

export type AuthSecurityErrorCode =
| "INVALID_STATE"
Expand Down
39 changes: 25 additions & 14 deletions packages/core/src/actions/callback/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { z } from "zod"
import { createEndpoint, createEndpointConfig, HeadersBuilder } from "@aura-stack/router"
import { createCSRF } from "@/secure.js"
import { cacheControl } from "@/headers.js"
import { isRelativeURL, isTrustedOrigin, safeEquals } from "@/assert.js"
import { isRelativeURL, isSameOrigin, isTrustedOrigin, safeEquals } from "@/assert.js"
import { getUserInfo } from "@/actions/callback/userinfo.js"
import { OAuthAuthorizationErrorResponse } from "@/schemas.js"
import { AuthSecurityError, OAuthProtocolError } from "@/errors.js"
import { getTrustedOrigins } from "@/actions/signIn/authorization.js"
import { getOriginURL, getTrustedOrigins } from "@/actions/signIn/authorization.js"
import { createAccessToken } from "@/actions/callback/access-token.js"
import { createSessionCookie, getCookie, expiredCookieAttributes } from "@/cookie.js"
import type { JWTPayload } from "@/jose.js"
Expand Down Expand Up @@ -61,8 +61,9 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {
request,
params: { oauth },
searchParams: { code, state },
context: { oauth: providers, cookies, jose, logger, trustedOrigins },
context,
} = ctx
const { oauth: providers, cookies, jose, logger, trustedOrigins } = context

const oauthConfig = providers[oauth]
const cookieState = getCookie(request, cookies.state.name)
Expand All @@ -84,17 +85,27 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {

const accessToken = await createAccessToken(oauthConfig, cookieRedirectURI, code, codeVerifier, logger)
const origins = await getTrustedOrigins(request, trustedOrigins)
if (!isRelativeURL(cookieRedirectTo) && !isTrustedOrigin(cookieRedirectTo, origins)) {
logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", {
structuredData: {
redirect_path: cookieRedirectTo,
provider: oauth,
},
})
throw new AuthSecurityError(
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
"Invalid redirect path. Potential open redirect attack detected."
)
const requestOrigin = await getOriginURL(request, context)

if (!isRelativeURL(cookieRedirectTo)) {
const isValid =
origins.length > 0
? isTrustedOrigin(cookieRedirectTo, origins)
: isSameOrigin(cookieRedirectTo, requestOrigin)
if (!isValid) {
logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", {
structuredData: {
redirect_path: cookieRedirectTo,
provider: oauth,
has_trusted_origins: origins.length > 0,
request_origin: requestOrigin,
},
})
throw new AuthSecurityError(
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
"Invalid redirect path. Potential open redirect attack detected."
)
}
}

const userInfo = await getUserInfo(oauthConfig, accessToken.access_token, logger)
Expand Down
58 changes: 32 additions & 26 deletions packages/core/src/actions/signIn/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,32 @@ export const createAuthorizationURL = (
return `${authorizeURL}?${searchParams}`
}

export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean, logger?: InternalLogger) => {
let origin = new URL(request.url).origin
/**
* Resolves trusted origins from config (array or function).
*/
export const getTrustedOrigins = async (request: Request, trustedOrigins: AuthConfig["trustedOrigins"]): Promise<string[]> => {
if (!trustedOrigins) return []
const raw = typeof trustedOrigins === "function" ? await trustedOrigins(request) : trustedOrigins
return Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []
}

export const getOriginURL = async (request: Request, context?: GlobalContext) => {
const headers = request.headers
if (trustedProxyHeaders) {
const protocol = headers.get("X-Forwarded-Proto") ?? headers.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] ?? "http"
let origin = new URL(request.url).origin
const trustedOrigins = await getTrustedOrigins(request, context?.trustedOrigins)
trustedOrigins.push(origin)
if (context?.trustedProxyHeaders) {
const protocol = headers.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] ?? headers.get("X-Forwarded-Proto") ?? "http"
const host =
headers.get("X-Forwarded-Host") ??
headers.get("Host") ??
headers.get("Forwarded")?.match(/host=([^;]+)/i)?.[1] ??
headers.get("X-Forwarded-Host") ??
null
origin = `${protocol}://${host}`
}
if (!isValidURL(origin)) {
logger?.log("INVALID_URL", { structuredData: { origin: origin } })
throw new AuthInternalError("INVALID_URL", "The constructed origin URL is invalid.")
if (!isTrustedOrigin(origin, trustedOrigins)) {
context?.logger?.log("UNTRUSTED_ORIGIN", { structuredData: { origin: origin } })
throw new AuthInternalError("UNTRUSTED_ORIGIN", "The constructed origin URL is not trusted.")
}
return origin
}
Expand All @@ -72,19 +83,10 @@ export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean, lo
* @returns The redirect URI for the OAuth callback.
*/
export const createRedirectURI = async (request: Request, oauth: string, context: GlobalContext) => {
const origin = getOriginURL(request, context.trustedProxyHeaders, context.logger)
const origin = await getOriginURL(request, context)
return `${origin}${context.basePath}/callback/${oauth}`
}

/**
* Resolves trusted origins from config (array or function).
*/
export const getTrustedOrigins = async (request: Request, trustedOrigins: AuthConfig["trustedOrigins"]): Promise<string[]> => {
if (!trustedOrigins) return []
const raw = typeof trustedOrigins === "function" ? await trustedOrigins(request) : trustedOrigins
return Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []
}

/**
* Verifies if the request's origin matches the expected origin. It accepts the redirectTo search
* parameter for redirection. It checks the Referer and Origin headers and the request URL against
Expand All @@ -101,22 +103,26 @@ export const getTrustedOrigins = async (request: Request, trustedOrigins: AuthCo
export const createRedirectTo = async (request: Request, redirectTo?: string, context?: GlobalContext) => {
try {
const headers = request.headers
const requestOrigin = await getOriginURL(request, context)
const origins = await getTrustedOrigins(request, context?.trustedOrigins)
const requestOrigin = getOriginURL(request, context?.trustedProxyHeaders, context?.logger)

const validateURL = (url: string): string => {
if (!isRelativeURL(url) && !isValidURL(url)) return "/"
if (isRelativeURL(url)) return url

if (origins.length > 0 && isTrustedOrigin(url, origins)) {
const urlOrigin = new URL(url).origin
for (const pattern of origins) {
const regex = patternToRegex(pattern)
if (regex?.test(urlOrigin)) {
return isSameOrigin(url, requestOrigin) ? extractPath(url) : url
if (origins.length > 0) {
if (isTrustedOrigin(url, origins)) {
const urlOrigin = new URL(url).origin
for (const pattern of origins) {
const regex = patternToRegex(pattern)
if (regex?.test(urlOrigin)) {
return isSameOrigin(url, request.url) ? extractPath(url) : url
}
if (isValidURL(pattern) && equals(new URL(pattern).origin, urlOrigin)) return url
}
if (isValidURL(pattern) && equals(new URL(pattern).origin, urlOrigin)) return url
}
context?.logger?.log("OPEN_REDIRECT_ATTACK")
return "/"
}
if (isSameOrigin(url, requestOrigin)) {
return extractPath(url)
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ export const isSameOrigin = (origin: string, expected: string): boolean => {
*/
export const patternToRegex = (pattern: string): RegExp | null => {
try {
if (pattern.length > 2048) return null

pattern = pattern.replace(/\\/g, "")
const match = pattern.match(/^(https?):\/\/([^\/\s:]+)(?::(\d+|\*))?(\/.*)?$/)
const match = pattern.match(/^(https?):\/\/([a-zA-Z0-9.*-]{1,253})(?::(\d{1,5}|\*))?(?:\/.*)?$/)
if (!match) return null

const [, protocol, host, port] = match
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export const logMessages = {
msgId: "COOKIE_HOST_STRATEGY_INSECURE",
message: "__Host- cookies require a secure HTTPS context. Falling back to standard cookie settings.",
},
UNTRUSTED_ORIGIN: {
facility: 10,
severity: "error",
msgId: "UNTRUSTED_ORIGIN",
message: "The constructed origin URL is not trusted.",
},
} as const

export const createLogEntry = <T extends keyof typeof logMessages>(key: T, overrides?: Partial<SyslogOptions>): SyslogOptions => {
Expand Down
Loading