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
32 changes: 31 additions & 1 deletion docs/src/content/docs/api-reference/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,39 @@ type HostCookie = {
Secret value used to sign and encrypt JWTs for session management and for CSRF token protection. This configuration overrides the `AURA_AUTH_SECRET` or `AUTH_SECRET` environment variable which is automatically loaded by Aura Auth. If neither option is configured, Aura Auth will throw an error, as this option is required for session management and CSRF protection.

```ts lineNumbers
type Secret = string
type JWTKey = string | Uint8Array | CryptoKey | CryptoKeyPair | CryptoSecret
```

###### CryptoKey and CryptoKeyPair Support [!toc]

Aura Auth supports using `CryptoKey` and `CryptoKeyPair` objects for signing and encryption operations, providing enhanced security by leveraging the Web Crypto API. This allows for the use of asymmetric keys (e.g., RSA, ECDSA) in addition to symmetric secrets. When using `CryptoKeyPair`, you can specify separate keys for signing and encryption, ensuring that the same key is not used for both operations.

```ts lineNumbers
import { createAuth } from "@aura-stack/auth"
import { createKeyPair } from "@aura-stack/auth/crypto"

const signKeyPair = await createKeyPair("RS256", { extractable: true })
const encryptKeyPair = await createKeyPair("RSA-OAEP-256", { extractable: true })

export const auth = createAuth({
oauth: [],
secret: {
sign: signKeyPair,
encrypt: encryptKeyPair,
},
})
```

<Callout type="warning">
This configuration option is advanced and is related to the JWT mode set in the Auth Instance configuration. By default the
`jwt.mode` is `sealed` (sign + encrypt) and for this mode is required to pass different keys for signing and encryption, so, its
needed to pass a `CryptoSecret` object with the `sign` and `encrypt` fields, if its provided the same secret key, it's possible
that the JOSE operations throw errors notifying that the secret keys must be different. On the other hand, if the JWT mode is
`signed` (sign only) or `encrypted` (encrypt only), its possible to pass the `CryptoKey` or `CryptoKeyPair` objects directly.
Ensure you understand the usage of `CryptoKey` and `CryptoKeyPair` in the context of JOSE operations before implementing this
option.
</Callout>

##### `basePath`

Base path used for all authentication routes, including `/signIn/:oauth`, `/session`, etc. This base path defines where the handlers are located in the framework's backend. By default, it is `/auth`.
Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/configuration/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
openssl rand -base64 32
```

Aura Auth also supports `CryptoKey`, `CryptoKeyPair`, and a `CryptoSecret` object (with separate `sign` and `encrypt` keys) for environments that support the Web Crypto API. This enables more secure key management and the use of asymmetric keys for signing and encryption, depending on the `jwt.mode` option. However, it requires additional setup for correct generation and usage.

<Callout type="warning">
If the `jwt.mode` is `signed` or `encrypted`, the secret must be a `CryptoKey` or `CryptoKeyPair` that is compatible with the
chosen signing or encryption mode. However, if the `jwt.mode` is `sealed`, the secret must be defined as an object with `sign`
and `encrypt` properties, each containing a `CryptoKey` or `CryptoKeyPair` for signing and encryption operations respectively.
Using incompatible key types or configurations can lead to authentication failures or security vulnerabilities. Always ensure
that the secret is properly configured according to the session strategy and JWT mode you are using.
</Callout>

### `trustedProxyHeaders` [#trusted-proxy-headers]

Enables Aura Auth to read proxy headers when an application runs behind reverse proxies, load balancers, or CDNs to detect the original client protocol and IP.
Expand Down
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

- 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)

- Added the `ClickUp` OAuth provider to the supported integrations in Aura Auth. [#151](https://github.com/aura-stack-ts/auth/pull/151)
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ export interface AuthConfig<Identity extends EditableShape<UserShape> = Editable
* Secret used to sign and verify JWT tokens for session and csrf protection.
* If not provided, it will load from the environment variable `AURA_AUTH_SECRET` or `AUTH_SECRET`, but if it
* doesn't exist, it will throw an error during the initialization of the Auth module.
*
* > It can be a string, a Uint8Array, a CryptoKey, a CryptoKeyPair, or an object containing separate keys for
* signing and encryption. It depends on the JWT mode and algorithms you choose in the session configuration.
* The default mode is "sealed" (signing + encryption), so if the secret is a string or Uint8Array, it will derive
* separate keys for signing and encryption using HKDF, but if you provide a CryptoKeyPair, it will required to
* pass separate keys for signing and encryption in the `CryptoSecret` format.
* @example
* import { createSecretValue } from "@aura-stack/auth/crypto"
*
* secret: createSecretValue(32)
*
* // For asymmetric keys, generate a key pair and pass the private
* import { createKeyPair } from "@aura-stack/auth/crypto"
*
* const signing = await createKeyPair("RS256", { extractable: true })
* const encryption = await createKeyPair("RSA-OAEP-256", { extractable: true })
*
* secret: {
* sign: signing,
* encrypt: encryption,
* }
*/
secret?: JWTKey
/**
Expand Down
17 changes: 8 additions & 9 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,22 @@ export interface Session<DefaultUser extends User = User> {
expires: string
}

export interface CryptoSecret {
sign: CryptoKey | CryptoKeyPair
encrypt: CryptoKey | CryptoKeyPair
}

/**
* 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
* - KeyPair: asymmetric signing (RS256, ES256, EdDSA, etc.)
* - CryptoKeyPair: asymmetric signing/encryption (RS256, ES256, EdDSA, RSA-OAEP, etc.)
*/
export type SecretKey = string | Uint8Array | CryptoKey

/** Asymmetric key pair for signing or key agreement (Web Crypto `CryptoKey` pair). */
export interface KeyPair {
privateKey: CryptoKey
publicKey: CryptoKey
}
export type SecretKey = string | Uint8Array | CryptoKey | CryptoKeyPair | CryptoSecret

/**
* @todo: add key rotation support for "SecretKey | KeyPair | [SecretKey | KeyPair, ...(SecretKey | KeyPair)[]]"
* @todo: add key rotation support for "SecretKey | CryptoKeyPair | [SecretKey | CryptoKeyPair, ...(SecretKey | CryptoKeyPair)[]]"
*/
export type JWTKey = SecretKey

Expand Down
50 changes: 41 additions & 9 deletions packages/core/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "@aura-stack/jose"
export { base64url, type JWTPayload } from "@aura-stack/jose/jose"
import { AuthInternalError, AuthSecurityError } from "@/shared/errors.ts"
import { isEncryptedMode, isSealedMode, isSignedMode } from "@/shared/assert.ts"
import { isCryptoKey, isCryptoKeyPair, isCryptoSecret, isEncryptedMode, isSealedMode, isSignedMode } from "@/shared/assert.ts"
export { encoder, getRandomBytes, getSubtleCrypto } from "@aura-stack/jose/crypto"
import type { User, SessionConfig, JWTKey } from "@/@types/index.ts"

Expand Down Expand Up @@ -102,6 +102,42 @@ export const verifyMaxExpiration = (payload: TypedJWTPayload<Partial<User>>) =>
}
}

const getSecrets = async (secret: JWTKey, salt: string) => {
if (isCryptoSecret(secret)) {
return {
jwsSecret: secret.sign,
jweSecret: secret.encrypt,
jwtSecret: {
sign: secret.sign,
encrypt: secret.encrypt,
},
}
}
if (isCryptoKey(secret) || isCryptoKeyPair(secret)) {
return {
jwsSecret: secret,
jweSecret: secret,
jwtSecret: {
sign: secret,
encrypt: secret,
},
}
}

const [derivedSigningKey, derivedEncryptionKey] = await Promise.all([
createDeriveKey(secret, salt, "aura:signing"),
createDeriveKey(secret, salt, "aura:encryption"),
])
return {
jwsSecret: derivedSigningKey,
jweSecret: derivedEncryptionKey,
jwtSecret: {
sign: derivedSigningKey,
encrypt: derivedEncryptionKey,
},
}
}

/**
* Creates the JOSE instance used for signing and verifying tokens. It derives keys
* for session tokens and CSRF tokens. For security and determinism, it's required
Expand Down Expand Up @@ -143,16 +179,12 @@ export const createJoseInstance = <DefaultUser extends User = User>(secret?: JWT
}

const jose = (async () => {
const [derivedSigningKey, derivedEncryptionKey, derivedCsrfTokenKey] = await Promise.all([
createDeriveKey(secret, salt, "signing"),
createDeriveKey(secret, salt, "encryption"),
createDeriveKey(secret, salt, "csrfToken"),
])
const { jwsSecret, jweSecret, jwtSecret } = await getSecrets(secret, salt)

return {
jwt: createJWT<DefaultUser>({ sign: derivedSigningKey, encrypt: derivedEncryptionKey }),
jws: createJWS<DefaultUser>(derivedCsrfTokenKey),
jwe: createJWE<DefaultUser>(derivedEncryptionKey),
jwt: createJWT<DefaultUser>(jwtSecret),
jws: createJWS<DefaultUser>(jwsSecret),
jwe: createJWE<DefaultUser>(jweSecret),
}
})()
jose.catch(() => {})
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { notion } from "./notion.ts"
import { dropbox } from "./dropbox.ts"
import { atlassian } from "./atlassian.ts"
import { clickUp } from "./click-up.ts"
import { dribble } from "./dribble.ts"
import { dribbble } from "./dribbble.ts"
import { formatZodError } from "@/shared/utils.ts"
import { AuthInternalError } from "@/shared/errors.ts"
import { OAuthEnvSchema, OAuthProviderCredentialsSchema } from "@/schemas.ts"
Expand All @@ -40,7 +40,7 @@ export * from "./notion.ts"
export * from "./dropbox.ts"
export * from "./atlassian.ts"
export * from "./click-up.ts"
export * from "./dribble.ts"
export * from "./dribbble.ts"

export const builtInOAuthProviders = {
github,
Expand All @@ -58,7 +58,7 @@ export const builtInOAuthProviders = {
dropbox,
atlassian,
clickUp,
dribble,
dribbble,
} as const

/**
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/shared/assert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { equals, patternToRegex } from "@/shared/utils.ts"
import type { JWTConfig, JWTMode, JWTPayloadWithToken, SessionConfig } from "@/@types/index.ts"
import type { CryptoSecret, JWTConfig, JWTMode, JWTPayloadWithToken, SessionConfig } from "@/@types/index.ts"

export const isFalsy = (value: unknown): boolean => {
return value === false || value === 0 || value === "" || value === null || value === undefined || Number.isNaN(value)
Expand Down Expand Up @@ -109,3 +109,22 @@ export const isEncryptedMode = (config?: SessionConfig): config is { jwt: Extrac

export const isSealedMode = (config?: SessionConfig): config is { jwt: Extract<JWTConfig, { mode: "sealed" }> } =>
getJWTMode(config) === "sealed"

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

export const isCryptoKey = (value: unknown): value is CryptoKey => {
return typeof value === "object" && value !== null && "algorithm" in value && "extractable" in value
}

export const isCryptoSecret = (value: unknown): value is CryptoSecret => {
return (
typeof value === "object" &&
value !== null &&
"sign" in value &&
"encrypt" in value &&
(isCryptoKey(value.sign) || isCryptoKeyPair(value.sign)) &&
(isCryptoKey(value.encrypt) || isCryptoKeyPair(value.encrypt))
)
}
2 changes: 2 additions & 0 deletions packages/core/src/shared/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { equals, timingSafeEqual } from "@/shared/utils.ts"
import { base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.ts"
import type { AuthRuntimeConfig, JoseInstance, User } from "@/@types/index.ts"

export { generateKeyPair as createKeyPair } from "@aura-stack/jose/jose"

export const createSecretValue = (length: number = 32) => {
return base64url.encode(getRandomBytes(length))
}
Expand Down
Loading
Loading