Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
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
85 changes: 57 additions & 28 deletions 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 { EditableShape, Prettify, 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 @@ -205,11 +212,6 @@ export interface SessionStrategy<DefaultUser extends User = User> {
destroySession(request: Headers, skipCSRFCheck?: boolean): Promise<Headers>
}

export interface GetSessionReturn<DefaultUser extends User = User> {
session: Session<DefaultUser> | null
headers: Headers
}

export interface CreateSessionStrategyOptions<Identity extends EditableShape<UserShape>> {
config?: SessionConfig
jose: JoseInstance<ShapeToObject<Identity> & User>
Expand All @@ -226,24 +228,21 @@ export interface JWTStrategyOptions<DefaultUser extends User = User> {
identity: IdentityConfig
}

export interface SignInOptions {
redirect?: boolean
redirectTo?: string
}

export interface SignOutOptions {
redirect?: boolean
redirectTo?: string
export type JWTManager<DefaultUser extends User = User> = {
createToken(user: TypedJWTPayload<Partial<DefaultUser>>): Promise<string>
verifyToken(token: string): Promise<TypedJWTPayload<DefaultUser>>
}

export interface GetSessionAPIOptions {
headers: HeadersInit
}
// #region API Types
export type FunctionAPIContext<Options extends object> = Prettify<
{
ctx: RouterGlobalContext
} & Options
>

export interface SignOutAPIOptions {
headers: HeadersInit
export interface SignInOptions {
redirect?: boolean
redirectTo?: string
skipCSRFCheck?: boolean
}

export interface SignInAPIOptions<Redirect extends boolean = boolean> {
Expand All @@ -253,21 +252,32 @@ export interface SignInAPIOptions<Redirect extends boolean = boolean> {
request?: Request
}

export type FunctionAPIContext<Options extends object> = {
ctx: RouterGlobalContext
} & Options

export type SignInReturn<Redirect extends boolean = boolean> = Redirect extends true
? Response
: { redirect: false; signInURL: string }

export interface GetSessionReturn<DefaultUser extends User = User> {
session: Session<DefaultUser> | null
headers: Headers
}

export interface GetSessionAPIOptions {
headers: HeadersInit
}

export type SessionResponse<DefaultUser extends User = User> =
| { session: Session<DefaultUser>; headers: Headers; authenticated: true }
| { session: null; headers: Headers; authenticated: false }

export type JWTManager<DefaultUser extends User = User> = {
createToken(user: TypedJWTPayload<Partial<DefaultUser>>): Promise<string>
verifyToken(token: string): Promise<TypedJWTPayload<DefaultUser>>
export interface SignOutOptions {
redirect?: boolean
redirectTo?: string
}

export interface SignOutAPIOptions {
headers: HeadersInit
redirectTo?: string
skipCSRFCheck?: boolean
}

export interface UpdateSessionAPIOptions<DefaultUser extends User = User> {
Expand All @@ -279,3 +289,22 @@ 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
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 }
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/signInCredentials/signInCredentials.ts"
export { callbackAction } from "@/actions/callback/callback.ts"
export { sessionAction } from "@/actions/session/session.ts"
export { signOutAction } from "@/actions/signOut/signOut.ts"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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, redirectURL } = await signInCredentials({
ctx: ctx.context,
payload,
request: ctx.request,
headers: ctx.request.headers,
redirectTo: ctx.searchParams.redirectTo,
})
return Response.json({ success, redirectURL }, { 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
69 changes: 69 additions & 0 deletions packages/core/src/api/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 { createRedirectTo, getBaseURL, getOriginURL } from "@/actions/signIn/authorization.ts"
import type { SignInCredentialsOptions, SignInCredentialsReturn } from "@/@types/session.ts"

export const signInCredentials = async ({
ctx,
payload,
request: requestInit,
headers: headerInit,
redirectTo,
}: SignInCredentialsOptions): Promise<SignInCredentialsReturn> => {
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,
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 redirectURL = await createRedirectTo(request, redirectTo, ctx)

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,
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,
}
}
logger?.log("CREDENTIALS_SIGN_IN_FAILED", {
severity: "error",
structuredData: { path: "/signIn/credentials" },
})
return {
success: false,
headers: new Headers(secureApiHeaders),
redirectURL: null,
}
}
}
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"
Loading
Loading