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
16 changes: 15 additions & 1 deletion packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type JWTPayloadWithToken = JWTPayload & { token: string }
* Standardized user profile returned by OAuth providers after fetching user information
* and mapping the response to this format by default or via the `profile` custom function.
*/
export interface User {
export interface User extends Record<string, unknown> {
sub: string
name?: string | null
email?: string | null
Expand Down Expand Up @@ -405,3 +405,17 @@ export interface SignOutOptions {
redirect?: boolean
redirectTo?: string
}

export interface GetSessionAPIOptions {
headers: HeadersInit
}

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

export type FunctionAPIContext<Options extends object> = {
ctx: RouterGlobalContext
} & Options
3 changes: 1 addition & 2 deletions packages/core/src/actions/callback/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { AuthSecurityError, OAuthProtocolError } from "@/errors.ts"
import { getOriginURL, getTrustedOrigins } from "@/actions/signIn/authorization.ts"
import { createAccessToken } from "@/actions/callback/access-token.ts"
import { createSessionCookie, getCookie, expiredCookieAttributes } from "@/cookie.ts"
import type { JWTPayload } from "@/jose.ts"
import type { OAuthProviderRecord } from "@/@types/index.ts"

const callbackConfig = (oauth: OAuthProviderRecord) => {
Expand Down Expand Up @@ -109,7 +108,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {
}

const userInfo = await getUserInfo(oauthConfig, accessToken.access_token, logger)
const sessionCookie = await createSessionCookie(jose, userInfo as JWTPayload)
const sessionCookie = await createSessionCookie(jose, userInfo)
const csrfToken = await createCSRF(jose)
Comment thread
halvaradop marked this conversation as resolved.

logger?.log("OAUTH_CALLBACK_SUCCESS", {
Expand Down
15 changes: 5 additions & 10 deletions packages/core/src/api/createApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@ import { signOut } from "@/api/signOut.ts"
import { validateRedirectTo } from "@/utils.ts"
import { getSession } from "@/api/getSession.ts"
import type { GlobalContext } from "@aura-stack/router"
import type { SessionResponse } from "@/@types/index.ts"

export interface APIOptions {
headers: HeadersInit
redirectTo?: string
}
import type { GetSessionAPIOptions, SessionResponse, SignOutAPIOptions } from "@/@types/index.ts"

export const createAPI = (ctx: GlobalContext) => {
return {
getSession: async ({ headers }: { headers: HeadersInit }): Promise<SessionResponse> => {
const session = await getSession({ ctx, headers })
getSession: async (options: GetSessionAPIOptions): Promise<SessionResponse> => {
const session = await getSession({ ctx, headers: options.headers })
return session
},
signOut: async (options: APIOptions) => {
const redirectTo = validateRedirectTo(options.redirectTo ?? "/")
signOut: async (options: SignOutAPIOptions) => {
const redirectTo = validateRedirectTo(options?.redirectTo ?? "/")
return signOut({ ctx, headers: options.headers, redirectTo, skipCSRFCheck: true })
},
}
Expand Down
7 changes: 3 additions & 4 deletions packages/core/src/api/getSession.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { getCookie } from "@/cookie.ts"
import { getErrorName, toISOString } from "@/utils.ts"
import type { GlobalContext } from "@aura-stack/router/types"
import type { JWTStandardClaims, SessionResponse, User } from "@/@types/index.ts"
import type { FunctionAPIContext, GetSessionAPIOptions, SessionResponse } from "@/@types/index.ts"

export const getSession = async ({ ctx, headers }: { ctx: GlobalContext; headers: HeadersInit }): Promise<SessionResponse> => {
export const getSession = async ({ ctx, headers }: FunctionAPIContext<GetSessionAPIOptions>): Promise<SessionResponse> => {
try {
const session = getCookie(new Headers(headers), ctx.cookies.sessionToken.name)
const decoded = await ctx.jose.decodeJWT(session)
ctx?.logger?.log("AUTH_SESSION_VALID")
const { exp, iat, jti, nbf, ...user } = decoded as User & JWTStandardClaims
const { exp, iat, jti, nbf, aud, iss, ...user } = decoded
return {
session: {
user,
Expand Down
20 changes: 8 additions & 12 deletions packages/core/src/api/signOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ import { verifyCSRF } from "@/secure.ts"
import { getErrorName } from "@/utils.ts"
import { AuthSecurityError } from "@/errors.ts"
import { secureApiHeaders } from "@/headers.ts"
import { HeadersBuilder, type GlobalContext } from "@aura-stack/router"
import { HeadersBuilder } from "@aura-stack/router"
import type { FunctionAPIContext, SignOutAPIOptions } from "@/@types/index.ts"

export type SignOutOptions = {
ctx: GlobalContext
headers: HeadersInit
redirectTo?: string
}

export type SignOutWithCSRFOptions = SignOutOptions & {
skipCSRFCheck?: boolean
}

export const signOut = async ({ ctx, headers: headersInit, redirectTo = "/", skipCSRFCheck = false }: SignOutWithCSRFOptions) => {
export const signOut = async ({
ctx,
headers: headersInit,
redirectTo = "/",
skipCSRFCheck = false,
}: FunctionAPIContext<SignOutAPIOptions>) => {
const headers = new Headers(headersInit)
const header = headers.get("X-CSRF-Token")
let session = null
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { env } from "@/env.ts"
import { parse, parseSetCookie, serialize, type SerializeOptions } from "@aura-stack/router/cookie"
import { AuthInternalError } from "@/errors.ts"
import type { JWTPayload } from "@/jose.ts"
import type { AuthRuntimeConfig, CookieStoreConfig, CookieConfig, InternalLogger } from "@/@types/index.ts"
import type { AuthRuntimeConfig, CookieStoreConfig, CookieConfig, InternalLogger, User } from "@/@types/index.ts"

/**
* Prefix for all cookies set by Aura Auth.
Expand Down Expand Up @@ -118,7 +117,7 @@ export const getSetCookie = (response: Response, cookieName: string) => {
* @param session - The JWT payload to be encoded in the session cookie
* @returns The serialized session cookie string
*/
export const createSessionCookie = async (jose: AuthRuntimeConfig["jose"], session: JWTPayload) => {
export const createSessionCookie = async (jose: AuthRuntimeConfig["jose"], session: User) => {
try {
const encoded = await jose.encodeJWT(session)
return encoded
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
createSecret,
type JWTVerifyOptions,
type DecodedJWTPayloadOptions,
type TypedJWTPayload,
} from "@aura-stack/jose"
import { AuthInternalError } from "@/errors.ts"
export { base64url, type JWTPayload } from "@aura-stack/jose/jose"
export { encoder, getRandomBytes, getSubtleCrypto } from "@aura-stack/jose/crypto"
import type { User } from "@/@types/index.ts"

/**
* Creates the JOSE instance used for signing and verifying tokens. It derives keys
Expand Down Expand Up @@ -52,21 +54,21 @@ export const createJoseInstance = (secret?: string) => {
const derivedCsrfTokenKey = await createDeriveKey(secret, salt, "csrfToken")

return {
jwt: createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }),
jwt: createJWT<User>({ jws: derivedSigningKey, jwe: derivedEncryptionKey }),
jws: createJWS(derivedCsrfTokenKey),
jwe: createJWE(derivedEncryptionKey),
}
})()
jose.catch(() => {})

return {
decodeJWT: async (...args: Parameters<ReturnType<typeof createJWT>["decodeJWT"]>) => {
decodeJWT: async (token: string, options?: DecodedJWTPayloadOptions) => {
const { jwt } = await jose
return jwt.decodeJWT(...args)
return jwt.decodeJWT(token, options)
},
encodeJWT: async (...args: Parameters<ReturnType<typeof createJWT>["encodeJWT"]>) => {
encodeJWT: async (payload: TypedJWTPayload<Partial<User>>) => {
const { jwt } = await jose
return jwt.encodeJWT(...args)
return jwt.encodeJWT(payload)
Comment thread
halvaradop marked this conversation as resolved.
},
signJWS: async (...args: Parameters<ReturnType<typeof createJWS>["signJWS"]>) => {
const { jws } = await jose
Expand Down
4 changes: 4 additions & 0 deletions packages/jose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- Introduced type-safe payloads in `createJWT`, `encodeJWT`, `decodeJWT`, `createJWS`, `signJWS` and `verifyJWS` to provide stronger typing for JWT encoding and decoding, including improved autocompletion for payload attributes during creation and verification. [#116](https://github.com/aura-stack-ts/auth/pull/116)

---

## [0.3.0] - 2026-02-16
Expand Down
25 changes: 18 additions & 7 deletions packages/jose/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createJWS } from "@/sign.ts"
import { getSecrets } from "@/secret.ts"
import { createJWE } from "@/encrypt.ts"
import { isAuraJoseError } from "@/assert.ts"
import { JWTDecodingError, JWTEncodingError } from "./errors.ts"
import { JWTDecodingError, JWTEncodingError } from "@/errors.ts"

export * from "@/sign.ts"
export * from "@/encrypt.ts"
Expand All @@ -23,6 +23,8 @@ export * from "@/crypto.ts"
export type SecretInput = Uint8Array | string | CryptoKey
export type DerivedKeyInput = { jws: SecretInput; jwe: SecretInput }
export type DecodedJWTPayloadOptions = { jws: JWTVerifyOptions; jwt: JWTDecryptOptions }
export type Prettify<T> = { [K in keyof T]: T[K] } & {}
export type TypedJWTPayload<Payload extends JWTPayload> = JWTPayload & Payload

/**
* Encode a JWT signed and encrypted token. The token first signed using JWS
Expand All @@ -38,7 +40,10 @@ export type DecodedJWTPayloadOptions = { jws: JWTVerifyOptions; jwt: JWTDecryptO
* @param secret - Secret key used for both signing and encrypting the JWT
* @returns Promise resolving to the signed and encrypted JWT string
*/
export const encodeJWT = async (token: JWTPayload, secret: SecretInput | DerivedKeyInput) => {
export const encodeJWT = async <Payload extends JWTPayload>(
token: TypedJWTPayload<Partial<Payload>>,
secret: SecretInput | DerivedKeyInput
) => {
try {
const { jweSecret, jwsSecret } = getSecrets(secret)
const { signJWS } = createJWS(jwsSecret)
Expand All @@ -65,10 +70,14 @@ export const encodeJWT = async (token: JWTPayload, secret: SecretInput | Derived
* @param secret
* @returns
*/
export const decodeJWT = async (token: string, secret: SecretInput | DerivedKeyInput, options?: DecodedJWTPayloadOptions) => {
export const decodeJWT = async <Payload extends JWTPayload>(
token: string,
secret: SecretInput | DerivedKeyInput,
options?: DecodedJWTPayloadOptions
): Promise<TypedJWTPayload<Payload>> => {
try {
const { jweSecret, jwsSecret } = getSecrets(secret)
const { verifyJWS } = createJWS(jwsSecret)
const { verifyJWS } = createJWS<Payload>(jwsSecret)
const { decryptJWE } = createJWE(jweSecret)
const decrypted = await decryptJWE(token, options?.jwt)
return await verifyJWS(decrypted, options?.jws)
Expand All @@ -88,9 +97,11 @@ export const decodeJWT = async (token: string, secret: SecretInput | DerivedKeyI
* @param secret - Secret key used for signing, verifying, encrypting and decrypting the JWT
* @returns JWT handler object with `signJWS/encryptJWE` and `verifyJWS/decryptJWE` methods
*/
export const createJWT = (secret: SecretInput | DerivedKeyInput) => {
export const createJWT = <Payload extends JWTPayload>(secret: SecretInput | DerivedKeyInput) => {
return {
encodeJWT: async (payload: JWTPayload) => await encodeJWT(payload, secret),
decodeJWT: async (token: string) => await decodeJWT(token, secret),
encodeJWT: async <EncodePayload extends JWTPayload = Payload>(payload: TypedJWTPayload<Partial<EncodePayload>>) =>
await encodeJWT<EncodePayload>(payload, secret),
decodeJWT: async <DecodePayload extends JWTPayload = Payload>(token: string, options?: DecodedJWTPayloadOptions) =>
await decodeJWT<DecodePayload>(token, secret, options),
}
}
1 change: 1 addition & 0 deletions packages/jose/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
* @module @aura-stack/jose/jose
*/
export * from "jose"
export type * from "jose"
23 changes: 16 additions & 7 deletions packages/jose/src/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createSecret } from "@/secret.ts"
import { getRandomBytes } from "@/crypto.ts"
import { isAuraJoseError, isFalsy, isInvalidPayload } from "@/assert.ts"
import { JWSSigningError, JWSVerificationError, InvalidPayloadError } from "@/errors.ts"
import type { SecretInput } from "@/index.ts"
import type { SecretInput, TypedJWTPayload } from "@/index.ts"

export type { JWTVerifyOptions } from "jose"

Expand All @@ -20,7 +20,10 @@ export type { JWTVerifyOptions } from "jose"
* @param secret - Secret key to sign the JWT (CryptoKey, KeyObject, string or Uint8Array)
* @returns Signed JWT string
*/
export const signJWS = async (payload: JWTPayload, secret: SecretInput): Promise<string> => {
export const signJWS = async <Payload extends JWTPayload>(
payload: TypedJWTPayload<Partial<Payload>>,
secret: SecretInput
): Promise<string> => {
try {
if (isInvalidPayload(payload)) {
throw new InvalidPayloadError("The payload must be a non-empty object")
Expand Down Expand Up @@ -53,14 +56,18 @@ export const signJWS = async (payload: JWTPayload, secret: SecretInput): Promise
* @param options - Additional JWT verification options
* @returns verify and return the payload of the JWT
*/
export const verifyJWS = async (token: string, secret: SecretInput, options?: JWTVerifyOptions): Promise<JWTPayload> => {
export const verifyJWS = async <Payload extends JWTPayload>(
token: string,
secret: SecretInput,
options?: JWTVerifyOptions
): Promise<TypedJWTPayload<Payload>> => {
try {
if (isFalsy(token)) {
throw new InvalidPayloadError("The token must be a non-empty string")
}
const secretKey = createSecret(secret)
const { payload } = await jwtVerify(token, secretKey, options)
return payload
return payload as TypedJWTPayload<Payload>
} catch (error) {
if (isAuraJoseError(error)) {
throw error
Expand All @@ -76,9 +83,11 @@ export const verifyJWS = async (token: string, secret: SecretInput, options?: JW
* @param secret - Secret key used for signing and verifying the JWS
* @returns signJWS and verifyJWS functions
*/
export const createJWS = (secret: SecretInput) => {
export const createJWS = <Payload extends JWTPayload>(secret: SecretInput) => {
return {
signJWS: (payload: JWTPayload) => signJWS(payload, secret),
verifyJWS: (payload: string, options?: JWTVerifyOptions) => verifyJWS(payload, secret, options),
signJWS: <SignPayload extends JWTPayload = Payload>(payload: TypedJWTPayload<Partial<SignPayload>>) =>
signJWS(payload, secret),
verifyJWS: <VerifyPayload extends JWTPayload = Payload>(payload: string, options?: JWTVerifyOptions) =>
verifyJWS<VerifyPayload>(payload, secret, options),
}
}
Loading
Loading