diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 689c9b52..fdc4dad7 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -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 { sub: string name?: string | null email?: string | null @@ -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 = { + ctx: RouterGlobalContext +} & Options diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index fdf47056..9278ba22 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -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) => { @@ -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) logger?.log("OAUTH_CALLBACK_SUCCESS", { diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index 365dc19d..4bda9195 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -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 => { - const session = await getSession({ ctx, headers }) + getSession: async (options: GetSessionAPIOptions): Promise => { + 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 }) }, } diff --git a/packages/core/src/api/getSession.ts b/packages/core/src/api/getSession.ts index 9033da62..63bad818 100644 --- a/packages/core/src/api/getSession.ts +++ b/packages/core/src/api/getSession.ts @@ -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 => { +export const getSession = async ({ ctx, headers }: FunctionAPIContext): Promise => { 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, diff --git a/packages/core/src/api/signOut.ts b/packages/core/src/api/signOut.ts index fd53f08d..5378fb23 100644 --- a/packages/core/src/api/signOut.ts +++ b/packages/core/src/api/signOut.ts @@ -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) => { const headers = new Headers(headersInit) const header = headers.get("X-CSRF-Token") let session = null diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index acae273c..a8c29f72 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -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. @@ -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 diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index c2b34d7a..bed51d0f 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -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 @@ -52,7 +54,7 @@ export const createJoseInstance = (secret?: string) => { const derivedCsrfTokenKey = await createDeriveKey(secret, salt, "csrfToken") return { - jwt: createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }), + jwt: createJWT({ jws: derivedSigningKey, jwe: derivedEncryptionKey }), jws: createJWS(derivedCsrfTokenKey), jwe: createJWE(derivedEncryptionKey), } @@ -60,13 +62,13 @@ export const createJoseInstance = (secret?: string) => { jose.catch(() => {}) return { - decodeJWT: async (...args: Parameters["decodeJWT"]>) => { + decodeJWT: async (token: string, options?: DecodedJWTPayloadOptions) => { const { jwt } = await jose - return jwt.decodeJWT(...args) + return jwt.decodeJWT(token, options) }, - encodeJWT: async (...args: Parameters["encodeJWT"]>) => { + encodeJWT: async (payload: TypedJWTPayload>) => { const { jwt } = await jose - return jwt.encodeJWT(...args) + return jwt.encodeJWT(payload) }, signJWS: async (...args: Parameters["signJWS"]>) => { const { jws } = await jose diff --git a/packages/jose/CHANGELOG.md b/packages/jose/CHANGELOG.md index def8f397..0f6ed6ea 100644 --- a/packages/jose/CHANGELOG.md +++ b/packages/jose/CHANGELOG.md @@ -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 diff --git a/packages/jose/src/index.ts b/packages/jose/src/index.ts index ca5dd78c..6baa4663 100644 --- a/packages/jose/src/index.ts +++ b/packages/jose/src/index.ts @@ -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" @@ -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 = { [K in keyof T]: T[K] } & {} +export type TypedJWTPayload = JWTPayload & Payload /** * Encode a JWT signed and encrypted token. The token first signed using JWS @@ -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 ( + token: TypedJWTPayload>, + secret: SecretInput | DerivedKeyInput +) => { try { const { jweSecret, jwsSecret } = getSecrets(secret) const { signJWS } = createJWS(jwsSecret) @@ -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 ( + token: string, + secret: SecretInput | DerivedKeyInput, + options?: DecodedJWTPayloadOptions +): Promise> => { try { const { jweSecret, jwsSecret } = getSecrets(secret) - const { verifyJWS } = createJWS(jwsSecret) + const { verifyJWS } = createJWS(jwsSecret) const { decryptJWE } = createJWE(jweSecret) const decrypted = await decryptJWE(token, options?.jwt) return await verifyJWS(decrypted, options?.jws) @@ -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 = (secret: SecretInput | DerivedKeyInput) => { return { - encodeJWT: async (payload: JWTPayload) => await encodeJWT(payload, secret), - decodeJWT: async (token: string) => await decodeJWT(token, secret), + encodeJWT: async (payload: TypedJWTPayload>) => + await encodeJWT(payload, secret), + decodeJWT: async (token: string, options?: DecodedJWTPayloadOptions) => + await decodeJWT(token, secret, options), } } diff --git a/packages/jose/src/jose.ts b/packages/jose/src/jose.ts index f2b2ccfa..deb44766 100644 --- a/packages/jose/src/jose.ts +++ b/packages/jose/src/jose.ts @@ -3,3 +3,4 @@ * @module @aura-stack/jose/jose */ export * from "jose" +export type * from "jose" diff --git a/packages/jose/src/sign.ts b/packages/jose/src/sign.ts index 7072e63d..ad468a7f 100644 --- a/packages/jose/src/sign.ts +++ b/packages/jose/src/sign.ts @@ -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" @@ -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 => { +export const signJWS = async ( + payload: TypedJWTPayload>, + secret: SecretInput +): Promise => { try { if (isInvalidPayload(payload)) { throw new InvalidPayloadError("The payload must be a non-empty object") @@ -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 => { +export const verifyJWS = async ( + token: string, + secret: SecretInput, + options?: JWTVerifyOptions +): Promise> => { 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 } catch (error) { if (isAuraJoseError(error)) { throw error @@ -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 = (secret: SecretInput) => { return { - signJWS: (payload: JWTPayload) => signJWS(payload, secret), - verifyJWS: (payload: string, options?: JWTVerifyOptions) => verifyJWS(payload, secret, options), + signJWS: (payload: TypedJWTPayload>) => + signJWS(payload, secret), + verifyJWS: (payload: string, options?: JWTVerifyOptions) => + verifyJWS(payload, secret, options), } } diff --git a/packages/jose/test/types.test-d.ts b/packages/jose/test/types.test-d.ts new file mode 100644 index 00000000..a76d4c67 --- /dev/null +++ b/packages/jose/test/types.test-d.ts @@ -0,0 +1,108 @@ +import { describe, expectTypeOf, test } from "vitest" +import type { JWTPayload, JWTVerifyOptions } from "jose" +import { + createJWS, + createJWT, + decodeJWT, + encodeJWT, + signJWS, + verifyJWS, + type SecretInput, + type DecodedJWTPayloadOptions, + type TypedJWTPayload, + type DerivedKeyInput, +} from "@/index.ts" + +interface User extends Record { + sub: string + name?: string + image?: string +} + +const payload: JWTPayload = { + sub: "1234567890", + name: "Alice", + image: "https://example.com/avatar.png", +} + +describe("type-safe payload", () => { + test("createJWT", async () => { + const jwt = createJWT("secret") + expectTypeOf(jwt.encodeJWT).toEqualTypeOf< + (payload: TypedJWTPayload>) => Promise + >() + expectTypeOf(jwt.decodeJWT).toEqualTypeOf< + ( + token: string, + options?: DecodedJWTPayloadOptions + ) => Promise> + >() + + const encoded = await jwt.encodeJWT({}) + expectTypeOf(encoded).toEqualTypeOf() + const decoded = await jwt.decodeJWT(encoded) + expectTypeOf(decoded).toEqualTypeOf>() + }) + + test("encodeJWT", async () => { + const encoded = await encodeJWT(payload, "secret") + + expectTypeOf(encoded).toEqualTypeOf() + expectTypeOf(encodeJWT) + .parameter(0) + .toEqualTypeOf>>() + expectTypeOf(encodeJWT) + .parameter(1) + .toEqualTypeOf() + }) + + test("decodeJWT", async () => { + const decoded = await decodeJWT("token", "secret") + expectTypeOf(decoded).toEqualTypeOf>() + expectTypeOf(decodeJWT) + .parameter(0) + .toEqualTypeOf() + expectTypeOf(decodeJWT) + .parameter(1) + .toEqualTypeOf() + expectTypeOf(decodeJWT) + .parameter(2) + .toEqualTypeOf() + }) + + test("createJWS", async () => { + const jws = createJWS("secret") + expectTypeOf(jws.signJWS).toEqualTypeOf< + (payload: TypedJWTPayload>) => Promise + >() + expectTypeOf(jws.verifyJWS).toEqualTypeOf< + ( + payload: string, + options?: JWTVerifyOptions + ) => Promise> + >() + + const signed = await jws.signJWS(payload) + expectTypeOf(signed).toEqualTypeOf() + const verified = await jws.verifyJWS(signed) + expectTypeOf(verified).toEqualTypeOf>() + }) + + test("signJWS", async () => { + const signed = await signJWS(payload, "secret") + expectTypeOf(signed).toEqualTypeOf() + expectTypeOf(signJWS) + .parameter(0) + .toEqualTypeOf>>() + expectTypeOf(signJWS) + .parameter(1) + .toEqualTypeOf() + }) + + test("verifyJWS", async () => { + const verified = await verifyJWS("token", "secret") + expectTypeOf(verified).toEqualTypeOf>() + expectTypeOf(verifyJWS).parameter(0).toEqualTypeOf() + expectTypeOf(verifyJWS).parameter(1).toEqualTypeOf() + }) +})