Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Security

- Hardened credential-based sign-in through the `POST /signIn/credentials` endpoint and `api.signInCredentials()` API. The operation now requires an additional `__Host-client_id_token` cookie to help ensure requests originate from trusted clients. [#269](https://github.com/aura-stack-ts/auth/pull/269)

- Hardened OAuth token handling in the `/providers/:provider/tokens` endpoint and `api.getProviderTokens()` API. The token storage cookie now uses the `__Host-` prefix, and responses include the `Cross-Origin-Resource-Policy: same-origin` and `Cross-Origin-Opener-Policy: same-origin` headers to strengthen cross-origin isolation and reduce the risk of cross-origin attacks. [#268](https://github.com/aura-stack-ts/auth/pull/268)

---
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export type CookieName =
| "redirectURI"
| "nonce"
| "accessToken"
| "clientIdToken"

/** Resolved cookie names and serialization attributes for each logical auth cookie. */
export type CookieStoreConfig = Record<CookieName, { name?: string; attributes?: CookieStrategyAttributes }>
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/api/signInCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ export const signInCredentials = async ({
toStandardizedHeaders(headerInit ?? requestInit?.headers ?? {})
)
.buildRequest(requestInit, "/signIn/credentials")
.verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken)
.verifyClientIdToken()
.verifyRateLimit("signInCredentials")
.verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken)
.execute()

if (rateLimit) {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,5 +289,18 @@ export const createCookieStore = (
logger
),
},
clientIdToken: {
name: `${hostPrefix}${prefix}.${overrides?.clientIdToken?.name ?? "client_id_token"}`,
attributes: defineSecureCookieOptions(
useSecure,
{
...overrides?.clientIdToken?.attributes,
...defaultHostCookieConfig,
sameSite: "strict",
},
overrides?.clientIdToken?.attributes?.strategy ?? "host",
logger
),
},
}
}
2 changes: 1 addition & 1 deletion packages/core/src/router/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const createContext = <Identity extends Identities, SignUpSchema extends
},
signUp: config?.signUp,
jwtManager: createJoseManager(isStatelessStrategy(config?.session) ? config?.session?.jwt : undefined, jose),
rateLimiters: createRateLimiterInstance(config?.rateLimiter),
rateLimiters: createRateLimiterInstance(config?.rateLimiter, useProxyHeaders),
sessionConfig: config?.session,
} as InternalContext<Identity, SignUpSchema>
ctx.sessionStrategy = createSessionStrategy<Identity>({
Expand Down
20 changes: 13 additions & 7 deletions packages/core/src/router/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ import { createRateLimiter, type RateLimiterRule } from "@aura-stack/rate-limite
import type { RateLimiterConfig } from "@/@types/config.ts"
import type { RouterGlobalContext } from "@/@types/internal.ts"

export const createRateLimiterInstance = (config?: RateLimiterConfig) => {
export const createRateLimiterInstance = (config?: RateLimiterConfig, useProxyHeaders: boolean = false) => {
const getLimitKey = (request: Request, action: string): string => {
const ip =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"anon"
const ip = useProxyHeaders
? (request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
request.headers.get("true-client-ip") ??
request.headers.get("x-client-ip") ??
request.headers.get("x-cluster-client-ip") ??
request.headers.get("x-forwarded") ??
request.headers.get("forwarded-for") ??
"anon")
: (request.headers.get("remote-addr") ?? "anon")
Comment thread
halvaradop marked this conversation as resolved.
return `rl:${action}:${ip}`
}

return createRateLimiter<RateLimiterConfig>({
return createRateLimiter({
rules: {
signIn: {
algorithm: "sliding-window",
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/shared/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.ts"
import { exportJWK, generateKeyPair, importPKCS8, importSPKI, type GenerateKeyPairOptions } from "@aura-stack/jose/jose"
import type { JoseInstance, User } from "@/@types/index.ts"
import type { AsymmetricKeyPairFromEnv, RouterGlobalContext } from "@/@types/internal.ts"
import { getCookie } from "@/cookie.ts"

export { generateKeyPair as createKeyPair } from "@aura-stack/jose/jose"

Expand Down Expand Up @@ -68,6 +69,13 @@ export const createCSRF = async (jose: RouterGlobalContext["jose"], csrfCookie?:
}
}

/**
* Creates a CSRF token to be used in OAuth flows to prevent cross-site request forgery attacks.
*
* @param csrfCookie - Optional existing CSRF cookie to verify and reuse
* @returns Signed CSRF token
*/

export const verifyCSRF = async <DefaultUser extends User = User>(
jose: JoseInstance<DefaultUser>,
cookie: string,
Expand Down Expand Up @@ -187,3 +195,31 @@ export const exportJWKKeyPair = async (alg: string, options?: GenerateKeyPairOpt
privateKey: jwkPrivateKey,
}
}

export const createClientIdToken = async (jose: RouterGlobalContext["jose"], clientIdToken?: string) => {
try {
if (clientIdToken) {
await jose.verifyJWS(clientIdToken)
return clientIdToken
}
const token = createSecretValue(32)
return jose.signJWS({ token })
} catch {
const token = createSecretValue(32)
return jose.signJWS({ token })
}
}

export const verifyClientIdToken = async (request: Request, ctx: RouterGlobalContext): Promise<string> => {
try {
ctx.logger?.log("CLIENT_ID_TOKEN_REQUESTED")
const clientIdToken = getCookie(request, ctx.cookies.clientIdToken.name)
ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED")
const jws = await ctx.jose.verifyJWS(clientIdToken)
ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED_SUCCESS")
return jws!.token as string
} catch {
ctx.logger?.log("INVALID_CLIENT_ID_TOKEN")
throw new AuraAuthError({ code: "INVALID_CLIENT_ID_TOKEN" })
}
}
18 changes: 18 additions & 0 deletions packages/core/src/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export const AuraErrorCode = {
*/
INVALID_SLIDING_THRESHOLD_CONFIG_VALUE: "INVALID_SLIDING_THRESHOLD_CONFIG_VALUE",
INVALID_CSRF_TOKEN: "INVALID_CSRF_TOKEN",
INVALID_CLIENT_ID_TOKEN: "INVALID_CLIENT_ID_TOKEN",
INVALID_BUILD_REQUEST: "INVALID_BUILD_REQUEST",
} as const

export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode]
Expand Down Expand Up @@ -958,6 +960,22 @@ export const ERROR_CATALOG: Record<AuraErrorCode, CatalogEntry> = {
"CSRF security verification failed. The provided anti-CSRF token does not match the token embedded in the secure session cookie context or failed cryptographic validation.",
userMessage: "Security verification failed. Invalid or missing CSRF token.",
},
INVALID_CLIENT_ID_TOKEN: {
type: "VALIDATION",
statusCode: 400,
name: "AuthValidationError",
message:
"The requested authentication operation could not be performed. The client identification parameter is missing from the request context or the provided client ID token failed security validation checks.",
userMessage: "Invalid request. The client identifier or token provided is invalid.",
},
INVALID_BUILD_REQUEST: {
type: "VALIDATION",
statusCode: 400,
name: "RequestBuildError",
message:
"The internal request object could not be constructed during the pipeline validation sequence. Initialized request values, rate-limiter contexts, or client parameters are malformed or invalid.",
userMessage: "Invalid request state. The request could not be processed during initial validation checks.",
},
}

export interface AuraErrorOptions extends ErrorOptions {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/shared/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,30 @@ export const LOG_MESSAGES = {
msgId: "UPDATE_SESSION_INVALID",
message: "Session update failed due to invalid session state",
},
CLIENT_ID_TOKEN_REQUESTED: {
facility: 4,
severity: "debug",
msgId: "CLIENT_ID_TOKEN_REQUESTED",
message: "Client requested a new client ID token",
},
CLIENT_ID_TOKEN_VERIFIED: {
facility: 4,
severity: "info",
msgId: "CLIENT_ID_TOKEN_VERIFIED",
message: "Client ID token verification succeeded",
},
CLIENT_ID_TOKEN_VERIFIED_SUCCESS: {
facility: 4,
severity: "info",
msgId: "CLIENT_ID_TOKEN_VERIFIED_SUCCESS",
message: "Client ID token verified successfully",
},
INVALID_CLIENT_ID_TOKEN: {
facility: 4,
severity: "error",
msgId: "INVALID_CLIENT_ID_TOKEN",
message: "Client ID token validation failed or token is invalid",
},
} as const

export const createLogEntry = <T extends keyof typeof LOG_MESSAGES>(
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getEnv } from "@/shared/env.ts"
import { getCookie } from "@/cookie.ts"
import { createHash, verifyCSRF } from "@/shared/crypto.ts"
import { encoder } from "@aura-stack/jose/crypto"
import { AuraAuthError } from "@/shared/errors.ts"
import { isRelativeURL, isString, isValidURL } from "@/shared/assert.ts"
import { createHash, verifyCSRF } from "@/shared/crypto.ts"
import type { DeviceType } from "@/@types/entities.ts"
import type { JoseInstance, OAuthTokenPayload } from "@/@types/index.ts"
import type {
Expand Down Expand Up @@ -176,7 +176,6 @@ export const verifyCSRFToken = async ({
try {
csrfToken = csrfToken || getCookie(headers, cookies.csrfToken.name)
} catch (cause) {
console.log("CSRF_TOKEN_MISSING in cookie retrieval", cause)
logger?.log("CSRF_TOKEN_MISSING")
throw new AuraAuthError({ code: "CSRF_TOKEN_MISSING", cause })
}
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/shared/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HeadersBuilder, type RequestHeaders } from "@aura-stack/router"
import { getOptionalCookie } from "@/cookie.ts"
import { assertCSRFTokenCookie, createCSRF, createHash } from "@/shared/crypto.ts"
import { assertCSRFTokenCookie, createCSRF, createHash, verifyClientIdToken as assertClientIdToken } from "@/shared/crypto.ts"
import { verifyRateLimit } from "@/router/rate-limiter.ts"
import { createCookieManager } from "@/session/cookie-manager.ts"
import { AuraAuthError, isAuraAuthError } from "@/shared/errors.ts"
Expand Down Expand Up @@ -29,6 +29,7 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers
const output: {
provider?: RuntimeOAuthProvider
request?: Request
clientId?: string
rateLimit?: any
headers: Headers
} = { headers }
Expand Down Expand Up @@ -102,7 +103,7 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers
verifyRateLimit: (action: keyof RateLimiterConfig) => {
steps.push(async () => {
if (!output.request) {
throw new Error("buildRequest must be called before verifyRateLimit")
throw new AuraAuthError({ code: "INVALID_BUILD_REQUEST" })
}
const rateLimit = await verifyRateLimit(ctx, output.request, action)
if (rateLimit) {
Expand All @@ -111,6 +112,15 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers
})
return builder
},
verifyClientIdToken: () => {
steps.push(async () => {
if (!output.request) {
throw new AuraAuthError({ code: "INVALID_BUILD_REQUEST" })
}
output.clientId = await assertClientIdToken(output.request, ctx)
})
return builder
},
execute: async () => {
for (const step of steps) {
await step()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { describe, test, expect, vi } from "vitest"
import { authInstance, deviceEntity, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts"
import { createCSRF } from "@/shared/crypto.ts"
import { createCSRF, createClientIdToken } from "@/shared/crypto.ts"
import { createSchemaRegistry } from "@/validator/registry.ts"

describe("signInCredentials action", async () => {
const csrfToken = await createCSRF(jose)
const clientId = await createClientIdToken(jose)

const headers = {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
Cookie: `aura-auth.csrf_token=${csrfToken}`,
Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`,
}

test("success signIn flow", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { describe, test, expect } from "vitest"
import { jose, POST } from "@test/presets.ts"
import { getSetCookie } from "@/cookie.ts"
import { createAuth } from "@/createAuth.ts"
import { createCSRF } from "@/shared/crypto.ts"
import { createCSRF, createClientIdToken } from "@/shared/crypto.ts"

describe("signInCredentials action", async () => {
const csrfToken = await createCSRF(jose)
const clientIdToken = await createClientIdToken(jose)

const headers = {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
Cookie: `aura-auth.csrf_token=${csrfToken}`,
Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`,
}

test("success signIn flow", async () => {
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/api/stateful/getSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ describe("getSession", () => {
expect(sessionByTokenMock).toHaveBeenCalledWith(tokenHash)
expect(revokeSessionMock).toHaveBeenCalledWith("session-123", "user_logout")
expect(spy).not.toHaveBeenCalled()
console.log("value: ")
expect(() => getSetCookie(output.headers, "aura-auth.csrf_token")).toThrow()
expect(getSetCookie(output.headers, "aura-auth.session_token")).toBe("")
})
Expand Down
5 changes: 3 additions & 2 deletions packages/core/test/api/stateful/signInCredentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { describe, test, expect, vi } from "vitest"
import { createCSRF } from "@/shared/crypto.ts"
import { createCSRF, createClientIdToken } from "@/shared/crypto.ts"
import { authInstance, deviceEntity, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts"
import { createSchemaRegistry } from "@/validator/registry.ts"

describe("signInCredentials API", async () => {
const csrfToken = await createCSRF(jose)
const clientId = await createClientIdToken(jose)

const headers = {
Cookie: `aura-auth.csrf_token=${csrfToken}`,
Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`,
}

test("success signIn flow", async () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/test/api/stateless/signInCredentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { describe, test, expect, vi } from "vitest"
import { createAuth } from "@/createAuth.ts"
import { getSetCookie } from "@/cookie.ts"
import { api, jose } from "@test/presets.ts"
import { createCSRF } from "@/shared/crypto.ts"
import { createCSRF, createClientIdToken } from "@/shared/crypto.ts"

describe("signInCredentials API", async () => {
const csrfToken = await createCSRF(jose)
const clientId = await createClientIdToken(jose)

const headers = {
Cookie: `aura-auth.csrf_token=${csrfToken}`,
Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`,
}

test("success signIn flow", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const oauthTransactionEntity: OAuthTransactionEntity = {

const auth = createAuth({
oauth: [oauthCustomService, oauthCustomServiceProfile, openIDCustomProvider],
logger: getEnv("CI") === "true" ? false : true,
//logger: getEnv("CI") === "true" ? false : true,
credentials: {
authorize: async ({ credentials }) => {
const { username } = credentials
Expand Down
16 changes: 8 additions & 8 deletions packages/core/test/rate-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { describe, expect, test } from "vitest"
import { jose, PATCH, POST, sessionPayload } from "./presets.ts"
import { createCSRF } from "@/shared/crypto.ts"
import { createCSRF, createClientIdToken } from "@/shared/crypto.ts"
import { equals } from "@/shared/utils.ts"

describe("Rate Limiter", async () => {
const csrfToken = await createCSRF(jose)
const clientIdToken = await createClientIdToken(jose)

const createRequest = async (makeRequest: () => Request, totalRequests: number, allowedLimit: number) => {
const expectedRejections = totalRequests - allowedLimit
const requests = Array.from({ length: totalRequests }).map(() => makeRequest())
const requests = Array.from({ length: totalRequests }, makeRequest)
const responses = await Promise.all(requests.map((req) => (req.method === "PATCH" ? PATCH(req) : POST(req))))

const successfulResponses = responses.filter((res) => res.status === 200)
const rejectedResponses = responses.filter((res) => res.status === 429)

expect(successfulResponses.length).toBe(allowedLimit)
const successfulResponses = responses.filter((res) => equals(res.status, 200))
const rejectedResponses = responses.filter((res) => equals(res.status, 429))
expect(rejectedResponses.length).toBe(expectedRejections)

expect(successfulResponses.length).toBe(allowedLimit)
if (rejectedResponses.length > 0) {
const targetReject = rejectedResponses[0]
expect(targetReject.headers.get("Retry-After")).toBeDefined()
Expand All @@ -33,7 +33,7 @@ describe("Rate Limiter", async () => {
headers: {
"x-forwarded-for": "192.168.1.50",
"X-CSRF-Token": csrfToken,
Cookie: `aura-auth.csrf_token=${csrfToken}`,
Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`,
},
})
await createRequest(makeRequest, 10, 8)
Expand Down
Loading
Loading