Skip to content
Open
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
44 changes: 44 additions & 0 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,45 @@ import type { EditableShape, Prettify, ShapeToObject } from "@/@types/utility.ts
import type { OAuthProviderCredentials, OAuthProviderRecord } from "@/@types/oauth.ts"
import type { JWTKey, SessionConfig, SessionStrategy, User, UserShape } from "@/@types/session.ts"

export interface CredentialsPayload {
username: string
password: string
}

/**
* Context provided to the credentials provider's authorize function.
* It includes the credentials sent by the user and hashing utilities.
*/
export interface CredentialsProviderContext<T> {
/**
* User-provided credentials (e.g., email, password).
*/
credentials: T
/**
* Hashes a password using the internal hashing algorithm (PBKDF2).
*/
deriveSecret: (password: string, salt?: string, iterations?: number) => Promise<string>
/**
* Verifies a password against a hashed value.
*/
verifySecret: (password: string, hashedPassword: string) => Promise<boolean>
}

/**
* Interface for the credentials provider.
*/
export interface CredentialsProvider<Identity extends EditableShape<UserShape> = EditableShape<UserShape>> {
hash?: (password: string, salt?: string, iterations?: number) => Promise<string>
verify?: (password: string, hashedPassword: string) => Promise<boolean>
/**
* Authenticates a user using credentials.
* Must return a User object or the identity type if the identity schema is provided.
*/
authorize: (
ctx: CredentialsProviderContext<CredentialsPayload>
) => Promise<ShapeToObject<Identity> | null> | ShapeToObject<Identity> | null
}

/**
* Main configuration interface for Aura Auth.
* This is the user-facing configuration object passed to `createAuth()`.
Expand Down Expand Up @@ -149,6 +188,10 @@ export interface AuthConfig<Identity extends EditableShape<UserShape> = Editable
schema: ZodObject<Identity>
unknownKeys: "passthrough" | "strict" | "strip"
}>
/**
* Credentials provider for username/password or similar authentication.
*/
credentials?: CredentialsProvider<Identity>
}

/**
Expand Down Expand Up @@ -254,6 +297,7 @@ export interface IdentityConfig<Schema extends ZodObject<any> = typeof UserIdent

export interface RouterGlobalContext<DefaultUser extends User = User> {
oauth: OAuthProviderRecord
credentials?: CredentialsProvider<any>
cookies: CookieStoreConfig
jose: JoseInstance<DefaultUser>
secret?: JWTKey
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/@types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type AuthInternalErrorCode =
| "UNTRUSTED_ORIGIN"
| "INVALID_OAUTH_PROVIDER_CONFIGURATION"
| "DUPLICATED_OAUTH_PROVIDER_ID"
| "CREDENTIALS_PROVIDER_NOT_CONFIGURED"
| "IDENTITY_VALIDATION_FAILED"

export type AuthSecurityErrorCode =
| "INVALID_STATE"
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { EditableShape, ShapeToObject } from "./utility.ts"
import type { TypedJWTPayload } from "@aura-stack/jose"
import type { UserIdentityType, UserShape } from "@/shared/identity.ts"
import type { CookieStoreConfig, IdentityConfig, InternalLogger, JoseInstance, RouterGlobalContext } from "@/@types/config.ts"
import type {
CookieStoreConfig,
CredentialsPayload,
IdentityConfig,
InternalLogger,
JoseInstance,
RouterGlobalContext,
} from "@/@types/config.ts"

export type User = UserIdentityType
export type { UserShape } from "@/shared/identity.ts"
Expand Down Expand Up @@ -279,3 +286,13 @@ export interface UpdateSessionAPIOptions<DefaultUser extends User = User> {
export type UpdateSessionReturn<DefaultUser extends User = User> =
| { session: Session<DefaultUser>; headers: Headers; updated: true }
| { session: null; headers: Headers; updated: false }

export type SignInCredentialsOptions = FunctionAPIContext<{
payload: CredentialsPayload
redirectTo?: string
}>

export interface SignInCredentialsAPIOptions {
payload: CredentialsPayload
redirectTo?: string
}
1 change: 1 addition & 0 deletions packages/core/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { signInAction } from "@/actions/signIn/signIn.ts"
export { signInCredentialsAction } from "@/actions/signIn/credentials.ts"
export { callbackAction } from "@/actions/callback/callback.ts"
export { sessionAction } from "@/actions/session/session.ts"
export { signOutAction } from "@/actions/signOut/signOut.ts"
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/actions/signIn/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from "zod/v4"
import { createEndpoint, createEndpointConfig } from "@aura-stack/router"
import { signInCredentials } from "@/api/credentials.ts"

const config = createEndpointConfig({
schemas: {
body: z.object({
username: z.string(),
password: z.string(),
}),
searchParams: z.object({
redirectTo: z.string().optional(),
}),
},
})

/**
* Handles the credentials-based sign-in flow.
* It extracts credentials from the request body, calls the provider's `authorize` function,
* validates the returned user object, and creates a session.
*
* @returns The signed-in user and session cookies.
*/
export const signInCredentialsAction = createEndpoint(
"POST",
"/signIn/credentials",
async (ctx) => {
const payload = ctx.body
const { headers, success } = await signInCredentials({
ctx: ctx.context,
payload,
})
return Response.json({ success }, { headers, status: success ? 200 : 401 })
},
config
)
13 changes: 9 additions & 4 deletions packages/core/src/api/createApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { signIn } from "@/api/signIn.ts"
import { signOut } from "@/api/signOut.ts"
import { getSession } from "@/api/getSession.ts"
import { updateSession } from "./updateSession.ts"
import { validateRedirectTo } from "@/shared/utils.ts"
import { getSession, signIn, signInCredentials, signOut, updateSession } from "@/api/index.ts"
import type { GlobalContext } from "@aura-stack/router"
import type {
BuiltInOAuthProvider,
Expand All @@ -14,6 +11,7 @@ import type {
SignOutAPIOptions,
UpdateSessionAPIOptions,
User,
SignInCredentialsAPIOptions,
} from "@/@types/index.ts"

export const createAuthAPI = <DefaultUser extends User = User>(ctx: GlobalContext) => {
Expand All @@ -34,6 +32,13 @@ export const createAuthAPI = <DefaultUser extends User = User>(ctx: GlobalContex
redirectTo: options?.redirectTo,
})
},
signInCredentials: async (options: SignInCredentialsAPIOptions) => {
return signInCredentials({
ctx,
payload: options.payload,
redirectTo: options.redirectTo,
})
},
signOut: async (options: SignOutAPIOptions) => {
const redirectTo = validateRedirectTo(options?.redirectTo ?? "/")
return signOut({ ctx, headers: options.headers, redirectTo, skipCSRFCheck: true })
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/api/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { AuthValidationError } from "@/shared/errors.ts"
import { secureApiHeaders } from "@/shared/headers.ts"
import { createCSRF, hashPassword, verifyPassword } from "@/shared/security.ts"
import { HeadersBuilder } from "@aura-stack/router"
import type { SignInCredentialsOptions } from "@/@types/session.ts"

export const signInCredentials = async ({ ctx, payload }: SignInCredentialsOptions) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

redirectTo is silently ignored in credentials sign-in.

At Line 7, redirectTo from SignInCredentialsOptions is not consumed, so callers can pass it (via createApi) but it has no effect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/credentials.ts` at line 7, signInCredentials currently
ignores the redirectTo field from SignInCredentialsOptions; update the function
to read redirectTo from the incoming options/payload and apply it after
successful sign-in (e.g., call ctx.redirect(redirectTo) or include redirectTo in
the returned success response) so callers using createApi can cause an actual
redirect. Locate the signInCredentials function and the SignInCredentialsOptions
usage, extract redirectTo from the payload/options, and ensure it is acted upon
on the successful sign-in path rather than being ignored.

const { cookies, credentials, sessionStrategy, logger } = ctx
try {
const session = await credentials?.authorize({
credentials: payload,
deriveSecret: credentials?.hash ?? hashPassword,
verifySecret: credentials?.verify ?? verifyPassword,
})
if (!session) {
throw new AuthValidationError("INVALID_CREDENTIALS", "The provided credentials are invalid.")
}
const sessionToken = await sessionStrategy.createSession(session)
const csrfToken = await createCSRF(ctx.jose)

logger?.log("CREDENTIALS_SIGN_IN_SUCCESS")

const headers = new HeadersBuilder(secureApiHeaders)
.setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes)
.setCookie(cookies.sessionToken.name, sessionToken, cookies.sessionToken.attributes)
.toHeaders()
return {
success: true,
headers,
}
} catch {
logger?.log("INVALID_CREDENTIALS", {
severity: "warning",
structuredData: {
path: "/signIn/credentials",
},
})
return {
success: false,
headers: new Headers(secureApiHeaders),
}
Comment on lines +31 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not collapse all runtime failures into INVALID_CREDENTIALS.

At Line 31, broad catch masks internal errors (e.g., session/cookie/crypto failures) as auth failures, which hurts reliability and observability.

Suggested error-splitting approach
-    } catch {
+    } catch (error) {
+        if (error instanceof AuthValidationError) {
+            logger?.log("INVALID_CREDENTIALS", {
+                severity: "warning",
+                structuredData: { path: "/signIn/credentials" },
+            })
+            return {
+                success: false,
+                headers: new Headers(secureApiHeaders),
+            }
+        }
+        logger?.log("CREDENTIALS_SIGN_IN_FAILED", {
+            severity: "error",
+            structuredData: { path: "/signIn/credentials" },
+        })
+        throw error
-        logger?.log("INVALID_CREDENTIALS", {
-            severity: "warning",
-            structuredData: {
-                path: "/signIn/credentials",
-            },
-        })
-        return {
-            success: false,
-            headers: new Headers(secureApiHeaders),
-        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/api/credentials.ts` around lines 31 - 41, The current broad
catch in credentials.ts collapses all failures into an INVALID_CREDENTIALS path;
change the catch to distinguish authentication failures from internal/runtime
errors: inside the try/catch around the sign-in logic (the block that currently
uses logger?.log and returns { success: false, headers: new
Headers(secureApiHeaders) }), catch specific auth-related errors (e.g.,
InvalidCredentialsError or whatever your sign-in validator throws) and keep the
existing logger?.log("INVALID_CREDENTIALS", ...) + auth-failure response for
those, but for unexpected/internal errors (crypto/session/cookie failures) log
them as errors with full details via logger?.error (include the error
message/stack) and return an appropriate 5xx response (or rethrow) rather than
classifying them as INVALID_CREDENTIALS; reference the logger variable and
secureApiHeaders in your changes.

}
}
2 changes: 2 additions & 0 deletions packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { createAuthAPI } from "@/api/createApi.ts"
export { signIn } from "@/api/signIn.ts"
export { signInCredentials } from "@/api/credentials.ts"
export { signOut } from "@/api/signOut.ts"
export { getSession } from "@/api/getSession.ts"
export { updateSession } from "@/api/updateSession.ts"
2 changes: 2 additions & 0 deletions packages/core/src/createAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isSecureConnection } from "@/shared/utils.ts"
import { createErrorHandler } from "@/router/errorHandler.ts"
import {
signInAction,
signInCredentialsAction,
callbackAction,
sessionAction,
signOutAction,
Expand Down Expand Up @@ -34,6 +35,7 @@ export const createAuthInstance = <Identity extends EditableShape<UserShape>>(au
const router = createRouter(
[
signInAction(config.context.oauth),
signInCredentialsAction,
callbackAction(config.context.oauth),
sessionAction,
signOutAction,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/router/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const createContext = <Identity extends EditableShape<UserShape>>(config?

const ctx = {
oauth: createBuiltInOAuthProviders(config?.oauth),
credentials: config?.credentials,
cookies: standardCookieStore,
jose: jose,
secret: config?.secret,
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class OAuthProtocolError extends Error {
this.error = error
this.errorURI = errorURI
this.name = new.target.name
Error.captureStackTrace(this, new.target)
Error?.captureStackTrace(this, new.target)
}
}

Expand All @@ -35,7 +35,7 @@ export class AuthInternalError extends Error {
super(message, options)
this.code = code
this.name = new.target.name
Error.captureStackTrace(this, new.target)
Error?.captureStackTrace(this, new.target)
}
}

Expand All @@ -53,7 +53,7 @@ export class AuthSecurityError extends Error {
super(message, options)
this.code = code
this.name = new.target.name
Error.captureStackTrace(this, new.target)
Error?.captureStackTrace(this, new.target)
}
}

Expand All @@ -65,7 +65,7 @@ export class AuthClientError extends Error {
super(message, options)
this.code = code
this.name = new.target.name
Error.captureStackTrace(this, new.target)
Error?.captureStackTrace(this, new.target)
}
}

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/shared/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,18 @@ export const logMessages = {
msgId: "IDENTITY_VALIDATION_FAILED",
message: "User identity validation against the schema failed",
},
CREDENTIALS_SIGN_IN_SUCCESS: {
facility: 4,
severity: "info",
msgId: "CREDENTIALS_SIGN_IN_SUCCESS",
message: "User successfully authenticated with credentials",
},
INVALID_CREDENTIALS: {
facility: 4,
severity: "warning",
msgId: "INVALID_CREDENTIALS",
message: "Authentication failed due to invalid credentials",
},
} as const

export const createLogEntry = <T extends keyof typeof logMessages>(key: T, overrides?: Partial<SyslogOptions>): SyslogOptions => {
Expand All @@ -298,7 +310,6 @@ export const createLogEntry = <T extends keyof typeof logMessages>(key: T, overr
...message,
timestamp: new Date().toISOString(),
hostname: "aura-auth",
procId: typeof process !== "undefined" && process.pid ? process.pid.toString() : "-",
...overrides,
}
}
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/shared/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,53 @@ export const verifyCSRF = async <DefaultUser extends User = User>(
throw new AuthSecurityError("CSRF_TOKEN_INVALID", "The CSRF tokens do not match.")
}
}

/**
* Hashes a password using PBKDF2 with SHA-256.
* PBKDF2 is available in standard Web Crypto (SubtleCrypto).
*
* @param password - The password to hash.
* @param salt - Optional salt (base64url encoded). If not provided, a random salt will be generated.
* @param iterations - The number of PBKDF2 iterations. Default is 100,000.
* @returns The hashed password in the format `iterations:salt:hash` (all segments base64url encoded).
*/
export const hashPassword = async (password: string, salt?: string, iterations = 100000) => {
const subtle = getSubtleCrypto()
const saltBuffer = (salt ? base64url.decode(salt) : getRandomBytes(16)) as any
const baseKey = await subtle.importKey("raw", encoder.encode(password) as any, "PBKDF2", false, ["deriveBits"])
const derivedKey = await subtle.deriveBits(
{
name: "PBKDF2",
salt: saltBuffer,
iterations,
hash: "SHA-256",
},
baseKey,
256
)
const hashValues = new Uint8Array(derivedKey)
const hash = base64url.encode(hashValues)
const saltStr = base64url.encode(saltBuffer)
return `${iterations}:${saltStr}:${hash}`
}

/**
* Verifies a password against a hashed value.
*
* @param password - The password to verify.
* @param hashedPassword - The hashed password to compare against.
* @returns A promise that resolves to true if the password matches the hash, false otherwise.
*/
export const verifyPassword = async (password: string, hashedPassword: string) => {
try {
const segments = hashedPassword.split(":")
if (segments.length !== 3) return false
const [iterationsStr, saltStr] = segments
const iterations = parseInt(iterationsStr, 10)
if (isNaN(iterations)) return false
const newHashed = await hashPassword(password, saltStr, iterations)
return timingSafeEqual(newHashed, hashedPassword)
} catch {
return false
}
}
Loading
Loading