diff --git a/docs/src/content/docs/configuration/options.mdx b/docs/src/content/docs/configuration/options.mdx
index 5a46c42f..1c1507e2 100644
--- a/docs/src/content/docs/configuration/options.mdx
+++ b/docs/src/content/docs/configuration/options.mdx
@@ -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.
+
+
+ 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.
+
+
+##### 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 |
| ---------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------- |
diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts
index d8770c95..0affd1b5 100644
--- a/packages/core/src/@types/index.ts
+++ b/packages/core/src/@types/index.ts
@@ -289,6 +289,7 @@ export type AuthInternalErrorCode =
| "INVALID_ENVIRONMENT_CONFIGURATION"
| "INVALID_URL"
| "INVALID_SALT_SECRET_VALUE"
+ | "UNTRUSTED_ORIGIN"
export type AuthSecurityErrorCode =
| "INVALID_STATE"
diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts
index 44025cb6..b63dfae6 100644
--- a/packages/core/src/actions/callback/callback.ts
+++ b/packages/core/src/actions/callback/callback.ts
@@ -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"
@@ -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)
@@ -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)
diff --git a/packages/core/src/actions/signIn/authorization.ts b/packages/core/src/actions/signIn/authorization.ts
index 8eb2390b..9abca70f 100644
--- a/packages/core/src/actions/signIn/authorization.ts
+++ b/packages/core/src/actions/signIn/authorization.ts
@@ -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 => {
+ 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
}
@@ -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 => {
- 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
@@ -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)
diff --git a/packages/core/src/assert.ts b/packages/core/src/assert.ts
index 0c27be4e..94275820 100644
--- a/packages/core/src/assert.ts
+++ b/packages/core/src/assert.ts
@@ -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
diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts
index c4224733..75991aac 100644
--- a/packages/core/src/logger.ts
+++ b/packages/core/src/logger.ts
@@ -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 = (key: T, overrides?: Partial): SyslogOptions => {
diff --git a/packages/core/test/actions/signIn/authorization.test.ts b/packages/core/test/actions/signIn/authorization.test.ts
index cf7e9a5a..edd84615 100644
--- a/packages/core/test/actions/signIn/authorization.test.ts
+++ b/packages/core/test/actions/signIn/authorization.test.ts
@@ -468,7 +468,6 @@ describe("createRedirectTo", () => {
headers: { Referer: "https://example.dev/auth" },
}),
},
-
{
description: "with redirectTo parameter containing full URL with different origin",
request: new Request(signInURL),
@@ -493,44 +492,54 @@ describe("createRedirectTo", () => {
const request = new Request("https://example.com/auth/signIn/github", {
headers: { Referer: "https://example.com/dashboard" },
})
- const result = await createRedirectTo(request, undefined, {
+ const redirectTo = await createRedirectTo(request, undefined, {
trustedOrigins: ["https://example.com", "https://admin.example.com"],
} as GlobalContext)
- expect(result).toBe("/dashboard")
+ expect(redirectTo).toBe("/dashboard")
})
test("accepts referer matching wildcard pattern", async () => {
const request = new Request("https://app.example.com/auth/signIn/github", {
headers: { Referer: "https://app.example.com/dashboard" },
})
- const result = await createRedirectTo(request, undefined, {
+ const redirectTo = await createRedirectTo(request, undefined, {
trustedOrigins: ["https://*.example.com"],
} as GlobalContext)
- expect(result).toBe("/dashboard")
+ expect(redirectTo).toBe("/dashboard")
})
test("rejects referer not in trusted origins", async () => {
const request = new Request("https://example.com/auth/signIn/github", {
headers: { Referer: "https://malicious.com/phishing" },
})
- const result = await createRedirectTo(request, undefined, {
+ const redirectTo = await createRedirectTo(request, undefined, {
trustedOrigins: ["https://example.com"],
} as GlobalContext)
- expect(result).toBe("/")
+ expect(redirectTo).toBe("/")
})
test("accepts redirectTo from trusted origin", async () => {
const request = new Request("https://example.com/auth/signIn/github")
- const result = await createRedirectTo(request, "https://example.com/dashboard", {} as GlobalContext)
- expect(result).toBe("/dashboard")
+ const redirectTo = await createRedirectTo(request, "https://example.com/dashboard", {} as GlobalContext)
+ expect(redirectTo).toBe("/dashboard")
})
test("with trusted origins that are not same origin", async () => {
const request = new Request("https://example.com/auth/signIn/github")
- const result = await createRedirectTo(request, "https://api.example.com/data", {
+ const redirectTo = await createRedirectTo(request, "https://api.example.com/redirect", {
trustedOrigins: ["https://api.example.com"],
+ trustedProxyHeaders: false,
+ } as GlobalContext)
+ expect(redirectTo).toBe("https://api.example.com/redirect")
+ })
+
+ test("misconfigurated", async () => {
+ const request = new Request("https://example.com/signIn/github")
+ const redirectTo = await createRedirectTo(request, "https://api.example.com/redirect", {
+ trustedOrigins: ["https://example.com"],
+ trustedProxyHeaders: false,
} as GlobalContext)
- expect(result).toBe("https://api.example.com/data")
+ expect(redirectTo).toBe("/")
})
})
})
@@ -541,18 +550,21 @@ describe("getOriginURL", () => {
description: "with standard URL",
request: new Request("https://example.com/auth/signIn/github"),
trustedProxyHeaders: false,
+ trustedOrigins: [],
expected: "https://example.com",
},
{
description: "with localhost URL",
request: new Request("http://localhost:3000/auth/signIn/github"),
trustedProxyHeaders: false,
+ trustedOrigins: [],
expected: "http://localhost:3000",
},
{
description: "with IP address URL",
request: new Request("http://192.168.0.1/auth/signIn/github"),
trustedProxyHeaders: false,
+ trustedOrigins: [],
expected: "http://192.168.0.1",
},
{
@@ -564,6 +576,7 @@ describe("getOriginURL", () => {
},
}),
trustedProxyHeaders: false,
+ trustedOrigins: [],
expected: "http://localhost:3000",
},
{
@@ -575,6 +588,7 @@ describe("getOriginURL", () => {
},
}),
trustedProxyHeaders: true,
+ trustedOrigins: ["https://example.com"],
expected: "https://example.com",
},
{
@@ -586,14 +600,61 @@ describe("getOriginURL", () => {
},
}),
trustedProxyHeaders: true,
+ trustedOrigins: ["http://192.168.0.1"],
expected: "http://192.168.0.1",
},
+ {
+ description: "priority of Forwarded header over X-Forwarded headers",
+ request: new Request("http://localhost:3000/auth/signIn/github", {
+ headers: {
+ Forwarded: "proto=https;host=app.com",
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "malicious.com",
+ },
+ }),
+ trustedProxyHeaders: true,
+ trustedOrigins: ["https://app.com"],
+ expected: "https://app.com",
+ },
]
- for (const { description, request, trustedProxyHeaders, expected } of testCases) {
- test(description, () => {
- const originURL = getOriginURL(request, trustedProxyHeaders)
+ for (const { description, request, trustedProxyHeaders, trustedOrigins, expected } of testCases) {
+ test(description, async () => {
+ const originURL = await getOriginURL(request, { trustedProxyHeaders, trustedOrigins } as GlobalContext)
expect(originURL).toBe(expected)
})
}
+
+ describe("Invalid origins", () => {
+ const testCases = [
+ {
+ description: "origin not set in trustedOrigins",
+ request: new Request("https://example.com/signIn/github", {
+ headers: {
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "localhost:3000",
+ },
+ }),
+ trustedOrigins: ["https://example.com"],
+ trustedProxyHeaders: true,
+ },
+ {
+ description: "trustedOrigins empty",
+ request: new Request("https://example.com/signIn/github", {
+ headers: {
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "localhost:3000",
+ },
+ }),
+ trustedOrigins: [],
+ trustedProxyHeaders: true,
+ },
+ ]
+
+ for (const { description, request, trustedOrigins, trustedProxyHeaders } of testCases) {
+ test(description, async () => {
+ await expect(getOriginURL(request, { trustedOrigins, trustedProxyHeaders } as GlobalContext)).rejects.toThrow()
+ })
+ }
+ })
})
diff --git a/packages/core/test/actions/signIn/signIn.test.ts b/packages/core/test/actions/signIn/signIn.test.ts
index 6ecdfe18..f3dfe3c8 100644
--- a/packages/core/test/actions/signIn/signIn.test.ts
+++ b/packages/core/test/actions/signIn/signIn.test.ts
@@ -1,9 +1,10 @@
import { describe, test, expect } from "vitest"
-import { GET } from "@test/presets.js"
+import { createAuth } from "@/index.js"
import { getSetCookie } from "@/cookie.js"
+import { GET, oauthCustomService } from "@test/presets.js"
describe("signIn action", () => {
- test("unsupported oauth provider", async () => {
+ test("rejects unsupported OAuth provider", async () => {
const request = await GET(new Request("http://example.com/auth/signIn/unsupported"))
expect(request.status).toBe(422)
expect(await request.json()).toEqual({
@@ -18,52 +19,365 @@ describe("signIn action", () => {
})
})
- describe("valid signIn requests", () => {
- const testCases = [
- {
- description: "standard case",
- url: "http://localhost:3000/auth/signIn/oauth-provider",
- expected: "http://localhost:3000/auth/callback/oauth-provider",
- },
- {
- description: "with query parameters",
- url: "https://myapp.com/auth/signIn/oauth-provider?ref=homepage",
- expected: "https://myapp.com/auth/callback/oauth-provider",
- },
- {
- description: "different domain",
- url: "https://anotherdomain.com/auth/signIn/oauth-provider",
- expected: "https://anotherdomain.com/auth/callback/oauth-provider",
- },
- {
- description: "with redirectTo parameter",
- url: "http://localhost:3000/auth/signIn/oauth-provider?redirectTo=/dashboard",
- expected: "http://localhost:3000/auth/callback/oauth-provider",
- },
- ]
-
- for (const { description, url, expected } of testCases) {
- test.concurrent(description, async ({ expect }) => {
- const request = await GET(new Request(url))
- const headers = new Headers(request.headers)
-
- /**
- * @todo: review state parameter handling and cookie management.
- * If the connection is https, the cookie should have the Secure attribute.
- */
- const stateCookie = getSetCookie(request, `aura-auth.state`)
- const redirectToCookie = getSetCookie(request, `aura-auth.redirect_to`)
-
- const location = headers.get("Location") as string
- const searchParams = new URL(location).searchParams
-
- expect(request.status).toBe(302)
- expect(location).toContain("https://example.com/oauth/authorize?")
- expect(searchParams.get("client_id")).toBe("oauth_client_id")
- expect(searchParams.get("redirect_uri")).toMatch(expected)
- expect(stateCookie).toBeDefined()
- expect(redirectToCookie).toEqual(url.includes("redirectTo") ? expect.stringContaining("/dashboard") : "/")
+ test("stores redirectTo cookie over HTTPS", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(new Request("https://example.com/auth/signIn/oauth-provider?redirectTo=/dashboard"))
+ expect(getSetCookie(request, "__Secure-aura-auth.redirect_to")).toEqual("/dashboard")
+ })
+
+ test("stores redirectTo cookie over HTTP", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(new Request("http://example.com/auth/signIn/oauth-provider?redirectTo=/dashboard"))
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/dashboard")
+ })
+
+ test("stores redirectTo when origin is trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedOrigins: ["http://app.com"],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider?redirectTo=http://app.com/dashboard")
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.com/dashboard")
+ })
+
+ test("falls back to root when redirectTo origin is untrusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider?redirectTo=http://app.com/dashboard")
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/")
+ })
+
+ test("uses same-origin Referer as redirectTo", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://example.com/dashboard",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/dashboard")
+ })
+
+ test("falls back to root when Referer is cross-origin", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.com/dashboard",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/")
+ })
+
+ test("accepts cross-origin Referer when origin is trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedOrigins: ["http://app.com"],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.com/dashboard",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.com/dashboard")
+ })
+
+ test("rejects cross-origin Referer when trusted proxy headers are used", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.com/dashboard",
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "app.com",
+ },
+ })
+ )
+ expect(await request.json()).toEqual({
+ type: "AUTH_INTERNAL_ERROR",
+ message: "The constructed origin URL is not trusted.",
+ })
+ })
+
+ test("accepts cross-origin Referer when trusted proxy headers and origins are set", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ trustedOrigins: ["http://app.com"],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.com/dashboard",
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "app.com",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.com/dashboard")
+ })
+
+ test("rejects subdomain Referer when wildcard origin is not trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.example.com/dashboard",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/")
+ })
+
+ test("accepts subdomain Referer when wildcard origin is trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedOrigins: ["http://*.example.com"],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.example.com/dashboard",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.example.com/dashboard")
+ })
+
+ test("rejects proxy-derived origin when trusted origins are not configured", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://evil.com/dashboard",
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "app.example.com",
+ },
+ })
+ )
+ expect(await request.json()).toEqual({
+ type: "AUTH_INTERNAL_ERROR",
+ message: "The constructed origin URL is not trusted.",
+ })
+ })
+
+ test("rejects proxy-derived origin not present in trustedOrigins", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ trustedOrigins: ["http://example.com"],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://evil.com/dashboard",
+ "X-Forwarded-Proto": "http",
+ "X-Forwarded-Host": "app.example.com",
+ },
+ })
+ )
+ expect(await request.json()).toEqual({
+ type: "AUTH_INTERNAL_ERROR",
+ message: "The constructed origin URL is not trusted.",
+ })
+ })
+
+ test("stores redirectTo for subdomain when wildcard origin is trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedOrigins: ["http://*.example.com"],
+ })
+ const request = await GET(
+ new Request("http://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ Referer: "http://app.example.com/dashboard",
+ },
+ })
+ )
+ expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.example.com/dashboard")
+ })
+
+ test("builds redirect URL using request origin without proxy headers", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ })
+ const request = await GET(new Request("https://example.com/auth/signIn/oauth-provider"))
+
+ const headers = new Headers(request.headers)
+ const location = headers.get("Location")!
+ const searchParams = new URL(location).searchParams
+ expect(location).toMatch(
+ new URLSearchParams({
+ response_type: "code",
+ client_id: "oauth_client_id",
+ redirect_uri: "https://example.com/auth/callback/oauth-provider",
+ state: searchParams.get("state")!,
+ }).toString()
+ )
+ })
+
+ test("builds redirect URL from trusted proxy headers with same origin", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ })
+
+ const request = await GET(
+ new Request("https://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ "X-Forwarded-Proto": "https",
+ "X-Forwarded-Host": "example.com",
+ },
+ })
+ )
+ const headers = new Headers(request.headers)
+ const location = headers.get("Location")!
+ const state = getSetCookie(request, "__Secure-aura-auth.state")!
+ expect(location).toMatch(
+ new URLSearchParams({
+ response_type: "code",
+ client_id: "oauth_client_id",
+ redirect_uri: "https://example.com/auth/callback/oauth-provider",
+ state,
+ }).toString()
+ )
+ })
+
+ test("rejects proxy-derived origin when it is not trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ })
+
+ const request = await GET(
+ new Request("https://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ "X-Forwarded-Proto": "https",
+ "X-Forwarded-Host": "app.com",
+ },
+ })
+ )
+ expect(await request.json()).toEqual({
+ type: "AUTH_INTERNAL_ERROR",
+ message: "The constructed origin URL is not trusted.",
+ })
+ })
+
+ test("builds redirect URL even with empty trustedOrigins", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedOrigins: [],
+ })
+
+ const request = await GET(
+ new Request("https://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ "X-Forwarded-Proto": "https",
+ "X-Forwarded-Host": "app.com",
+ },
+ })
+ )
+ const headers = new Headers(request.headers)
+ const location = headers.get("Location")!
+ const state = getSetCookie(request, "__Secure-aura-auth.state")!
+ expect(location).toMatch(
+ new URLSearchParams({
+ response_type: "code",
+ client_id: "oauth_client_id",
+ redirect_uri: "https://example.com/auth/callback/oauth-provider",
+ state,
+ }).toString()
+ )
+ })
+
+ test("builds redirect URL from trusted proxy headers when origin is trusted", async () => {
+ const {
+ handlers: { GET },
+ } = createAuth({
+ oauth: [oauthCustomService],
+ trustedProxyHeaders: true,
+ trustedOrigins: ["https://app.com"],
+ })
+
+ const request = await GET(
+ new Request("https://example.com/auth/signIn/oauth-provider", {
+ headers: {
+ "X-Forwarded-Proto": "https",
+ "X-Forwarded-Host": "app.com",
+ },
})
- }
+ )
+ const headers = new Headers(request.headers)
+ const location = headers.get("Location")!
+ const state = getSetCookie(request, "__Secure-aura-auth.state")!
+ expect(location).toMatch(
+ new URLSearchParams({
+ response_type: "code",
+ client_id: "oauth_client_id",
+ redirect_uri: "https://app.com/auth/callback/oauth-provider",
+ state,
+ }).toString()
+ )
})
})
diff --git a/packages/core/test/assert.test.ts b/packages/core/test/assert.test.ts
index 9ab710b9..938e4a48 100644
--- a/packages/core/test/assert.test.ts
+++ b/packages/core/test/assert.test.ts
@@ -422,6 +422,24 @@ describe("isTrustedOrigin", () => {
trustedOrigins: ["https://exa*mple.com"],
expected: false,
},
+ {
+ description: "invalid subdomain depth with wildcard",
+ url: "https://sub.sub.example.com",
+ trustedOrigins: ["https://*.example.com"],
+ expected: false,
+ },
+ {
+ description: "invalid pattern with wildcard in the middle of the domain",
+ url: "https://example.com",
+ trustedOrigins: ["https://example.*.com"],
+ expected: false,
+ },
+ {
+ description: "invalid pattern with wildcard in the middle of the domain",
+ url: "https://api.example.com",
+ trustedOrigins: ["https://api.*.com"],
+ expected: false,
+ },
]
for (const { description, url, trustedOrigins, expected } of testCases) {