diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index eada3b8c..76f8934e 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -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 { + /** + * 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 + /** + * Verifies a password against a hashed value. + */ + 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 + ) => Promise | null> | ShapeToObject | null +} + /** * Main configuration interface for Aura Auth. * This is the user-facing configuration object passed to `createAuth()`. @@ -149,6 +188,10 @@ export interface AuthConfig = Editable schema: ZodObject unknownKeys: "passthrough" | "strict" | "strip" }> + /** + * Credentials provider for username/password or similar authentication. + */ + credentials?: CredentialsProvider } /** @@ -254,6 +297,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/@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/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..ba605444 --- /dev/null +++ b/packages/core/src/actions/signIn/credentials.ts @@ -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 +) 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/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/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/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/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 { 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"] }