Skip to content
Open
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
141 changes: 136 additions & 5 deletions Sources/WorkOS/Helpers/SessionHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ public struct AuthenticateSessionResult: Sendable {
/// the user as unauthenticated.
public let needsRefresh: Bool
/// Populated on failure: `no_session_cookie_provided`,
/// `invalid_session_cookie`, `invalid_jwt`, or `session_expired`.
/// `invalid_session_cookie`, `invalid_jwt`, `session_expired`, or (for
/// verified authentication) `client_required` / `invalid_cookie_password`.
public let reason: String?

init(
Expand Down Expand Up @@ -123,6 +124,10 @@ public enum SessionError: Error, Equatable, Sendable {
case missingSessionID
/// The operation requires a `WorkOSClient`, but the session was created without one.
case clientRequired
/// Verified sessions require a password of at least 32 characters.
case invalidCookiePassword
/// The access token could not be cryptographically validated.
case invalidJWT
}

/// Session-cookie management: load a sealed session, authenticate it,
Expand All @@ -134,15 +139,99 @@ public struct Session: Sendable {
/// The sealed session cookie value.
public let sessionData: String

@available(
*, deprecated,
message:
"Use init(client:sessionData:validatingCookiePassword:) and authenticateVerified()."
)
public init(client: WorkOSClient? = nil, sessionData: String, cookiePassword: String) {
self.init(client: client, sessionData: sessionData, uncheckedCookiePassword: cookiePassword)
}

// Retain legacy password compatibility for refresh without using a deprecated API.
fileprivate init(
client: WorkOSClient?, sessionData: String, uncheckedCookiePassword cookiePassword: String
) {
self.client = client
self.sessionData = sessionData
self.cookiePassword = cookiePassword
}

/// Construct a session with a password of at least 32 characters. Use a
/// cryptographically random password; length alone does not ensure entropy.
public init(
client: WorkOSClient, sessionData: String, validatingCookiePassword cookiePassword: String
) throws {
guard cookiePassword.count >= 32 else { throw SessionError.invalidCookiePassword }
self.init(client: client, sessionData: sessionData, uncheckedCookiePassword: cookiePassword)
}

/// Authenticate against the configured client's JWKS. Requires a signed
/// RS256 token with `sub` and `exp`. A cookie user must match the signed sub;
/// its other profile fields remain cookie data, not verified JWT claims.
/// Cookie impersonator data is not returned because it is not signed.
/// Fetches JWKS on every call (no cache). Fetch/verification failures fail
/// closed with `invalid_jwt`.
public func authenticateVerified() async -> AuthenticateSessionResult {
guard cookiePassword.count >= 32 else {
return AuthenticateSessionResult(
authenticated: false, reason: "invalid_cookie_password")
}
guard !sessionData.isEmpty else {
return AuthenticateSessionResult(
authenticated: false, reason: "no_session_cookie_provided")
}
guard let client else {
return AuthenticateSessionResult(authenticated: false, reason: "client_required")
}
guard
let session = try? SessionSealing.unseal(
sessionData, password: cookiePassword, as: SessionData.self)
else {
return AuthenticateSessionResult(authenticated: false, reason: "invalid_session_cookie")
}
guard
let (claims, verified) = try? await SessionTokenVerification.verify(
session.accessToken, client: client),
session.user.map({ $0.id == verified.sub }) ?? true
else {
return AuthenticateSessionResult(authenticated: false, reason: "invalid_jwt")
}
let expired = Date().timeIntervalSince1970 >= Double(verified.exp)
return AuthenticateSessionResult(
authenticated: !expired,
sessionId: claims.sessionId,
organizationId: claims.organizationId,
role: claims.role,
permissions: claims.permissions ?? [],
entitlements: claims.entitlements ?? [],
user: session.user,
needsRefresh: expired,
reason: expired ? "session_expired" : nil
)
}

/// One-shot cryptographically verified authentication.
public static func authenticateVerified(
client: WorkOSClient, sealedSession: String, cookiePassword: String
) async -> AuthenticateSessionResult {
guard
let session = try? Session(
client: client, sessionData: sealedSession, validatingCookiePassword: cookiePassword
)
else {
return AuthenticateSessionResult(
authenticated: false, reason: "invalid_cookie_password")
}
return await session.authenticateVerified()
}

/// Validate the sealed session: unseal it, check the access token, and
/// extract the JWT claims. Never throws — failures are reported through
/// `authenticated == false` plus `reason`.
@available(
*, deprecated, message: "Does not verify JWT signatures. Use await authenticateVerified()."
)
public func authenticate() -> AuthenticateSessionResult {
guard !sessionData.isEmpty else {
return AuthenticateSessionResult(
Expand All @@ -165,7 +254,10 @@ public struct Session: Sendable {

// Enforce JWT expiration: an expired access token signals the caller
// to refresh the session rather than treat the user as logged out.
if let exp = claims.exp, Date().timeIntervalSince1970 >= Double(exp) {
guard let exp = claims.exp else {
return AuthenticateSessionResult(authenticated: false, reason: "invalid_jwt")
}
if Date().timeIntervalSince1970 >= Double(exp) {
return AuthenticateSessionResult(
authenticated: false,
sessionId: claims.sessionId,
Expand Down Expand Up @@ -264,6 +356,9 @@ public struct Session: Sendable {

/// Build the logout URL for this session. An expired access token is
/// fine — the logout endpoint only needs the session ID.
@available(
*, deprecated, message: "Uses unverified claims. Use await getVerifiedLogoutUrl(returnTo:)."
)
public func getLogoutUrl(returnTo: String? = nil) throws -> URL {
guard !sessionData.isEmpty else { throw SessionError.noSessionData }

Expand All @@ -284,8 +379,32 @@ public struct Session: Sendable {
return components.url!
}

/// Build a logout URL using a cryptographically verified session ID.
/// Expired but validly signed tokens can still be used to log out.
public func getVerifiedLogoutUrl(returnTo: String? = nil) async throws -> URL {
guard !sessionData.isEmpty else { throw SessionError.noSessionData }
guard let client else { throw SessionError.clientRequired }
let result = await authenticateVerified()
guard result.authenticated || result.needsRefresh else { throw SessionError.invalidJWT }
guard let sessionID = result.sessionId, !sessionID.isEmpty else {
throw SessionError.missingSessionID
}
var base = client.configuration.baseURL.absoluteString
if base.hasSuffix("/") { base.removeLast() }
var components = URLComponents(string: "\(base)/user_management/sessions/logout")!
var query = [URLQueryItem(name: "session_id", value: sessionID)]
if let returnTo { query.append(URLQueryItem(name: "return_to", value: returnTo)) }
components.queryItems = query
return components.url!
}

/// One-shot session authentication that needs no client — only the
/// sealed session and the cookie password.
@available(
*, deprecated,
message:
"Does not verify JWT signatures. Use await authenticateVerified(client:sealedSession:cookiePassword:)."
)
public static func authenticate(
sealedSession: String, cookiePassword: String
) -> AuthenticateSessionResult {
Expand Down Expand Up @@ -323,8 +442,8 @@ public struct Session: Sendable {
}

/// Decode the payload (claims) of a JWT without verifying its signature.
/// Acceptable because the token was sealed by us and is trusted after
/// unsealing.
/// Used only by legacy authentication and as a refresh request hint.
/// These claims MUST NOT be trusted for authentication or authorization.
static func parseJWTPayload(_ token: String) throws -> JWTClaims {
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3 else {
Expand All @@ -340,16 +459,28 @@ public struct Session: Sendable {

extension WorkOSClient {
/// Load a sealed session cookie into a `Session` bound to this client.
@available(
*, deprecated,
message:
"Use try loadVerifiedSession(sessionData:cookiePassword:) and await authenticateVerified()."
)
public func loadSealedSession(sessionData: String, cookiePassword: String) -> Session {
Session(client: self, sessionData: sessionData, cookiePassword: cookiePassword)
}

/// Load a session with password validation. Authenticate it with
/// `await session.authenticateVerified()` before trusting its claims.
public func loadVerifiedSession(sessionData: String, cookiePassword: String) throws -> Session {
try Session(
client: self, sessionData: sessionData, validatingCookiePassword: cookiePassword)
}

/// One-shot refresh of a sealed session.
public func refreshSession(
sealedSession: String, cookiePassword: String, requestOptions: RequestOptions? = nil
) async throws -> RefreshSessionResult {
try await Session(
client: self, sessionData: sealedSession, cookiePassword: cookiePassword
client: self, sessionData: sealedSession, uncheckedCookiePassword: cookiePassword
).refresh(requestOptions: requestOptions)
}
}
61 changes: 45 additions & 16 deletions Sources/WorkOS/Helpers/SessionSealing.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @oagen-ignore-file — hand-maintained; oagen must never overwrite this file.

import CommonCrypto
import CryptoKit
import Foundation

Expand All @@ -15,10 +16,12 @@ public enum SessionSealingError: Error, Equatable, Sendable {

/// Raw seal/unseal helpers for session payloads.
///
/// `seal` encrypts any JSON-serializable value with AES-256-GCM and returns a
/// base64 string of `nonce(12) || ciphertext || tag`. The password is used
/// directly as the key when it is a hex-encoded 32-byte string (64 hex
/// characters); otherwise it is hashed with SHA-256 to derive the key.
/// New seals use `wos2.` followed by base64 `salt(16) || nonce(12) || ciphertext || tag`.
/// Keys are derived with PBKDF2-HMAC-SHA256 (600,000 iterations), costing roughly
/// 100–300 ms of CPU per seal/unseal depending on the device. These synchronous
/// operations should run off the UI thread. Legacy seals remain readable and
/// are upgraded the next time the session is sealed.
/// Use a cryptographically random password of at least 32 characters.
public enum SessionSealing {
/// Encrypt a JSON-serializable value into a sealed base64 string.
public static func seal<T: Encodable>(_ value: T, password: String) throws -> String {
Expand All @@ -36,42 +39,68 @@ public enum SessionSealing {

/// Encrypt raw bytes with AES-256-GCM using the derived key.
static func sealBytes(_ plaintext: Data, password: String) throws -> String {
let key = deriveKey(password)
let salt = SymmetricKey(size: .bits128).withUnsafeBytes { Data($0) }
let key = try deriveKey(password, salt: salt)
do {
let sealedBox = try AES.GCM.seal(plaintext, using: key, nonce: AES.GCM.Nonce())
var output = Data(sealedBox.nonce)
let sealedBox = try AES.GCM.seal(
plaintext, using: key, authenticating: Data("wos2.".utf8))
var output = salt
output.append(contentsOf: sealedBox.nonce)
output.append(sealedBox.ciphertext)
output.append(sealedBox.tag)
return output.base64EncodedString()
return "wos2." + output.base64EncodedString()
} catch {
throw SessionSealingError.cryptoFailure("encryption failed: \(error)")
}
}

/// Decrypt a sealed base64 string back to raw bytes.
static func unsealBytes(_ sealed: String, password: String) throws -> Data {
guard let raw = Data(base64Encoded: sealed) else {
let versioned = sealed.hasPrefix("wos2.")
guard let raw = Data(base64Encoded: versioned ? String(sealed.dropFirst(5)) : sealed) else {
throw SessionSealingError.invalidSealedData
}
// nonce(12) plus tag(16) with at least some ciphertext.
guard raw.count > 28 else {
guard raw.count > (versioned ? 44 : 28) else {
throw SessionSealingError.sealedDataTooShort
}

let key = deriveKey(password)
let key =
versioned ? try deriveKey(password, salt: Data(raw.prefix(16))) : deriveKey(password)
Comment on lines +68 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Forged Cookies Amplify CPU

Any attacker-supplied wos2. cookie that base64-decodes to more than 44 bytes reaches the 600,000-iteration PBKDF before authentication fails. Session cookies are processed on each request, and this synchronous derivation runs before authenticateVerified() reaches its first await. Repeated forged cookies can therefore consume roughly 100–300 ms of CPU each, starving the cooperative executor or exhausting server capacity. Avoid performing this expensive password derivation independently for every untrusted request—for example, prederive or cache server-side key material and use a cheap authenticated per-cookie derivation while bounding concurrent work.

How this was verified: A syntactically valid unauthenticated cookie reaches the synchronous 600,000-iteration PBKDF before AES-GCM authentication or JWT verification occurs.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/WorkOS/Helpers/SessionSealing.swift
Line: 68-69

Comment:
**Forged Cookies Amplify CPU**

Any attacker-supplied `wos2.` cookie that base64-decodes to more than 44 bytes reaches the 600,000-iteration PBKDF before authentication fails. Session cookies are processed on each request, and this synchronous derivation runs before `authenticateVerified()` reaches its first `await`. Repeated forged cookies can therefore consume roughly 100–300 ms of CPU each, starving the cooperative executor or exhausting server capacity. Avoid performing this expensive password derivation independently for every untrusted request—for example, prederive or cache server-side key material and use a cheap authenticated per-cookie derivation while bounding concurrent work.

**How this was verified:** A syntactically valid unauthenticated cookie reaches the synchronous 600,000-iteration PBKDF before AES-GCM authentication or JWT verification occurs.

**Knowledge Base Used:**
- [Authentication and sessions](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-ios/-/docs/authentication-and-sessions.md)
- [Security and data protection](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-ios/-/docs/security-and-data-protection.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

let payload = versioned ? Data(raw.dropFirst(16)) : raw
do {
let sealedBox = try AES.GCM.SealedBox(
nonce: AES.GCM.Nonce(data: raw.prefix(12)),
ciphertext: raw.dropFirst(12).dropLast(16),
tag: raw.suffix(16)
nonce: AES.GCM.Nonce(data: payload.prefix(12)),
ciphertext: payload.dropFirst(12).dropLast(16),
tag: payload.suffix(16)
)
return try AES.GCM.open(sealedBox, using: key)
return try AES.GCM.open(
sealedBox, using: key, authenticating: versioned ? Data("wos2.".utf8) : Data())
} catch {
throw SessionSealingError.cryptoFailure("decryption failed: \(error)")
}
}

/// Derive a 32-byte AES key from the password: hex-decode when the
private static func deriveKey(_ password: String, salt: Data) throws -> SymmetricKey {
let passwordBytes = Array(password.utf8)
var key = [UInt8](repeating: 0, count: 32)
let status = passwordBytes.withUnsafeBytes { passwordBuffer in
salt.withUnsafeBytes { saltBuffer in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBuffer.baseAddress?.assumingMemoryBound(to: Int8.self),
passwordBytes.count,
saltBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self), salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), 600_000, &key, key.count)
}
}
guard status == kCCSuccess else {
throw SessionSealingError.cryptoFailure("key derivation failed")
}
return SymmetricKey(data: key)
}

/// Legacy read-only derivation. Derive a 32-byte AES key from the password: hex-decode when the
/// password is exactly 64 hex characters, otherwise SHA-256 the UTF-8 bytes.
static func deriveKey(_ password: String) -> SymmetricKey {
if password.count == 64, let decoded = decodeHex(password), decoded.count == 32 {
Expand Down
Loading
Loading