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 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)
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export interface Logger {
log?: (args: SyslogOptions) => void
}

export type AuthAPI = ReturnType<typeof createAuthAPI>
export type AuthAPI<DefaultUser extends User = User> = ReturnType<typeof createAuthAPI<DefaultUser>>
export type JoseInstance<DefaultUser extends User = User> = ReturnType<typeof createJoseInstance<DefaultUser>>

export interface InternalLogger {
Expand Down Expand Up @@ -232,11 +232,12 @@ export interface RouterGlobalContext<DefaultUser extends User = User> {
export type AuthRuntimeConfig<DefaultUser extends User = User> = RouterGlobalContext<DefaultUser>

export interface AuthInstance<DefaultUser extends User = User> {
api: AuthAPI
api: AuthAPI<DefaultUser>
jose: JoseInstance<DefaultUser>
handlers: {
GET: (request: Request) => Response | Promise<Response>
POST: (request: Request) => Response | Promise<Response>
PATCH: (request: Request) => Response | Promise<Response>
ALL: (request: Request) => Response | Promise<Response>
}
}
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/@types/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
Expand Down
27 changes: 24 additions & 3 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export type StatelessStrategyConfig = {
*/
export type SessionConfig = StatelessStrategyConfig

export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

/**
* Abstraction layer for session management.
*/
Expand All @@ -184,7 +188,14 @@ export interface SessionStrategy<DefaultUser extends User = User> {
* Attempt to refresh using the refresh token cookie.
* Returns null session + cookie-clearing response on any failure.
*/
refreshSession(session: Session<DefaultUser>): Promise<Session<DefaultUser> | null>
refreshSession(
headers: Headers,
session: DeepPartial<Session<DefaultUser>>,
skipCSRFCheck?: boolean
): Promise<{
session: Session<DefaultUser> | null
headers: Headers
}>
Comment thread
halvaradop marked this conversation as resolved.

/**
* Revoke a session by ID.
Expand Down Expand Up @@ -254,11 +265,21 @@ export type SignInReturn<Redirect extends boolean = boolean> = Redirect extends
? Response
: { redirect: false; signInURL: string }

export type SessionResponse =
| { session: Session; headers: Headers; authenticated: true }
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 UpdateSessionAPIOptions<DefaultUser extends User = User> {
headers: HeadersInit
session: DeepPartial<Session<DefaultUser>>
skipCSRFCheck?: boolean
}

export type UpdateSessionReturn<DefaultUser extends User = User> =
| { session: Session<DefaultUser>; headers: Headers; updated: true }
| { session: null; headers: Headers; updated: false }
1 change: 1 addition & 0 deletions packages/core/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
30 changes: 30 additions & 0 deletions packages/core/src/actions/updateSession/updateSession.ts
Original file line number Diff line number Diff line change
@@ -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
)
17 changes: 14 additions & 3 deletions packages/core/src/api/createApi.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,12 +12,14 @@ import type {
SignInAPIOptions,
SignInReturn,
SignOutAPIOptions,
UpdateSessionAPIOptions,
User,
} from "@/@types/index.ts"

export const createAuthAPI = (ctx: GlobalContext) => {
export const createAuthAPI = <DefaultUser extends User = User>(ctx: GlobalContext) => {
return {
getSession: async (options: GetSessionAPIOptions): Promise<SessionResponse> => {
const session = await getSession({ ctx, headers: options.headers })
getSession: async (options: GetSessionAPIOptions): Promise<SessionResponse<DefaultUser>> => {
const session = await getSession<DefaultUser>({ ctx, headers: options.headers })
return session
},
signIn: async <Redirect extends boolean = true>(
Expand All @@ -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<DefaultUser>) => {
return updateSession<DefaultUser>({
ctx,
headers: options.headers,
session: options.session,
skipCSRFCheck: options.skipCSRFCheck,
})
},
}
}
11 changes: 5 additions & 6 deletions packages/core/src/api/getSession.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
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 <DefaultUser extends User = User>({
ctx,
headers: headersInit,
}: FunctionAPIContext<GetSessionAPIOptions>): Promise<SessionResponse> => {
}: FunctionAPIContext<GetSessionAPIOptions>): Promise<SessionResponse<DefaultUser>> => {
const unauthorized: SessionResponse<DefaultUser> = { session: null, headers: new Headers(), authenticated: false }
try {
const { session, headers } = await ctx.sessionStrategy.getSession(new Headers(headersInit))
if (!session) return unauthorized
return {
session,
headers,
authenticated: true,
}
} as SessionResponse<DefaultUser>
} catch (error) {
ctx?.logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } })
return unauthorized
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/api/updateSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FunctionAPIContext, UpdateSessionAPIOptions, UpdateSessionReturn, User } from "@/@types/session.ts"

export const updateSession = async <DefaultUser extends User = User>({
ctx,
headers: headersInit,
session: sessionInit,
skipCSRFCheck = false,
}: FunctionAPIContext<UpdateSessionAPIOptions<DefaultUser>>): Promise<UpdateSessionReturn<DefaultUser>> => {
const { session, headers } = await ctx.sessionStrategy.refreshSession(new Headers(headersInit), sessionInit, skipCSRFCheck)
return {
session,
headers,
updated: session !== null,
} as UpdateSessionReturn<DefaultUser>
}
36 changes: 34 additions & 2 deletions packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import type {
AuthClient,
SignInOptions,
SignOutOptions,
User,
DeepPartial,
} from "@/@types/index.ts"

export const createClient = createClientAPI<AuthClient>

export const createAuthClient = (options: AuthClientOptions) => {
export const createAuthClient = <DefaultUser extends User = User>(options: AuthClientOptions) => {
if (typeof window === "undefined" && !options.baseURL) {
throw new AuthClientError("`baseURL` is required when createAuthClient is used outside the browser.")
}
Expand All @@ -36,7 +38,7 @@ export const createAuthClient = (options: AuthClientOptions) => {
}
}

const getSession = async (): Promise<Session | null> => {
const getSession = async (): Promise<Session<DefaultUser> | null> => {
try {
const response = await client.get("/session")
if (!response.ok) return null
Expand Down Expand Up @@ -100,9 +102,39 @@ export const createAuthClient = (options: AuthClientOptions) => {
}
}

const updateSession = async (session: DeepPartial<Session<DefaultUser>>) => {
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 })
}
}
Comment thread
halvaradop marked this conversation as resolved.

return {
getSession,
signIn,
signOut,
updateSession,
}
}
21 changes: 18 additions & 3 deletions packages/core/src/createAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <DefaultUser extends User = User>(authConfig?: AuthConfig<DefaultUser>): RouterConfig => {
Expand Down Expand Up @@ -47,14 +54,21 @@ const createInternalConfig = <DefaultUser extends User = User>(authConfig?: Auth
export const createAuthInstance = <DefaultUser extends User = User>(authConfig: AuthConfig<DefaultUser>) => {
const config = createInternalConfig<DefaultUser>(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<DefaultUser>(config.context),
}
}

Expand All @@ -65,6 +79,7 @@ export const createAuth = <DefaultUser extends User = User>(config: AuthConfig<D
const methodHandlers = {
GET: authInstance.handlers.GET,
POST: authInstance.handlers.POST,
PATCH: authInstance.handlers.PATCH,
} as const
if (method in methodHandlers) {
return await methodHandlers[method as keyof typeof methodHandlers](request)
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/oauth/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export const github = <DefaultUser extends User = User>(
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) =>
Expand Down
11 changes: 0 additions & 11 deletions packages/core/src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LiteralUnion<BuiltInOAuthProvider>, OAuthProviderCredentials<any>>
}

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
2 changes: 1 addition & 1 deletion packages/core/src/oauth/notion.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/router/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
Expand Down
Loading
Loading