From 833ef1a7ab7ec1eb35be73a5957997844c1c98b6 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Mon, 6 Apr 2026 12:35:24 -0500 Subject: [PATCH 1/4] feat(core): add credentials sign-in provider --- packages/core/src/@types/config.ts | 38 +++++++++ packages/core/src/@types/errors.ts | 2 + packages/core/src/actions/index.ts | 1 + .../core/src/actions/signIn/credentials.ts | 81 +++++++++++++++++++ packages/core/src/createAuth.ts | 2 + packages/core/src/router/context.ts | 1 + packages/core/src/shared/logger.ts | 13 ++- packages/core/src/shared/security.ts | 50 ++++++++++++ packages/core/tsconfig.json | 20 ++--- 9 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/actions/signIn/credentials.ts diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index eada3b8c..78f20852 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -9,6 +9,39 @@ 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" +/** + * Context provided to the credentials provider's authorize function. + * It includes the credentials sent by the user and hashing utilities. + */ +export interface CredentialsProviderContext { + /** + * User-provided credentials (e.g., email, password). + */ + credentials: Record + /** + * Hashes a password using the internal hashing algorithm (PBKDF2). + */ + hash: (password: string, salt?: string, iterations?: number) => Promise + /** + * Verifies a password against a hashed value. + */ + verify: (password: string, hashedPassword: string) => Promise +} + +/** + * Interface for the credentials provider. + */ +export interface CredentialsProvider = EditableShape> { + /** + * Authenticates a user using credentials. + * Must return a User object or the identity type if the identity schema is provided. + */ + authorize: ( + ctx: CredentialsProviderContext, + request: Request + ) => Promise | null> | ShapeToObject | null +} + /** * Main configuration interface for Aura Auth. * This is the user-facing configuration object passed to `createAuth()`. @@ -149,6 +182,10 @@ export interface AuthConfig = Editable schema: ZodObject unknownKeys: "passthrough" | "strict" | "strip" }> + /** + * Credentials provider for username/password or similar authentication. + */ + credentials?: CredentialsProvider } /** @@ -254,6 +291,7 @@ export interface IdentityConfig = typeof UserIdent export interface RouterGlobalContext { oauth: OAuthProviderRecord + credentials?: CredentialsProvider cookies: CookieStoreConfig jose: JoseInstance secret?: JWTKey diff --git a/packages/core/src/@types/errors.ts b/packages/core/src/@types/errors.ts index b62cbcfd..796a0776 100644 --- a/packages/core/src/@types/errors.ts +++ b/packages/core/src/@types/errors.ts @@ -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" diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 41be23a5..06606475 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -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" diff --git a/packages/core/src/actions/signIn/credentials.ts b/packages/core/src/actions/signIn/credentials.ts new file mode 100644 index 00000000..7953ac04 --- /dev/null +++ b/packages/core/src/actions/signIn/credentials.ts @@ -0,0 +1,81 @@ +import { createEndpoint, HeadersBuilder } from "@aura-stack/router" +import { createCSRF, hashPassword, verifyPassword } from "@/shared/security.ts" +import { cacheControl } from "@/shared/headers.ts" +import { AuthInternalError, AuthSecurityError } from "@/shared/errors.ts" + +/** + * 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 { request, context } = ctx + const { credentials: provider, sessionStrategy, cookies, jose, logger, identity } = context + + if (!provider) { + throw new AuthInternalError("CREDENTIALS_PROVIDER_NOT_CONFIGURED", "The credentials provider is not configured.") + } + + let body: Record + try { + body = await request.clone().json() + } catch { + throw new AuthSecurityError("INVALID_REQUEST_BODY", "The request body must be a valid JSON object.") + } + + const user = await provider.authorize( + { + credentials: body, + hash: hashPassword, + verify: verifyPassword, + }, + request + ) + + if (!user) { + logger?.log("INVALID_CREDENTIALS", { + severity: "warning", + structuredData: { + path: "/signIn/credentials", + }, + }) + return Response.json({ error: "Invalid credentials" }, { status: 401 }) + } + + let validatedUser = user as any + if (!identity.skipValidation) { + const result = identity.schema.safeParse(user) + if (!result.success) { + logger?.log("IDENTITY_VALIDATION_FAILED", { + severity: "error", + structuredData: { + error: result.error.message.slice(0, 100), + }, + }) + throw new AuthInternalError( + "IDENTITY_VALIDATION_FAILED", + "User data returned from credentials provider failed validation." + ) + } + validatedUser = result.data + } + + const session = await sessionStrategy.createSession(validatedUser as any) + const csrfToken = await createCSRF(jose) + + logger?.log("CREDENTIALS_SIGN_IN_SUCCESS", { + severity: "info", + structuredData: { + user_id: String((validatedUser as any).sub || "unknown"), + }, + }) + + const headers = new HeadersBuilder(cacheControl) + .setCookie(cookies.sessionToken.name, session, cookies.sessionToken.attributes) + .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) + .toHeaders() + + return Response.json({ success: true, user: validatedUser }, { status: 200, headers }) +}) diff --git a/packages/core/src/createAuth.ts b/packages/core/src/createAuth.ts index fccd8b8d..a299bda4 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -5,6 +5,7 @@ import { isSecureConnection } from "@/shared/utils.ts" import { createErrorHandler } from "@/router/errorHandler.ts" import { signInAction, + signInCredentialsAction, callbackAction, sessionAction, signOutAction, @@ -34,6 +35,7 @@ export const createAuthInstance = >(au const router = createRouter( [ signInAction(config.context.oauth), + signInCredentialsAction, callbackAction(config.context.oauth), sessionAction, signOutAction, diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 7e5698f6..d41bc6dc 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -20,6 +20,7 @@ export const createContext = >(config? const ctx = { oauth: createBuiltInOAuthProviders(config?.oauth), + credentials: config?.credentials, cookies: standardCookieStore, jose: jose, secret: config?.secret, diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 27b96e76..456ecba8 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -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 = (key: T, overrides?: Partial): SyslogOptions => { @@ -298,7 +310,6 @@ export const createLogEntry = (key: T, overr ...message, timestamp: new Date().toISOString(), hostname: "aura-auth", - procId: typeof process !== "undefined" && process.pid ? process.pid.toString() : "-", ...overrides, } } diff --git a/packages/core/src/shared/security.ts b/packages/core/src/shared/security.ts index ce0c1ad4..5e6fdd53 100644 --- a/packages/core/src/shared/security.ts +++ b/packages/core/src/shared/security.ts @@ -81,3 +81,53 @@ export const verifyCSRF = async ( 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 + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index bd5113ae..6e1331cd 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,13 +1,13 @@ { - "extends": "@aura-stack/tsconfig/tsconfig.base.json", - "compilerOptions": { - "paths": { - "@/*": ["./src/*"], - "@test/*": ["./test/*"], - }, - "allowImportingTsExtensions": true, - "noEmit": true, + "extends": "@aura-stack/tsconfig/tsconfig.base.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@test/*": ["./test/*"] }, - "include": ["src", "test"], - "exclude": ["dist", "node_modules"], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "test"], + "exclude": ["dist", "node_modules"] } From 62f9e81e2f9d550fa2104c8aba706762ee478b9b Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 8 Apr 2026 14:59:50 -0500 Subject: [PATCH 2/4] feat(core): add experimental signInCredentials --- packages/core/src/@types/config.ts | 18 ++- packages/core/src/@types/session.ts | 19 ++- .../core/src/actions/signIn/credentials.ts | 99 +++++---------- packages/core/src/api/createApi.ts | 13 +- packages/core/src/api/credentials.ts | 43 +++++++ packages/core/src/api/index.ts | 2 + packages/core/src/shared/errors.ts | 8 +- .../test/actions/signIn/credentials.test.ts | 113 ++++++++++++++++++ .../core/test/api/signInCredentials.test.ts | 89 ++++++++++++++ packages/core/test/presets.ts | 10 ++ 10 files changed, 327 insertions(+), 87 deletions(-) create mode 100644 packages/core/src/api/credentials.ts create mode 100644 packages/core/test/actions/signIn/credentials.test.ts create mode 100644 packages/core/test/api/signInCredentials.test.ts diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 78f20852..76f8934e 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -9,36 +9,42 @@ 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 { +export interface CredentialsProviderContext { /** * User-provided credentials (e.g., email, password). */ - credentials: Record + credentials: T /** * Hashes a password using the internal hashing algorithm (PBKDF2). */ - hash: (password: string, salt?: string, iterations?: number) => Promise + deriveSecret: (password: string, salt?: string, iterations?: number) => Promise /** * Verifies a password against a hashed value. */ - verify: (password: string, hashedPassword: string) => Promise + verifySecret: (password: string, hashedPassword: string) => Promise } /** * Interface for the credentials provider. */ export interface CredentialsProvider = EditableShape> { + hash?: (password: string, salt?: string, iterations?: number) => Promise + verify?: (password: string, hashedPassword: string) => Promise /** * Authenticates a user using credentials. * Must return a User object or the identity type if the identity schema is provided. */ authorize: ( - ctx: CredentialsProviderContext, - request: Request + ctx: CredentialsProviderContext ) => Promise | null> | ShapeToObject | null } diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index e8018116..ed51f18e 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -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" @@ -279,3 +286,13 @@ export interface UpdateSessionAPIOptions { export type UpdateSessionReturn = | { session: Session; 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 +} diff --git a/packages/core/src/actions/signIn/credentials.ts b/packages/core/src/actions/signIn/credentials.ts index 7953ac04..ba605444 100644 --- a/packages/core/src/actions/signIn/credentials.ts +++ b/packages/core/src/actions/signIn/credentials.ts @@ -1,7 +1,18 @@ -import { createEndpoint, HeadersBuilder } from "@aura-stack/router" -import { createCSRF, hashPassword, verifyPassword } from "@/shared/security.ts" -import { cacheControl } from "@/shared/headers.ts" -import { AuthInternalError, AuthSecurityError } from "@/shared/errors.ts" +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. @@ -10,72 +21,16 @@ import { AuthInternalError, AuthSecurityError } from "@/shared/errors.ts" * * @returns The signed-in user and session cookies. */ -export const signInCredentialsAction = createEndpoint("POST", "/signIn/credentials", async (ctx) => { - const { request, context } = ctx - const { credentials: provider, sessionStrategy, cookies, jose, logger, identity } = context - - if (!provider) { - throw new AuthInternalError("CREDENTIALS_PROVIDER_NOT_CONFIGURED", "The credentials provider is not configured.") - } - - let body: Record - try { - body = await request.clone().json() - } catch { - throw new AuthSecurityError("INVALID_REQUEST_BODY", "The request body must be a valid JSON object.") - } - - const user = await provider.authorize( - { - credentials: body, - hash: hashPassword, - verify: verifyPassword, - }, - request - ) - - if (!user) { - logger?.log("INVALID_CREDENTIALS", { - severity: "warning", - structuredData: { - path: "/signIn/credentials", - }, +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({ error: "Invalid credentials" }, { status: 401 }) - } - - let validatedUser = user as any - if (!identity.skipValidation) { - const result = identity.schema.safeParse(user) - if (!result.success) { - logger?.log("IDENTITY_VALIDATION_FAILED", { - severity: "error", - structuredData: { - error: result.error.message.slice(0, 100), - }, - }) - throw new AuthInternalError( - "IDENTITY_VALIDATION_FAILED", - "User data returned from credentials provider failed validation." - ) - } - validatedUser = result.data - } - - const session = await sessionStrategy.createSession(validatedUser as any) - const csrfToken = await createCSRF(jose) - - logger?.log("CREDENTIALS_SIGN_IN_SUCCESS", { - severity: "info", - structuredData: { - user_id: String((validatedUser as any).sub || "unknown"), - }, - }) - - const headers = new HeadersBuilder(cacheControl) - .setCookie(cookies.sessionToken.name, session, cookies.sessionToken.attributes) - .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) - .toHeaders() - - return Response.json({ success: true, user: validatedUser }, { status: 200, headers }) -}) + return Response.json({ success }, { headers, status: success ? 200 : 401 }) + }, + config +) diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index 5e9c35e5..fc316cd2 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -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, @@ -14,6 +11,7 @@ import type { SignOutAPIOptions, UpdateSessionAPIOptions, User, + SignInCredentialsAPIOptions, } from "@/@types/index.ts" export const createAuthAPI = (ctx: GlobalContext) => { @@ -34,6 +32,13 @@ export const createAuthAPI = (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 }) diff --git a/packages/core/src/api/credentials.ts b/packages/core/src/api/credentials.ts new file mode 100644 index 00000000..5c65cc65 --- /dev/null +++ b/packages/core/src/api/credentials.ts @@ -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) => { + 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), + } + } +} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index e63f7166..668d410c 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -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" diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index ca908043..9563cf66 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -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) } } @@ -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) } } @@ -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) } } @@ -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) } } diff --git a/packages/core/test/actions/signIn/credentials.test.ts b/packages/core/test/actions/signIn/credentials.test.ts new file mode 100644 index 00000000..c78a1b2a --- /dev/null +++ b/packages/core/test/actions/signIn/credentials.test.ts @@ -0,0 +1,113 @@ +import { getSetCookie } from "@/cookie.ts" +import { createAuth } from "@/createAuth.ts" +import { POST } from "@test/presets.ts" +import { describe, test, expect } from "vitest" + +describe("signInCredentials action", () => { + test("success signIn flow", async () => { + const response = await POST( + new Request("http://localhost:3000/auth/signIn/credentials", { + method: "POST", + body: JSON.stringify({ + username: "johndoe", + password: "1234567890", + }), + }) + ) + const data = await response.json() + expect(response.status).toBe(200) + expect(data).toMatchObject({ + success: true, + }) + }) + + test("invalid credentials", async () => { + const { + handlers: { POST }, + } = createAuth({ + oauth: [], + credentials: { + authorize: () => null, + }, + }) + const response = await POST( + new Request("http://localhost:3000/auth/signIn/credentials", { + method: "POST", + body: JSON.stringify({ + username: "johndoe", + password: "wrongpassword", + }), + }) + ) + const data = await response.json() + expect(response.status).toBe(401) + expect(data).toMatchObject({ + success: false, + }) + }) + + test("invalid authorize by missing required fields", async () => { + const { + handlers: { POST }, + } = createAuth({ + oauth: [], + credentials: { + authorize: () => + ({ + name: "John Doe", + email: "johndoe@example.com", + }) as any, + }, + }) + const response = await POST( + new Request("http://localhost:3000/auth/signIn/credentials", { + method: "POST", + body: JSON.stringify({ + username: "johndoe", + password: "1234567890", + } as any), + }) + ) + const data = await response.json() + expect(response.status).toBe(401) + expect(data).toMatchObject({ + success: false, + }) + }) + + test("simulate hashing and verification", async () => { + const { + jose, + handlers: { POST }, + } = createAuth({ + oauth: [], + credentials: { + authorize: async (ctx) => { + // Simulate password hashing and verification + const hash = await ctx.deriveSecret(ctx.credentials.password, "salt") + const isVerified = await ctx.verifySecret(ctx.credentials.password, hash) + if (!isVerified) return null + return { + sub: "1234567890-abcdef", + name: ctx.credentials.username, + } + }, + }, + }) + const response = await POST( + new Request("http://localhost:3000/auth/signIn/credentials", { + method: "POST", + body: JSON.stringify({ + username: "johndoe", + password: "1234567890", + }), + }) + ) + expect(response.status).toBe(200) + const decoded = await jose.decodeJWT(getSetCookie(response.headers, "aura-auth.session_token")!) + expect(decoded).toMatchObject({ + sub: "1234567890-abcdef", + name: "johndoe", + }) + }) +}) diff --git a/packages/core/test/api/signInCredentials.test.ts b/packages/core/test/api/signInCredentials.test.ts new file mode 100644 index 00000000..a5f945d7 --- /dev/null +++ b/packages/core/test/api/signInCredentials.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from "vitest" +import { createAuth } from "@/createAuth.ts" +import { getSetCookie } from "@/cookie.ts" +import { api, jose } from "@test/presets.ts" + +describe("signInCredentials API", () => { + test("success signIn flow", async () => { + const { headers, success } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + }, + }) + expect(success).toBe(true) + const decoded = await jose.decodeJWT(getSetCookie(headers, "aura-auth.session_token")!) + expect(decoded).toMatchObject({ + sub: "1234567890", + email: "johndoe@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }) + }) + + test("invalid authorize return", async () => { + const { api } = createAuth({ + oauth: [], + credentials: { + authorize: () => null, + }, + }) + const { success } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "wrongpassword", + }, + }) + expect(success).toBe(false) + }) + + test("invalid authorize by missing required fields", async () => { + const { api } = createAuth({ + oauth: [], + credentials: { + authorize: () => + ({ + name: "John Doe", + email: "johndoe@example.com", + }) as any, + }, + }) + const { success } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + } as any, + }) + expect(success).toBe(false) + }) + + test("simulate hashing and verification", async () => { + const { api } = createAuth({ + oauth: [], + credentials: { + authorize: async (ctx) => { + // Simulate password hashing and verification + const hash = await ctx.deriveSecret(ctx.credentials.password, "salt") + const isVerified = await ctx.verifySecret(ctx.credentials.password, hash) + if (!isVerified) return null + return { + sub: "1234567890-abcdef", + name: ctx.credentials.username, + } + }, + }, + }) + const { headers, success } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + }, + }) + expect(success).toBe(true) + const decoded = await jose.decodeJWT(getSetCookie(headers, "aura-auth.session_token")!) + expect(decoded).toMatchObject({ + sub: "1234567890-abcdef", + name: "johndoe", + }) + }) +}) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 5367554b..01e67318 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -39,6 +39,16 @@ export const sessionPayload: JWTPayload = { const auth = createAuth({ oauth: [oauthCustomService, oauthCustomServiceProfile], logger: true, + credentials: { + authorize: async () => { + return { + sub: "1234567890", + email: "johndoe@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + } + }, + }, }) export const { From e1890973362a77bbdf295daa6bdbcde6301d7f9a Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 9 Apr 2026 10:54:54 -0500 Subject: [PATCH 3/4] feat(core): implement `redirectTo` option in `signInCredentials` --- packages/core/src/@types/session.ts | 66 +++++++++++-------- packages/core/src/actions/index.ts | 2 +- .../signInCredentials.ts} | 1 + packages/core/src/api/credentials.ts | 48 ++++++++++---- packages/core/src/shared/logger.ts | 6 ++ packages/core/src/shared/security.ts | 7 +- .../signInCredentials.test.ts} | 12 +++- .../core/test/api/signInCredentials.test.ts | 50 +++++++++++++- packages/rate-limiter/tsconfig.json | 18 ++--- 9 files changed, 156 insertions(+), 54 deletions(-) rename packages/core/src/actions/{signIn/credentials.ts => signInCredentials/signInCredentials.ts} (96%) rename packages/core/test/actions/{signIn/credentials.test.ts => signInCredentials/signInCredentials.test.ts} (95%) diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index ed51f18e..496cb09a 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -1,4 +1,4 @@ -import { EditableShape, ShapeToObject } from "./utility.ts" +import { EditableShape, Prettify, ShapeToObject } from "./utility.ts" import type { TypedJWTPayload } from "@aura-stack/jose" import type { UserIdentityType, UserShape } from "@/shared/identity.ts" import type { @@ -212,11 +212,6 @@ export interface SessionStrategy { destroySession(request: Headers, skipCSRFCheck?: boolean): Promise } -export interface GetSessionReturn { - session: Session | null - headers: Headers -} - export interface CreateSessionStrategyOptions> { config?: SessionConfig jose: JoseInstance & User> @@ -233,24 +228,21 @@ export interface JWTStrategyOptions { identity: IdentityConfig } -export interface SignInOptions { - redirect?: boolean - redirectTo?: string -} - -export interface SignOutOptions { - redirect?: boolean - redirectTo?: string +export type JWTManager = { + createToken(user: TypedJWTPayload>): Promise + verifyToken(token: string): Promise> } -export interface GetSessionAPIOptions { - headers: HeadersInit -} +// #region API Types +export type FunctionAPIContext = Prettify< + { + ctx: RouterGlobalContext + } & Options +> -export interface SignOutAPIOptions { - headers: HeadersInit +export interface SignInOptions { + redirect?: boolean redirectTo?: string - skipCSRFCheck?: boolean } export interface SignInAPIOptions { @@ -260,21 +252,32 @@ export interface SignInAPIOptions { request?: Request } -export type FunctionAPIContext = { - ctx: RouterGlobalContext -} & Options - export type SignInReturn = Redirect extends true ? Response : { redirect: false; signInURL: string } +export interface GetSessionReturn { + session: Session | null + headers: Headers +} + +export interface GetSessionAPIOptions { + headers: HeadersInit +} + export type SessionResponse = | { session: Session; headers: Headers; authenticated: true } | { session: null; headers: Headers; authenticated: false } -export type JWTManager = { - createToken(user: TypedJWTPayload>): Promise - verifyToken(token: string): Promise> +export interface SignOutOptions { + redirect?: boolean + redirectTo?: string +} + +export interface SignOutAPIOptions { + headers: HeadersInit + redirectTo?: string + skipCSRFCheck?: boolean } export interface UpdateSessionAPIOptions { @@ -289,10 +292,19 @@ export type UpdateSessionReturn = export type SignInCredentialsOptions = FunctionAPIContext<{ payload: CredentialsPayload + request?: Request + headers?: HeadersInit redirectTo?: string }> export interface SignInCredentialsAPIOptions { payload: CredentialsPayload + request?: Request + headers?: HeadersInit + redirect?: boolean redirectTo?: string } + +export type SignInCredentialsReturn = + | { success: true; headers: Headers; redirectURL: string } + | { success: false; headers: Headers; redirectURL?: null } diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 06606475..8dd66ea2 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -1,5 +1,5 @@ export { signInAction } from "@/actions/signIn/signIn.ts" -export { signInCredentialsAction } from "@/actions/signIn/credentials.ts" +export { signInCredentialsAction } from "@/actions/signInCredentials/signInCredentials.ts" export { callbackAction } from "@/actions/callback/callback.ts" export { sessionAction } from "@/actions/session/session.ts" export { signOutAction } from "@/actions/signOut/signOut.ts" diff --git a/packages/core/src/actions/signIn/credentials.ts b/packages/core/src/actions/signInCredentials/signInCredentials.ts similarity index 96% rename from packages/core/src/actions/signIn/credentials.ts rename to packages/core/src/actions/signInCredentials/signInCredentials.ts index ba605444..10b68863 100644 --- a/packages/core/src/actions/signIn/credentials.ts +++ b/packages/core/src/actions/signInCredentials/signInCredentials.ts @@ -29,6 +29,7 @@ export const signInCredentialsAction = createEndpoint( const { headers, success } = await signInCredentials({ ctx: ctx.context, payload, + request: ctx.request }) return Response.json({ success }, { headers, status: success ? 200 : 401 }) }, diff --git a/packages/core/src/api/credentials.ts b/packages/core/src/api/credentials.ts index 5c65cc65..387fceaf 100644 --- a/packages/core/src/api/credentials.ts +++ b/packages/core/src/api/credentials.ts @@ -1,12 +1,27 @@ -import { AuthValidationError } from "@/shared/errors.ts" +import { HeadersBuilder } from "@aura-stack/router" import { secureApiHeaders } from "@/shared/headers.ts" +import { AuthValidationError } from "@/shared/errors.ts" import { createCSRF, hashPassword, verifyPassword } from "@/shared/security.ts" -import { HeadersBuilder } from "@aura-stack/router" -import type { SignInCredentialsOptions } from "@/@types/session.ts" +import { createRedirectTo, getBaseURL, getOriginURL } from "@/actions/signIn/authorization.ts" +import type { SignInCredentialsOptions, SignInCredentialsReturn } from "@/@types/session.ts" -export const signInCredentials = async ({ ctx, payload }: SignInCredentialsOptions) => { +export const signInCredentials = async({ + ctx, + payload, + request: requestInit, + headers: headerInit, + redirectTo, +}: SignInCredentialsOptions): Promise => { const { cookies, credentials, sessionStrategy, logger } = ctx try { + let request = requestInit + if (!request) { + const origin = await getBaseURL({ ctx, headers: headerInit }) + const url = `${origin}${ctx.basePath}/signIn/credentials` + request = new Request(url, { headers: headerInit }) + } + await getOriginURL(request, ctx) + const session = await credentials?.authorize({ credentials: payload, deriveSecret: credentials?.hash ?? hashPassword, @@ -17,8 +32,8 @@ export const signInCredentials = async ({ ctx, payload }: SignInCredentialsOptio } const sessionToken = await sessionStrategy.createSession(session) const csrfToken = await createCSRF(ctx.jose) - logger?.log("CREDENTIALS_SIGN_IN_SUCCESS") + const redirectURL = await createRedirectTo(request, redirectTo, ctx) const headers = new HeadersBuilder(secureApiHeaders) .setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes) @@ -27,17 +42,28 @@ export const signInCredentials = async ({ ctx, payload }: SignInCredentialsOptio return { success: true, headers, + redirectURL + } + } catch (error) { + if (error instanceof AuthValidationError) { + logger?.log("INVALID_CREDENTIALS", { + severity: "warning", + structuredData: { path: "/signIn/credentials" }, + }) + return { + success: false, + headers: new Headers(secureApiHeaders), + redirectURL: null + } } - } catch { - logger?.log("INVALID_CREDENTIALS", { - severity: "warning", - structuredData: { - path: "/signIn/credentials", - }, + logger?.log("CREDENTIALS_SIGN_IN_FAILED", { + severity: "error", + structuredData: { path: "/signIn/credentials" }, }) return { success: false, headers: new Headers(secureApiHeaders), + redirectURL: null } } } diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 456ecba8..9b05281e 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -302,6 +302,12 @@ export const logMessages = { msgId: "INVALID_CREDENTIALS", message: "Authentication failed due to invalid credentials", }, + CREDENTIALS_SIGN_IN_FAILED: { + facility: 4, + severity: "error", + msgId: "CREDENTIALS_SIGN_IN_FAILED", + message: "An error occurred during credentials sign-in", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/src/shared/security.ts b/packages/core/src/shared/security.ts index 5e6fdd53..da545e0c 100644 --- a/packages/core/src/shared/security.ts +++ b/packages/core/src/shared/security.ts @@ -108,7 +108,7 @@ export const hashPassword = async (password: string, salt?: string, iterations = const hashValues = new Uint8Array(derivedKey) const hash = base64url.encode(hashValues) const saltStr = base64url.encode(saltBuffer) - return `${iterations}:${saltStr}:${hash}` + return `pbkdf2-sha256:${iterations}:${saltStr}:${hash}` } /** @@ -121,8 +121,9 @@ export const hashPassword = async (password: string, salt?: string, iterations = export const verifyPassword = async (password: string, hashedPassword: string) => { try { const segments = hashedPassword.split(":") - if (segments.length !== 3) return false - const [iterationsStr, saltStr] = segments + if (segments.length !== 4) return false + const [scheme, iterationsStr, saltStr] = segments + if (scheme !== "pbkdf2-sha256") return false const iterations = parseInt(iterationsStr, 10) if (isNaN(iterations)) return false const newHashed = await hashPassword(password, saltStr, iterations) diff --git a/packages/core/test/actions/signIn/credentials.test.ts b/packages/core/test/actions/signInCredentials/signInCredentials.test.ts similarity index 95% rename from packages/core/test/actions/signIn/credentials.test.ts rename to packages/core/test/actions/signInCredentials/signInCredentials.test.ts index c78a1b2a..ca7c5379 100644 --- a/packages/core/test/actions/signIn/credentials.test.ts +++ b/packages/core/test/actions/signInCredentials/signInCredentials.test.ts @@ -1,7 +1,15 @@ +import { describe, test, expect, beforeEach, vi, afterEach } from "vitest" +import { POST } from "@test/presets.ts" import { getSetCookie } from "@/cookie.ts" import { createAuth } from "@/createAuth.ts" -import { POST } from "@test/presets.ts" -import { describe, test, expect } from "vitest" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) describe("signInCredentials action", () => { test("success signIn flow", async () => { diff --git a/packages/core/test/api/signInCredentials.test.ts b/packages/core/test/api/signInCredentials.test.ts index a5f945d7..e2f8e8cc 100644 --- a/packages/core/test/api/signInCredentials.test.ts +++ b/packages/core/test/api/signInCredentials.test.ts @@ -1,10 +1,20 @@ -import { describe, test, expect } from "vitest" +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest" import { createAuth } from "@/createAuth.ts" import { getSetCookie } from "@/cookie.ts" import { api, jose } from "@test/presets.ts" +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + describe("signInCredentials API", () => { test("success signIn flow", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const { headers, success } = await api.signInCredentials({ payload: { username: "johndoe", @@ -58,6 +68,8 @@ describe("signInCredentials API", () => { }) test("simulate hashing and verification", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const { api } = createAuth({ oauth: [], credentials: { @@ -86,4 +98,40 @@ describe("signInCredentials API", () => { name: "johndoe", }) }) + + test("signIn without URL configuration", async () => { + const { success } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + }, + }) + expect(success).toBe(false) + }) + + test("signIn with valid redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const { success } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + }, + redirectTo: "https://example.com/dashboard", + }) + expect(success).toBe(true) + }) + + test("signIn with invalid redirectTo", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const { redirectURL } = await api.signInCredentials({ + payload: { + username: "johndoe", + password: "1234567890", + }, + redirectTo: "https://malicious.com/phishing", + }) + expect(redirectURL).toBe("/") + }) }) diff --git a/packages/rate-limiter/tsconfig.json b/packages/rate-limiter/tsconfig.json index 729c090c..5db7d3f1 100644 --- a/packages/rate-limiter/tsconfig.json +++ b/packages/rate-limiter/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "@aura-stack/tsconfig/tsconfig.base.json", - "compilerOptions": { - "paths": { - "@/*": ["./src/*"], - }, - "noEmit": true, - "allowImportingTsExtensions": true, + "extends": "@aura-stack/tsconfig/tsconfig.base.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] }, - "include": ["src", "tsup.config.ts", "test"], - "exclude": ["dist", "node_modules"], + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src", "tsup.config.ts", "test"], + "exclude": ["dist", "node_modules"] } From 313b92dbb1a126841c51f0b1b302db2e9337d366 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 9 Apr 2026 11:34:30 -0500 Subject: [PATCH 4/4] chore(core): improve code --- packages/core/CHANGELOG.md | 4 ++++ .../signInCredentials/signInCredentials.ts | 8 +++++--- packages/core/src/api/credentials.ts | 8 ++++---- packages/core/src/client/client.ts | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index b40d5d34..d60afdfd 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Introduced experimental credentials-based authentication via the `/session` endpoint, enabling username/password sign-in on the client side. [#136](https://github.com/aura-stack-ts/auth/pull/136) + +- Introduced experimental credentials-based authentication via the `signInCredentials` API, enabling username/password sign-in on the server side. [#136](https://github.com/aura-stack-ts/auth/pull/136) + - Introduced the `identity` configuration option in `createAuth` to validate and extend default user fields (for example, role and permissions) using the `identity.schema` Zod schema. It also supports unknown field handling through `unknownKeys`, which can strip, pass through, or reject unknown fields. Additionally, the `/session` endpoint now supports any fields defined in `identity.schema`. [#130](https://github.com/aura-stack-ts/auth/pull/130) - Introduced an experimental `/session` endpoint to update default session data from the initial OAuth profile data. It currently supports updates only for the `email`, `name`, and `image` fields. For broader claim support, use the experimental `api.updateSession` function. [#129](https://github.com/aura-stack-ts/auth/pull/129) diff --git a/packages/core/src/actions/signInCredentials/signInCredentials.ts b/packages/core/src/actions/signInCredentials/signInCredentials.ts index 10b68863..ec6fecfd 100644 --- a/packages/core/src/actions/signInCredentials/signInCredentials.ts +++ b/packages/core/src/actions/signInCredentials/signInCredentials.ts @@ -26,12 +26,14 @@ export const signInCredentialsAction = createEndpoint( "/signIn/credentials", async (ctx) => { const payload = ctx.body - const { headers, success } = await signInCredentials({ + const { headers, success, redirectURL } = await signInCredentials({ ctx: ctx.context, payload, - request: ctx.request + request: ctx.request, + headers: ctx.request.headers, + redirectTo: ctx.searchParams.redirectTo, }) - return Response.json({ success }, { headers, status: success ? 200 : 401 }) + return Response.json({ success, redirectURL }, { headers, status: success ? 200 : 401 }) }, config ) diff --git a/packages/core/src/api/credentials.ts b/packages/core/src/api/credentials.ts index 387fceaf..b9261eac 100644 --- a/packages/core/src/api/credentials.ts +++ b/packages/core/src/api/credentials.ts @@ -5,7 +5,7 @@ import { createCSRF, hashPassword, verifyPassword } from "@/shared/security.ts" import { createRedirectTo, getBaseURL, getOriginURL } from "@/actions/signIn/authorization.ts" import type { SignInCredentialsOptions, SignInCredentialsReturn } from "@/@types/session.ts" -export const signInCredentials = async({ +export const signInCredentials = async ({ ctx, payload, request: requestInit, @@ -42,7 +42,7 @@ export const signInCredentials = async({ return { success: true, headers, - redirectURL + redirectURL, } } catch (error) { if (error instanceof AuthValidationError) { @@ -53,7 +53,7 @@ export const signInCredentials = async({ return { success: false, headers: new Headers(secureApiHeaders), - redirectURL: null + redirectURL: null, } } logger?.log("CREDENTIALS_SIGN_IN_FAILED", { @@ -63,7 +63,7 @@ export const signInCredentials = async({ return { success: false, headers: new Headers(secureApiHeaders), - redirectURL: null + redirectURL: null, } } } diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 548cd2c8..59b33062 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -10,6 +10,7 @@ import type { SignOutOptions, User, DeepPartial, + CredentialsPayload, } from "@/@types/index.ts" export const createClient = createClientAPI @@ -132,9 +133,27 @@ export const createAuthClient = (options: AuthC } } + const signInCredentials = async (credentials: CredentialsPayload, options?: SignInOptions) => { + try { + const response = await client.post("/signIn/credentials", { + body: credentials, + searchParams: {}, + }) + const json = await response.json() + if ((options?.redirect ?? true) && typeof window !== "undefined" && json?.signInURL) { + window.location.assign(json.signInURL) + } + return json + } catch (error) { + console.error("Error during credentials sign-in:", error) + return { success: false, redirectURL: null } + } + } + return { getSession, signIn, + signInCredentials, signOut, updateSession, }