diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7acec7f2..096db682 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 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) + +- Introduced an experimental `updateSession` API to update default session data from the initial OAuth profile data. The function infers the user generic type provided in `createAuth` and offers autocomplete. For security, the `sub` value cannot be overridden. [#129](https://github.com/aura-stack-ts/auth/pull/129) + - Introduced support for custom user type configuration across the authentication system and OAuth providers, enabling type-safe handling of extended user data in `jose` utilities and the `profile` field of providers such as `github`, `gitlab`, and `bitbucket`. [#127](https://github.com/aura-stack-ts/auth/pull/127) - Introduced JWT session expiration models within the session strategy system via the `expirationStrategy` option. This configuration enables flexible control over how session expiration is handled and updated over time. Supported models are `fixed`, `absolute`, `rolling`, and `sliding`. [#126](https://github.com/aura-stack-ts/auth/pull/126) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 9409b2fb..c9a7c194 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -204,7 +204,7 @@ export interface Logger { log?: (args: SyslogOptions) => void } -export type AuthAPI = ReturnType +export type AuthAPI = ReturnType> export type JoseInstance = ReturnType> export interface InternalLogger { @@ -232,11 +232,12 @@ export interface RouterGlobalContext { export type AuthRuntimeConfig = RouterGlobalContext export interface AuthInstance { - api: AuthAPI + api: AuthAPI jose: JoseInstance handlers: { GET: (request: Request) => Response | Promise POST: (request: Request) => Response | Promise + PATCH: (request: Request) => Response | Promise ALL: (request: Request) => Response | Promise } } diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index eb85e2db..9c34c807 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -5,7 +5,6 @@ import type { Prettify } from "@/@types/utility.ts" import type { ClientOptions } from "@aura-stack/router" import type { createAuthInstance } from "@/createAuth.ts" -export type { BuiltInOAuthProvider } from "@/oauth/index.ts" export type { TypedJWTPayload } from "@aura-stack/jose" export type * from "@/@types/config.ts" diff --git a/packages/core/src/@types/oauth.ts b/packages/core/src/@types/oauth.ts index f37401fa..5bcc8475 100644 --- a/packages/core/src/@types/oauth.ts +++ b/packages/core/src/@types/oauth.ts @@ -2,6 +2,8 @@ import type { User } from "@/@types/session.ts" import type { LiteralUnion } from "@/@types/utility.ts" import type { BuiltInOAuthProvider } from "@/oauth/index.ts" +export type { BuiltInOAuthProvider } from "@/oauth/index.ts" + export type AuthorizeParams = LiteralUnion< "clientId" | "prompt" | "scope" | "responseMode" | "audience" | "loginHint" | "nonce" | "display" > diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index e42f621f..622c59ea 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -164,6 +164,10 @@ export type StatelessStrategyConfig = { */ export type SessionConfig = StatelessStrategyConfig +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] +} + /** * Abstraction layer for session management. */ @@ -184,7 +188,14 @@ export interface SessionStrategy { * Attempt to refresh using the refresh token cookie. * Returns null session + cookie-clearing response on any failure. */ - refreshSession(session: Session): Promise | null> + refreshSession( + headers: Headers, + session: DeepPartial>, + skipCSRFCheck?: boolean + ): Promise<{ + session: Session | null + headers: Headers + }> /** * Revoke a session by ID. @@ -254,11 +265,21 @@ export type SignInReturn = Redirect extends ? Response : { redirect: false; signInURL: string } -export type SessionResponse = - | { session: Session; headers: Headers; authenticated: true } +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 UpdateSessionAPIOptions { + headers: HeadersInit + session: DeepPartial> + skipCSRFCheck?: boolean +} + +export type UpdateSessionReturn = + | { session: Session; headers: Headers; updated: true } + | { session: null; headers: Headers; updated: false } diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 75a8ba05..41be23a5 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -3,3 +3,4 @@ export { callbackAction } from "@/actions/callback/callback.ts" export { sessionAction } from "@/actions/session/session.ts" export { signOutAction } from "@/actions/signOut/signOut.ts" export { csrfTokenAction } from "@/actions/csrfToken/csrfToken.ts" +export { updateSessionAction } from "@/actions/updateSession/updateSession.ts" diff --git a/packages/core/src/actions/updateSession/updateSession.ts b/packages/core/src/actions/updateSession/updateSession.ts new file mode 100644 index 00000000..2a2b87b7 --- /dev/null +++ b/packages/core/src/actions/updateSession/updateSession.ts @@ -0,0 +1,30 @@ +import { z } from "zod/v4" +import { updateSession } from "@/api/updateSession.ts" +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" + +export const config = createEndpointConfig({ + schemas: { + body: z.object({ + name: z.string().optional(), + email: z.email().optional(), + image: z.string().optional(), + expires: z.string().optional(), + }), + }, +}) + +export const updateSessionAction = createEndpoint( + "PATCH", + "/session", + async (ctx) => { + const updated = await updateSession({ + ctx: ctx.context, + headers: ctx.request.headers, + session: { + user: ctx.body, + }, + }) + return Response.json(updated, { status: updated.updated ? 200 : 401 }) + }, + config +) diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index 84f0f28c..5e9c35e5 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -1,6 +1,7 @@ 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 type { GlobalContext } from "@aura-stack/router" import type { @@ -11,12 +12,14 @@ import type { SignInAPIOptions, SignInReturn, SignOutAPIOptions, + UpdateSessionAPIOptions, + User, } from "@/@types/index.ts" -export const createAuthAPI = (ctx: GlobalContext) => { +export const createAuthAPI = (ctx: GlobalContext) => { return { - getSession: async (options: GetSessionAPIOptions): Promise => { - const session = await getSession({ ctx, headers: options.headers }) + getSession: async (options: GetSessionAPIOptions): Promise> => { + const session = await getSession({ ctx, headers: options.headers }) return session }, signIn: async ( @@ -35,5 +38,13 @@ export const createAuthAPI = (ctx: GlobalContext) => { const redirectTo = validateRedirectTo(options?.redirectTo ?? "/") return signOut({ ctx, headers: options.headers, redirectTo, skipCSRFCheck: true }) }, + updateSession: async (options: UpdateSessionAPIOptions) => { + return updateSession({ + ctx, + headers: options.headers, + session: options.session, + skipCSRFCheck: options.skipCSRFCheck, + }) + }, } } diff --git a/packages/core/src/api/getSession.ts b/packages/core/src/api/getSession.ts index a262e827..368790c1 100644 --- a/packages/core/src/api/getSession.ts +++ b/packages/core/src/api/getSession.ts @@ -1,12 +1,11 @@ import { getErrorName } from "@/shared/utils.ts" -import type { FunctionAPIContext, GetSessionAPIOptions, SessionResponse } from "@/@types/index.ts" +import type { FunctionAPIContext, GetSessionAPIOptions, SessionResponse, User } from "@/@types/index.ts" -const unauthorized: SessionResponse = { session: null, headers: new Headers(), authenticated: false } - -export const getSession = async ({ +export const getSession = async ({ ctx, headers: headersInit, -}: FunctionAPIContext): Promise => { +}: FunctionAPIContext): Promise> => { + const unauthorized: SessionResponse = { session: null, headers: new Headers(), authenticated: false } try { const { session, headers } = await ctx.sessionStrategy.getSession(new Headers(headersInit)) if (!session) return unauthorized @@ -14,7 +13,7 @@ export const getSession = async ({ session, headers, authenticated: true, - } + } as SessionResponse } catch (error) { ctx?.logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) return unauthorized diff --git a/packages/core/src/api/updateSession.ts b/packages/core/src/api/updateSession.ts new file mode 100644 index 00000000..9b3d09ed --- /dev/null +++ b/packages/core/src/api/updateSession.ts @@ -0,0 +1,15 @@ +import { FunctionAPIContext, UpdateSessionAPIOptions, UpdateSessionReturn, User } from "@/@types/session.ts" + +export const updateSession = async ({ + ctx, + headers: headersInit, + session: sessionInit, + skipCSRFCheck = false, +}: FunctionAPIContext>): Promise> => { + const { session, headers } = await ctx.sessionStrategy.refreshSession(new Headers(headersInit), sessionInit, skipCSRFCheck) + return { + session, + headers, + updated: session !== null, + } as UpdateSessionReturn +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index c7ce3d5f..31c7686a 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -8,11 +8,13 @@ import type { AuthClient, SignInOptions, SignOutOptions, + User, + DeepPartial, } from "@/@types/index.ts" export const createClient = createClientAPI -export const createAuthClient = (options: AuthClientOptions) => { +export const createAuthClient = (options: AuthClientOptions) => { if (typeof window === "undefined" && !options.baseURL) { throw new AuthClientError("`baseURL` is required when createAuthClient is used outside the browser.") } @@ -36,7 +38,7 @@ export const createAuthClient = (options: AuthClientOptions) => { } } - const getSession = async (): Promise => { + const getSession = async (): Promise | null> => { try { const response = await client.get("/session") if (!response.ok) return null @@ -100,9 +102,39 @@ export const createAuthClient = (options: AuthClientOptions) => { } } + const updateSession = async (session: DeepPartial>) => { + try { + const csrfToken = await getCSRFToken() + if (!csrfToken) { + throw new AuthClientError("Failed to fetch CSRF token for sign-out.") + } + const { sub: _sub, ...spread } = (session.user ?? {}) as DefaultUser + const response = await client.patch("/session", { + body: { + ...spread, + expires: session.expires, + }, + headers: { + "X-CSRF-Token": csrfToken, + }, + }) + if (!response.ok) { + return { session: null, updated: false } + } + const json = await response.json() + return json + } catch (error) { + console.error("Error updating session:", error) + throw isNativeError(error) + ? error + : new AuthClientError("Session update failed.", "The session update request failed.", { cause: error }) + } + } + return { getSession, signIn, signOut, + updateSession, } } diff --git a/packages/core/src/createAuth.ts b/packages/core/src/createAuth.ts index 37ae0aec..a31909a2 100644 --- a/packages/core/src/createAuth.ts +++ b/packages/core/src/createAuth.ts @@ -3,7 +3,14 @@ import { createAuthAPI } from "@/api/createApi.ts" import { createContext } from "@/router/context.ts" import { isSecureConnection } from "@/shared/utils.ts" import { createErrorHandler } from "@/router/errorHandler.ts" -import { signInAction, callbackAction, sessionAction, signOutAction, csrfTokenAction } from "@/actions/index.ts" +import { + signInAction, + callbackAction, + sessionAction, + signOutAction, + csrfTokenAction, + updateSessionAction, +} from "@/actions/index.ts" import type { AuthConfig, AuthInstance, User } from "@/@types/index.ts" const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => { @@ -47,14 +54,21 @@ const createInternalConfig = (authConfig?: Auth export const createAuthInstance = (authConfig: AuthConfig) => { const config = createInternalConfig(authConfig) const router = createRouter( - [signInAction(config.context.oauth), callbackAction(config.context.oauth), sessionAction, signOutAction, csrfTokenAction], + [ + signInAction(config.context.oauth), + callbackAction(config.context.oauth), + sessionAction, + signOutAction, + csrfTokenAction, + updateSessionAction, + ], config ) return { handlers: router, jose: config.context.jose, - api: createAuthAPI(config.context), + api: createAuthAPI(config.context), } } @@ -65,6 +79,7 @@ export const createAuth = (config: AuthConfig( responseType: "code", }, }, - authorizeURL: "https://github.com/login/oauth/authorize", accessToken: "https://github.com/login/oauth/access_token", userInfo: "https://api.github.com/user", profile: (profile) => diff --git a/packages/core/src/oauth/index.ts b/packages/core/src/oauth/index.ts index 045ee2b9..e3ed9de1 100644 --- a/packages/core/src/oauth/index.ts +++ b/packages/core/src/oauth/index.ts @@ -127,19 +127,8 @@ export const createBuiltInOAuthProviders = (oauth: (BuiltInOAuthProvider | OAuth `Duplicate OAuth provider id "${oauthConfig.id}" found. Each provider must have a unique id.` ) } - return { ...previous, [oauthConfig.id]: oauthConfig } }, {}) as Record, OAuthProviderCredentials> } -export const createBasicAuthHeader = (username: string, password: string): string => { - const getUsername = getEnv(username.toUpperCase()) ?? username - const getPassword = getEnv(password.toUpperCase()) ?? password - if (!getUsername || !getPassword) { - throw new AuthInternalError("INVALID_OAUTH_CONFIGURATION", "Missing client credentials for OAuth provider configuration.") - } - const credentials = `${getUsername}:${getPassword}` - return `Basic ${btoa(credentials)}` -} - export type BuiltInOAuthProvider = keyof typeof builtInOAuthProviders diff --git a/packages/core/src/oauth/notion.ts b/packages/core/src/oauth/notion.ts index b367dcfc..98166e24 100644 --- a/packages/core/src/oauth/notion.ts +++ b/packages/core/src/oauth/notion.ts @@ -1,4 +1,4 @@ -import { createBasicAuthHeader } from "@/oauth/index.ts" +import { createBasicAuthHeader } from "@/shared/utils.ts" import type { OAuthProviderCredentials, User } from "@/@types/index.ts" export interface Person { diff --git a/packages/core/src/router/errorHandler.ts b/packages/core/src/router/errorHandler.ts index 9ad6ad47..2313f119 100644 --- a/packages/core/src/router/errorHandler.ts +++ b/packages/core/src/router/errorHandler.ts @@ -63,7 +63,7 @@ export const createErrorHandler = (logger?: InternalLogger): RouterConfig["onErr { status: 400 } ) } - logger?.log("SERVER_ERROR") + logger?.log("SERVER_ERROR", { structuredData: { error_type: error.name, error_message: error.message } }) return Response.json( { type: "SERVER_ERROR", code: "SERVER_ERROR", message: "An unexpected error occurred" }, { status: 500 } diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 28f78d65..45005d19 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -5,6 +5,7 @@ const AuthorizeConfigSchema = z.union([ object({ url: string().url(), params: object({ + owner: string().optional(), responseType: options(["code", "token", "id_token", "refresh_token"]).optional(), scope: string().optional(), }), diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index ff495a06..80b150ee 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -4,7 +4,15 @@ import { getErrorName } from "@/shared/utils.ts" import { AuthSecurityError } from "@/shared/errors.ts" import { createJoseManager } from "@/session/jose-manager.ts" import { createCookieManager } from "@/session/cookie-manager.ts" -import type { Session, SessionStrategy, User, TypedJWTPayload, JWTStrategyOptions, GetSessionReturn } from "@/@types/index.ts" +import type { + Session, + SessionStrategy, + User, + TypedJWTPayload, + JWTStrategyOptions, + GetSessionReturn, + DeepPartial, +} from "@/@types/index.ts" export const createStatelessStrategy = ({ config, @@ -38,58 +46,7 @@ export const createStatelessStrategy = ({ } } - const getSession = async (headers: Headers): Promise> => { - const newHeaders = new Headers() - try { - const { sessionToken } = cookieConfig.getCookie(headers) - if (!sessionToken) return { session: null, headers: newHeaders } - - const { - exp, - iat: _iat, - jti: _jti, - nbf: _nbf, - aud: _aud, - iss: _iss, - mexp, - ...user - } = await jwt.verifyToken(sessionToken) - if (!user.sub) return { session: null, headers: newHeaders } - const session: Session = { - user: user as DefaultUser, - expires: exp ? new Date(exp * 1000).toISOString() : "", - } - - const expiresAt = updateExpires({ exp }) - if (!expiresAt) return { session, headers: newHeaders } - - const newSession = { ...session, expires: expiresAt.toISOString() } - const newSessionToken = await jwt.createToken({ - ...(user as DefaultUser), - exp: Math.floor(expiresAt.getTime() / 1000), - mexp, - }) - logger?.log("SESSION_REFRESHED", { structuredData: { strategy: "stateless", expiresAt: expiresAt.toISOString() } }) - return { - session: newSession, - headers: cookieConfig.setCookie({ sessionToken: newSessionToken }), - } - } catch (error) { - logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) - return { session: null, headers: newHeaders } - } - } - - const createSession = async (session: TypedJWTPayload) => jwt.createToken(session) - - const refreshSession = async (_session: Session): Promise | null> => { - return null - } - - // JWT strategy: stateless tokens cannot be revoked server-side - const revokeSession = async (_sessionId: string): Promise => {} - - const destroySession = async (headers: Headers, skipCSRFCheck: boolean = false) => { + const verifyCSRFToken = async (headers: Headers, skipCSRFCheck: boolean = false): Promise => { let session = null let csrfToken = null const header = headers.get("X-CSRF-Token") @@ -103,7 +60,7 @@ export const createStatelessStrategy = ({ } catch { throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") } - logger?.log("SIGN_OUT_ATTEMPT", { + logger?.log("CSRF_TOKEN_REQUESTED", { structuredData: { has_session: Boolean(session), has_csrf_token: Boolean(csrfToken), @@ -130,7 +87,7 @@ export const createStatelessStrategy = ({ logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") } - logger?.log("SIGN_OUT_CSRF_VERIFIED") + logger?.log("CSRF_TOKEN_VERIFIED") } else { try { await jose.verifyJWS(csrfToken) @@ -141,10 +98,116 @@ export const createStatelessStrategy = ({ } try { await jose.decodeJWT(session) - logger?.log("SIGN_OUT_SUCCESS") + return true } catch (error) { logger?.log("INVALID_JWT_TOKEN", { structuredData: { error_type: getErrorName(error) } }) + return false + } + } + + const getSession = async (headers: Headers): Promise> => { + const newHeaders = new Headers() + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) return { session: null, headers: newHeaders } + + const { + exp, + iat: _iat, + jti: _jti, + nbf: _nbf, + aud: _aud, + iss: _iss, + mexp, + ...user + } = await jwt.verifyToken(sessionToken) + if (!user.sub) return { session: null, headers: newHeaders } + const session: Session = { + user: user as DefaultUser, + expires: exp ? new Date(exp * 1000).toISOString() : "", + } + + const expiresAt = updateExpires({ exp }) + if (!expiresAt) return { session, headers: newHeaders } + + const newSession = { ...session, expires: expiresAt.toISOString() } + const newSessionToken = await jwt.createToken({ + ...(user as DefaultUser), + exp: Math.floor(expiresAt.getTime() / 1000), + mexp, + }) + logger?.log("SESSION_REFRESHED", { structuredData: { strategy: "stateless", expiresAt: expiresAt.toISOString() } }) + return { + session: newSession, + headers: cookieConfig.setCookie({ sessionToken: newSessionToken }), + } + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return { session: null, headers: newHeaders } } + } + + const createSession = async (session: TypedJWTPayload) => jwt.createToken(session) + + const refreshSession = async ( + headers: Headers, + session: DeepPartial>, + skipCSRFCheck: boolean = false + ): Promise<{ + session: Session | null + headers: Headers + }> => { + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) { + return { session: null, headers: cookieConfig.clear() } + } + const isValidToken = await verifyCSRFToken(headers, skipCSRFCheck) + if (!isValidToken) { + return { session: null, headers: cookieConfig.clear() } + } + const { + exp, + mexp, + sub, + iat, + jti: _jti, + nbf: _nbf, + aud: _aud, + iss: _iss, + ...user + } = await jwt.verifyToken(sessionToken) + const expiresAt = session.expires + ? new Date(session.expires) + : (updateExpires({ exp }) ?? new Date(Date.now() + maxAge * 1000)) + const updatedSession: Session = { + user: { + ...user, + ...session.user, + sub, + } as DefaultUser, + expires: expiresAt.toISOString(), + } + const issuedAt = strategy === "absolute" ? iat : Math.floor(Date.now() / 1000) + const newToken = await jwt.createToken({ + ...updatedSession.user, + exp: Math.floor(expiresAt.getTime() / 1000), + iat: issuedAt, + mexp, + }) + updatedSession.expires = new Date(updatedSession.expires).toISOString() + return { session: updatedSession, headers: cookieConfig.setCookie({ sessionToken: newToken }) } + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return { session: null, headers: cookieConfig.clear() } + } + } + + // JWT strategy: stateless tokens cannot be revoked server-side + const revokeSession = async (_sessionId: string): Promise => {} + + const destroySession = async (headers: Headers, skipCSRFCheck: boolean = false) => { + await verifyCSRFToken(headers, skipCSRFCheck) return cookieConfig.clear() } diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index 307c762a..b9472818 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -272,6 +272,12 @@ export const logMessages = { msgId: "AUTH_SECURITY_ERROR", message: "An authentication security error occurred", }, + CSRF_TOKEN_VERIFIED: { + facility: 4, + severity: "info", + msgId: "CSRF_TOKEN_VERIFIED", + message: "CSRF token verification succeeded", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 320324e3..91512099 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -1,4 +1,6 @@ +import { getEnv } from "@/shared/env.ts" import { encoder } from "@aura-stack/jose/crypto" +import { AuthInternalError } from "@/shared/errors.ts" import { isRelativeURL, isValidURL } from "@/shared/assert.ts" import type { ZodError } from "zod" import type { APIErrorMap } from "@/@types/index.ts" @@ -104,3 +106,14 @@ export const timingSafeEqual = (a: string, b: string): boolean => { } return diff === 0 && bufferA.length === bufferB.length } + +export const createBasicAuthHeader = (username: string, password: string): string => { + const getUsername = getEnv(username) ?? username + const getPassword = getEnv(password) ?? password + if (!getUsername || !getPassword) { + throw new AuthInternalError("INVALID_OAUTH_CONFIGURATION", "Missing client credentials for OAuth provider configuration.") + } + const credentials = `${getUsername}:${getPassword}` + const binaryCredentials = String.fromCharCode.apply(null, Array.from(encoder.encode(credentials))) + return `Basic ${btoa(binaryCredentials)}` +} diff --git a/packages/core/test/actions/updateSession/updateSession.test.ts b/packages/core/test/actions/updateSession/updateSession.test.ts new file mode 100644 index 00000000..e6e4f0ac --- /dev/null +++ b/packages/core/test/actions/updateSession/updateSession.test.ts @@ -0,0 +1,91 @@ +import { createCSRF } from "@/shared/security.ts" +import { jose, PATCH } from "@test/presets.ts" +import { describe, test, expect } from "vitest" + +describe("updateSession action", () => { + test("invalid session", async () => { + const response = await PATCH( + new Request("http://localhost:3000/auth/session", { + method: "PATCH", + body: JSON.stringify({}), + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + session: null, + headers: {}, + updated: false, + }) + }) + + test("updates user session", async () => { + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }) + const csrfToken = await createCSRF(jose) + + const newUser = { + name: "Alice Smith", + email: "alicesmith@example.com", + image: "https://example.com/alicesmith-avatar.jpg", + } + + const response = await PATCH( + new Request("http://localhost:3000/auth/session", { + method: "PATCH", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + body: JSON.stringify(newUser), + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + session: { + user: { + sub: "1234567890", + ...newUser, + }, + expires: expect.any(String), + }, + headers: expect.any(Object), + updated: true, + }) + }) + + test("rejects session update when X-CSRF-Token header is missing", async () => { + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }) + const csrfToken = await createCSRF(jose) + + const newUser = { + name: "Alice Smith", + email: "alicesmith@example.com", + image: "https://example.com/alicesmith-avatar.jpg", + } + + const response = await PATCH( + new Request("http://localhost:3000/auth/session", { + method: "PATCH", + headers: { + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + body: JSON.stringify(newUser), + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + session: null, + headers: {}, + updated: false, + }) + }) +}) diff --git a/packages/core/test/api/updateSession.test.ts b/packages/core/test/api/updateSession.test.ts new file mode 100644 index 00000000..56896012 --- /dev/null +++ b/packages/core/test/api/updateSession.test.ts @@ -0,0 +1,166 @@ +import { describe, test, expect } from "vitest" +import { createAuth } from "@/createAuth.ts" +import { api, jose } from "@test/presets.ts" +import { createCSRF } from "@/shared/security.ts" +import type { User } from "@/index.ts" + +describe("updateSession API", () => { + test("invalid session", async () => { + const updated = await api.updateSession({ + headers: new Headers(), + session: {}, + }) + expect(updated).toEqual({ + session: null, + headers: expect.any(Headers), + updated: false, + }) + }) + + test("updates user session with skipCSRFCheck", async () => { + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }) + + const newUser = { + name: "Alice Smith", + email: "alicesmith@example.com", + image: "https://example.com/alicesmith-avatar.jpg", + } + + const csrfToken = await createCSRF(jose) + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }), + session: { + user: newUser, + }, + skipCSRFCheck: true, + }) + expect(updated).toEqual({ + session: { + user: { + sub: "1234567890", + ...newUser, + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + updated: true, + }) + }) + + test("updates user session with disabled skipCSRFCheck", async () => { + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + }) + + const newUser = { + name: "Alice Smith", + email: "alicesmith@example.com", + image: "https://example.com/alicesmith-avatar.jpg", + } + + const csrfToken = await createCSRF(jose) + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + "X-CSRF-Token": csrfToken, + }), + session: { + user: newUser, + }, + skipCSRFCheck: false, + }) + expect(updated).toEqual({ + session: { + user: { + sub: "1234567890", + ...newUser, + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + updated: true, + }) + }) + + test("updates user session with generic user type", async () => { + const { jose, api } = createAuth({ + oauth: [], + }) + + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + role: "admin", + department: "Engineering", + }) + + const csrfToken = await createCSRF(jose) + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }), + session: { + user: { role: "superadmin", department: "Executive" }, + }, + skipCSRFCheck: true, + }) + expect(updated).toEqual({ + session: { + user: { + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + image: "https://example.com/johndoe-avatar.jpg", + role: "superadmin", + department: "Executive", + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + updated: true, + }) + }) + + test("updates expiry on valid session", async () => { + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + }) + const csrfToken = await createCSRF(jose) + const expiresAt = new Date(Date.now() - 100_000).toISOString() + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }), + session: { + expires: expiresAt, + }, + skipCSRFCheck: true, + }) + expect(updated).toEqual({ + session: { + user: { + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + }, + expires: expiresAt, + }, + headers: expect.any(Headers), + updated: true, + }) + }) +}) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 41a53609..16844457 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -38,7 +38,7 @@ export const sessionPayload: JWTPayload = { } export const { - handlers: { GET, POST }, + handlers: { GET, POST, PATCH }, jose, api, } = createAuth({ diff --git a/packages/core/test/types.test-d.ts b/packages/core/test/types.test-d.ts index 1f12e0e2..ca4d72df 100644 --- a/packages/core/test/types.test-d.ts +++ b/packages/core/test/types.test-d.ts @@ -1,7 +1,8 @@ import { describe, expectTypeOf } from "vitest" import { createAuth } from "@/createAuth.ts" -import { AuthConfig, AuthInstance, JoseInstance, OAuthProviderCredentials, User } from "@/index.ts" -import { github, GitHubProfile } from "@/oauth/github.ts" +import { github, type GitHubProfile } from "@/oauth/github.ts" +import type { AuthConfig, AuthInstance, JoseInstance, OAuthProviderCredentials, User } from "@/index.ts" +import type { GetSessionAPIOptions, SessionResponse, UpdateSessionAPIOptions, UpdateSessionReturn } from "@/@types/session.ts" describe("createAuth", () => { expectTypeOf(createAuth).parameter(0).toEqualTypeOf() @@ -12,6 +13,19 @@ describe("createAuth", () => { expectTypeOf(createAuth({ oauth: [] })["jose"]).toEqualTypeOf< JoseInstance >() + expectTypeOf(createAuth({ oauth: [] })["api"].getSession).toEqualTypeOf< + (options: GetSessionAPIOptions) => Promise> + >() + expectTypeOf(createAuth({ oauth: [] })["api"].updateSession).toEqualTypeOf< + (options: UpdateSessionAPIOptions) => Promise> + >() + + expectTypeOf(createAuth({ oauth: [] })["api"].getSession).toEqualTypeOf< + (options: GetSessionAPIOptions) => Promise> + >() + expectTypeOf(createAuth({ oauth: [] })["api"].updateSession).toEqualTypeOf< + (options: UpdateSessionAPIOptions) => Promise> + >() }) describe("OAuth providers", () => {