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
2 changes: 2 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Extended asymmetric cryptography support to accept JWK (JSON Web Key) format keys in addition to `CryptoKeyPair` across JOSE functions exposed by `createAuth.jose`, including the dedicated `signJWS`, `verifyJWS`, `encryptJWE`, `decryptJWE`, `encodeJWT`, and `decodeJWT` functions. [#159](https://github.com/aura-stack-ts/auth/pull/159)

- Added support for asymmetric cryptography using `public/private` key pairs via `CryptoKeyPair` across JOSE functions exposed by `createAuth.jose`, including the dedicated `signJWS`, `verifyJWS`, `encryptJWE`, `decryptJWE`, `encodeJWT`, and `decodeJWT` functions. [#157](https://github.com/aura-stack-ts/auth/pull/157)

- Added the `Dribbble` OAuth provider to the supported integrations in Aura Auth. [#153](https://github.com/aura-stack-ts/auth/pull/153)
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { TypedJWTPayload } from "@aura-stack/jose"
import type { UserIdentity, UserShape } from "@/shared/identity.ts"
import type { DeepPartial, EditableShape, Prettify, ZodShapeToObject } from "@/@types/utility.ts"
import type { CookieStoreConfig, IdentityConfig, InternalLogger, JoseInstance } from "@/@types/config.ts"
import type { JWK } from "@aura-stack/jose/jose"

/** Application user type, inferred from the configured identity schema (defaults to the built-in user shape). */
export type User = Infer<typeof UserIdentity>
Expand All @@ -18,23 +19,28 @@ export interface Session<DefaultUser extends User = User> {
}

export interface CryptoSecret {
sign: CryptoKey | CryptoKeyPair
encrypt: CryptoKey | CryptoKeyPair
sign: CryptoKey | CryptoKeyPair | JWK | JsonWebKey | AsymmetricKeyPair
encrypt: CryptoKey | CryptoKeyPair | JWK | JsonWebKey | AsymmetricKeyPair
}

export interface AsymmetricKeyPairFromEnv {
publicKey: string
privateKey: string
}

export interface AsymmetricKeyPair {
publicKey: CryptoKey | JWK
privateKey: CryptoKey | JWK
}

/**
* A symmetric secret or asymmetric key pair used for JWT operations.
*
* - string / Uint8Array: used as-is for HMAC (signed) or AES (encrypted)
* - CryptoKey: Web Crypto API key, for environments that support it
* - CryptoKeyPair: asymmetric signing/encryption (RS256, ES256, EdDSA, RSA-OAEP, etc.)
*/
export type SecretKey = string | Uint8Array | CryptoKey | CryptoKeyPair | CryptoSecret
export type SecretKey = string | Uint8Array | CryptoKey | CryptoKeyPair | CryptoSecret | JWK | AsymmetricKeyPair

/**
* @todo: add key rotation support for "SecretKey | CryptoKeyPair | [SecretKey | CryptoKeyPair, ...(SecretKey | CryptoKeyPair)[]]"
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isCryptoSecret,
isEncryptedMode,
isJWTPEMFormattedKeyPair,
isKeyPair,
isPEMFormattedKeyPairFromEnv,
isSealedMode,
isSignedMode,
Expand Down Expand Up @@ -187,7 +188,7 @@ const getSecrets = async (
},
}
}
if (isCryptoKey(secret) || isCryptoKeyPair(secret)) {
if (isCryptoKey(secret) || isCryptoKeyPair(secret) || isKeyPair(secret)) {
return {
jwsSecret: secret,
jweSecret: secret,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/shared/assert.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { equals, patternToRegex } from "@/shared/utils.ts"
import type {
AsymmetricKeyPair,
AsymmetricKeyPairFromEnv,
CryptoSecret,
JWTConfig,
JWTMode,
JWTPayloadWithToken,
SessionConfig,
} from "@/@types/index.ts"
import type { JWK } from "@aura-stack/jose/jose"

export const isFalsy = (value: unknown): boolean => {
return value === false || value === 0 || value === "" || value === null || value === undefined || Number.isNaN(value)
Expand Down Expand Up @@ -125,6 +127,10 @@ export const isCryptoKey = (value: unknown): value is CryptoKey => {
return typeof value === "object" && value !== null && "algorithm" in value && "extractable" in value
}

export const isKeyPair = (value: unknown): value is AsymmetricKeyPair => {
return typeof value === "object" && value !== null && "publicKey" in value && "privateKey" in value
}

export const isCryptoSecret = (value: unknown): value is CryptoSecret => {
return (
typeof value === "object" &&
Expand Down Expand Up @@ -163,3 +169,7 @@ export const isJWTPEMFormattedKeyPair = (
isPEMFormattedKeyPairFromEnv((value as any).encrypt)
)
}

export const isJWKFormattedKey = (value: unknown): value is JWK => {
return typeof value === "object" && value !== null && "kty" in value && typeof (value as any).kty === "string"
}
19 changes: 18 additions & 1 deletion packages/core/src/shared/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AuthSecurityError } from "@/shared/errors.ts"
import { isJWTPayloadWithToken } from "@/shared/assert.ts"
import { equals, timingSafeEqual } from "@/shared/utils.ts"
import { importPKCS8, importSPKI } from "@aura-stack/jose/jose"
import { exportJWK, generateKeyPair, GenerateKeyPairOptions, importPKCS8, importSPKI } from "@aura-stack/jose/jose"
import { base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.ts"
import type { AsymmetricKeyPairFromEnv, AuthRuntimeConfig, JoseInstance, User } from "@/@types/index.ts"

Expand Down Expand Up @@ -151,3 +151,20 @@ export const importPEMKeyPair = async (key: AsymmetricKeyPairFromEnv, algorithm:
privateKey: importedPrivateKey,
}
}

/**
* Generates a new asymmetric key pair and exports it in JWK format.
*
* @param alg - The intended algorithm for the keys (e.g. "RS256" for RSA signing, "RSA-OAEP" for RSA encryption)
* @param options - Optional parameters for key generation (e.g. modulusLength for RSA)
* @returns A Promise that resolves to an object containing the public and private keys in JWK format
*/
export const exportJWKKeyPair = async (alg: string, options?: GenerateKeyPairOptions) => {
const { publicKey, privateKey } = await generateKeyPair(alg, options)
const jwkPublicKey = await exportJWK(publicKey)
const jwkPrivateKey = await exportJWK(privateKey)
return {
publicKey: jwkPublicKey,
privateKey: jwkPrivateKey,
}
}
65 changes: 64 additions & 1 deletion packages/core/test/jose.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import { createJoseInstance, encoder } from "@/jose.ts"
import { createSecretValue } from "@/shared/crypto.ts"
import { createSecretValue, exportJWKKeyPair } from "@/shared/crypto.ts"
import { createAuth } from "@/createAuth.ts"
import { generateKeyPair } from "@aura-stack/jose/jose"
import { RS256PEMFormat, RSAOAEP256PEMFormat } from "./presets.ts"
Expand Down Expand Up @@ -618,4 +618,67 @@ describe("createJoseInstance", () => {
/Multiples PEM Key Pairs from environment variables require 'sealed' JWT mode. For 'signed' or 'encrypted' modes, provide a single PEM key pair or a combined key object./
)
})

test("JWS (signed) with JWK formatted keys", async () => {
vi.stubEnv("AURA_AUTH_SALT", createSecretValue(32))

const secret = await exportJWKKeyPair("RS256", { extractable: true })
const jose = createJoseInstance(secret, {
jwt: {
signingAlgorithm: "RS256",
},
})

const signed = await jose.signJWS(payload)
const verified = await jose.verifyJWS(signed)
expect(verified).toMatchObject(payload)
})

test("JWE (encrypted) with JWK formatted keys", async () => {
vi.stubEnv("AURA_AUTH_SALT", createSecretValue(32))

const secret = await exportJWKKeyPair("RSA-OAEP-256", { extractable: true })
const jose = createJoseInstance(secret, {
jwt: {
mode: "encrypted",
keyAlgorithm: "RSA-OAEP-256",
encryptionAlgorithm: "A256GCM",
},
})
const encrypted = await jose.encryptJWE(payload)
const decrypted = await jose.decryptJWE(encrypted)
expect(decrypted).toMatchObject(payload)
})

test("JWT (sealed) with JWK formatted keys", async () => {
vi.stubEnv("AURA_AUTH_SALT", createSecretValue(32))

const signingKeyPair = await exportJWKKeyPair("RS256", { extractable: true })
const encryptionKeyPair = await exportJWKKeyPair("RSA-OAEP-256", { extractable: true })
const jose = createJoseInstance(
{
sign: signingKeyPair,
encrypt: encryptionKeyPair,
},
{
jwt: {
signingAlgorithm: "RS256",
keyAlgorithm: "RSA-OAEP-256",
encryptionAlgorithm: "A256GCM",
},
}
)

const encoded = await jose.encodeJWT(payload)
const decoded = await jose.decodeJWT(encoded)
expect(decoded).toMatchObject(payload)

const signed = await jose.signJWS(payload)
const verified = await jose.verifyJWS(signed)
expect(verified).toMatchObject(payload)

const encrypted = await jose.encryptJWE(payload)
const decrypted = await jose.decryptJWE(encrypted)
expect(decrypted).toMatchObject(payload)
})
})
2 changes: 2 additions & 0 deletions packages/jose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Extended asymmetric cryptography support to accept JWK (JSON Web Key) format keys in addition to `CryptoKeyPair` across JOSE functions, including the dedicated `signJWS`, `verifyJWS`, `encryptJWE`, `decryptJWE`, `encryptCompactJWE`, `decryptCompactJWE`, `encodeJWT`, and `decodeJWT` functions, as well as the factory functions `createJWS`, `createJWE`, and `createJWT`. [#159](https://github.com/aura-stack-ts/auth/pull/159)

- Added support for asymmetric cryptography using `public/private` key pairs via `CryptoKeyPair` across JOSE functions, including the dedicated `signJWS`, `verifyJWS`, `encryptJWE`, `decryptJWE`, `encryptCompactJWE`, `decryptCompactJWE`, `encodeJWT`, and `decodeJWT` functions, as well as the factory functions `createJWS`, `createJWE`, and `createJWT`. [#157](https://github.com/aura-stack-ts/auth/pull/157)

## [0.5.0] - 2026-04-21
Expand Down
7 changes: 6 additions & 1 deletion packages/jose/src/assert.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AuraJoseError, InvalidSecretError } from "@/errors.ts"
import type { AsymmetricKeyPair } from "@/index.ts"

export const isAuraJoseError = (error: unknown): error is AuraJoseError => {
return error instanceof AuraJoseError
Expand All @@ -24,6 +25,10 @@ export const isInvalidPayload = (payload: unknown): boolean => {
)
}

export const isCryptoKeyPair = (value: unknown): value is CryptoKeyPair => {
export const isAsymmetricKeyPair = (value: unknown): value is AsymmetricKeyPair => {
return typeof value === "object" && value !== null && "publicKey" in value && "privateKey" in value
}

export const isJWKKey = (value: unknown): value is JsonWebKey => {
return typeof value === "object" && value !== null && "kty" in value && typeof (value as JsonWebKey).kty === "string"
}
Comment thread
halvaradop marked this conversation as resolved.
7 changes: 4 additions & 3 deletions packages/jose/src/deriveKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createSecret } from "@/secret.ts"
import { KeyDerivationError } from "@/errors.ts"
import { encoder, getSubtleCrypto } from "@/crypto.ts"
import { SecretInput } from "./index.ts"
import { isJWKKey } from "./assert.ts"

/**
* Generate a derived key using HKDF (HMAC-based Extract-and-Expand Key Derivation Function)
Expand Down Expand Up @@ -62,9 +63,9 @@ export const createDeriveKey = async (
info?: string | Uint8Array,
length: number = 32
) => {
const secretKey = createSecret(secret)
if (secretKey instanceof CryptoKey) {
throw new KeyDerivationError("Cannot derive key from CryptoKey. Use Uint8Array or string secret instead.")
const secretKey = createSecret(secret) as Uint8Array<ArrayBufferLike>
if (secretKey instanceof CryptoKey || isJWKKey(secretKey)) {
throw new KeyDerivationError("Cannot derive key from CryptoKey or JWK. Use Uint8Array or string secret instead.")
}
const key = await deriveKey(secretKey, salt ?? "Aura Jose secret salt", info ?? "Aura Jose secret derivation", length)
return key
Expand Down
16 changes: 8 additions & 8 deletions packages/jose/src/encrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
} from "jose"
import { createSecret } from "@/secret.ts"
import { decoder, encoder, getRandomBytes } from "@/crypto.ts"
import { isAuraJoseError, isCryptoKeyPair, isFalsy } from "@/assert.ts"
import { isAuraJoseError, isAsymmetricKeyPair, isFalsy } from "@/assert.ts"
import { InvalidPayloadError, JWEDecryptionError, JWEEncryptionError } from "@/errors.ts"
import type { SecretInput, TypedJWTPayload } from "@/index.ts"
import type { JWTSecretInput, SecretInput, TypedJWTPayload } from "@/index.ts"

export type { JWTDecryptOptions, JWEHeaderParameters, DecryptOptions } from "jose"

Expand Down Expand Up @@ -148,9 +148,9 @@ export const decryptCompactJWE = async (token: string, secret: SecretInput, opti
* @param secret - Secret key used for encrypting and decrypting the JWE
* @returns encryptJWE and decryptJWE functions
*/
export const createJWE = <Payload extends JWTPayload>(secret: SecretInput | CryptoKeyPair) => {
const encryptSecret = isCryptoKeyPair(secret) ? secret.publicKey : secret
const decryptSecret = isCryptoKeyPair(secret) ? secret.privateKey : secret
export const createJWE = <Payload extends JWTPayload>(secret: JWTSecretInput) => {
const encryptSecret = isAsymmetricKeyPair(secret) ? secret.publicKey : secret
const decryptSecret = isAsymmetricKeyPair(secret) ? secret.privateKey : secret

return {
encryptJWE: <Encrypted extends JWTPayload = Payload>(
Expand All @@ -168,9 +168,9 @@ export const createJWE = <Payload extends JWTPayload>(secret: SecretInput | Cryp
* @param secret - Secret key used for encrypting and decrypting the JWE
* @returns compactEncryptJWE and decryptCompactJWE functions
*/
export const createCompactJWE = (secret: SecretInput | CryptoKeyPair) => {
const encryptSecret = isCryptoKeyPair(secret) ? secret.publicKey : secret
const decryptSecret = isCryptoKeyPair(secret) ? secret.privateKey : secret
export const createCompactJWE = (secret: JWTSecretInput) => {
const encryptSecret = isAsymmetricKeyPair(secret) ? secret.publicKey : secret
const decryptSecret = isAsymmetricKeyPair(secret) ? secret.privateKey : secret

return {
compactEncryptJWE: (payload: string, options?: JWEHeaderParameters) => compactEncryptJWE(payload, encryptSecret, options),
Expand Down
11 changes: 8 additions & 3 deletions packages/jose/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @module @aura-stack/jose
*/
import type { DecryptOptions, JWEHeaderParameters, JWTHeaderParameters, JWTPayload, JWTVerifyOptions } from "jose"
import type { DecryptOptions, JWEHeaderParameters, JWK, JWTHeaderParameters, JWTPayload, JWTVerifyOptions } from "jose"
import { getSecrets } from "@/secret.ts"
import { signJWS, verifyJWS } from "@/sign.ts"
import { isAuraJoseError } from "@/assert.ts"
Expand All @@ -19,14 +19,19 @@ export type * from "@/secret.ts"
export * from "@/crypto.ts"
export type * from "@/crypto.ts"

export interface AsymmetricKeyPair {
publicKey: CryptoKey | JWK | JsonWebKey
privateKey: CryptoKey | JWK | JsonWebKey
}

/**
* Secret input can be:
* - CryptoKey: W3C standard key object (works across all runtimes)
* - Uint8Array: Raw bytes
* - string: String that will be encoded to UTF-8
*/
export type SecretInput = Uint8Array | string | CryptoKey
export type JWTSecretInput = SecretInput | CryptoKeyPair
export type SecretInput = Uint8Array | string | CryptoKey | JWK
export type JWTSecretInput = SecretInput | AsymmetricKeyPair
export type DerivedKeyInput = { sign: JWTSecretInput; encrypt: JWTSecretInput }
export type Prettify<T> = { [K in keyof T]: T[K] } & {}
export type TypedJWTPayload<Payload extends JWTPayload> = JWTPayload & Payload
Expand Down
10 changes: 5 additions & 5 deletions packages/jose/src/secret.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isCryptoKeyPair, isObject } from "@/assert.ts"
import { isAsymmetricKeyPair, isJWKKey, isObject } from "@/assert.ts"
import { InvalidSecretError } from "@/errors.ts"
import { encoder } from "@/crypto.ts"
import type { DerivedKeyInput, JWTSecretInput, SecretInput } from "@/index.ts"
Expand Down Expand Up @@ -45,17 +45,17 @@ export const createSecret = (secret: SecretInput, length: number = 32) => {
}
return encoded
}
if (secret instanceof CryptoKey || secret instanceof Uint8Array) {
if (secret instanceof CryptoKey || secret instanceof Uint8Array || isJWKKey(secret)) {
if (secret instanceof Uint8Array && secret.byteLength < length) {
throw new InvalidSecretError(`Secret must be at least ${length} bytes long`)
}
return secret
}
throw new InvalidSecretError("Secret must be a string, Uint8Array, or CryptoKey")
throw new InvalidSecretError("Secret must be a string, Uint8Array, CryptoKey or JWK")
}

const getJWSSecrets = (secret: JWTSecretInput) => {
if (!isCryptoKeyPair(secret)) {
if (!isAsymmetricKeyPair(secret)) {
return {
encode: secret,
decode: secret,
Expand All @@ -69,7 +69,7 @@ const getJWSSecrets = (secret: JWTSecretInput) => {
}

const getJWESecrets = (secret: JWTSecretInput) => {
if (!isCryptoKeyPair(secret)) {
if (!isAsymmetricKeyPair(secret)) {
return {
encode: secret,
decode: secret,
Expand Down
10 changes: 5 additions & 5 deletions packages/jose/src/sign.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { base64url, jwtVerify, SignJWT, type JWTPayload, type JWTVerifyOptions, type JWTHeaderParameters } from "jose"
import { createSecret } from "@/secret.ts"
import { getRandomBytes } from "@/crypto.ts"
import { isAuraJoseError, isCryptoKeyPair, isFalsy, isInvalidPayload } from "@/assert.ts"
import { isAsymmetricKeyPair, isAuraJoseError, isFalsy, isInvalidPayload } from "@/assert.ts"
import { JWSSigningError, JWSVerificationError, InvalidPayloadError } from "@/errors.ts"
import type { SecretInput, TypedJWTPayload } from "@/index.ts"
import type { JWTSecretInput, SecretInput, TypedJWTPayload } from "@/index.ts"

export type { JWTVerifyOptions, JWTHeaderParameters } from "jose"

Expand Down Expand Up @@ -90,9 +90,9 @@ export const verifyJWS = async <Payload extends JWTPayload>(
* @param options - Optional signing options (e.g. algorithm)
* @returns signJWS and verifyJWS functions
*/
export const createJWS = <Payload extends JWTPayload>(secret: SecretInput | CryptoKeyPair) => {
const signSecret = isCryptoKeyPair(secret) ? secret.privateKey : secret
const verifySecret = isCryptoKeyPair(secret) ? secret.publicKey : secret
export const createJWS = <Payload extends JWTPayload>(secret: JWTSecretInput) => {
const signSecret = isAsymmetricKeyPair(secret) ? secret.privateKey : secret
const verifySecret = isAsymmetricKeyPair(secret) ? secret.publicKey : secret
return {
signJWS: <SignPayload extends JWTPayload = Payload>(
payload: TypedJWTPayload<Partial<SignPayload>>,
Expand Down
Loading
Loading