Class: WorkOS::Session
- Inherits:
-
Object
- Object
- WorkOS::Session
- Defined in:
- lib/workos/session.rb
Overview
Wraps a sealed session cookie for authentication, refresh, and logout. Constructed by WorkOS::SessionManager#load; not intended for direct instantiation.
Constant Summary collapse
- MIN_COOKIE_PASSWORD_BYTES =
Minimum cookie_password byte length. AES-256-GCM derives a 32-byte key from the password via SHA-256; a passphrase shorter than the output it derives to provides less than the full keyspace and makes offline brute-force feasible. Require callers to supply at least 32 bytes of high-entropy secret. See README + V7_MIGRATION_GUIDE.md.
32
Instance Attribute Summary collapse
-
#cookie_password ⇒ Object
readonly
Returns the value of attribute cookie_password.
-
#seal_data ⇒ Object
readonly
Returns the value of attribute seal_data.
Instance Method Summary collapse
-
#authenticate(include_expired: false, &claim_extractor) ⇒ Hash
Authenticates the user based on the session data.
-
#get_logout_url(return_to: nil) ⇒ Object
Build the WorkOS session-logout URL for the currently authenticated session.
-
#initialize(manager, seal_data:, cookie_password:) ⇒ Session
constructor
A new instance of Session.
- #refresh(organization_id: nil, cookie_password: nil) ⇒ Object
Constructor Details
#initialize(manager, seal_data:, cookie_password:) ⇒ Session
Returns a new instance of Session.
31 32 33 34 35 36 37 38 |
# File 'lib/workos/session.rb', line 31 def initialize(manager, seal_data:, cookie_password:) raise ArgumentError, "cookie_password is required" if .nil? || .empty? raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if .bytesize < MIN_COOKIE_PASSWORD_BYTES @manager = manager @client = manager.client @seal_data = seal_data @cookie_password = end |
Instance Attribute Details
#cookie_password ⇒ Object (readonly)
Returns the value of attribute cookie_password.
40 41 42 |
# File 'lib/workos/session.rb', line 40 def @cookie_password end |
#seal_data ⇒ Object (readonly)
Returns the value of attribute seal_data.
40 41 42 |
# File 'lib/workos/session.rb', line 40 def seal_data @seal_data end |
Instance Method Details
#authenticate(include_expired: false, &claim_extractor) ⇒ Hash
Authenticates the user based on the session data
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/workos/session.rb', line 46 def authenticate(include_expired: false, &claim_extractor) return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::NO_SESSION_COOKIE_PROVIDED) if @seal_data.nil? || @seal_data.empty? session = begin @manager.unseal_data(@seal_data, @cookie_password) rescue ArgumentError, OpenSSL::Cipher::CipherError return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_SESSION_COOKIE) end return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_SESSION_COOKIE) unless session.is_a?(Hash) && session["access_token"] decoded = begin @manager.decode_jwt(session["access_token"], verify_expiration: !include_expired) rescue JWT::ExpiredSignature return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::EXPIRED_JWT) rescue JWT::IncorrectAlgorithm return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT_ALGORITHM) rescue JWT::VerificationError return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT_SIGNATURE) rescue JWT::DecodeError return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT) end is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i SessionManager::AuthSuccess.new( authenticated: !is_expired, reason: is_expired ? SessionManager::EXPIRED_JWT : nil, session_id: decoded["sid"], organization_id: decoded["org_id"], role: decoded["role"], roles: decoded["roles"], permissions: decoded["permissions"], entitlements: decoded["entitlements"], user: session["user"], impersonator: session["impersonator"], feature_flags: decoded["feature_flags"], custom_claims: claim_extractor&.call(decoded) ) end |
#get_logout_url(return_to: nil) ⇒ Object
Build the WorkOS session-logout URL for the currently authenticated session. Requires #authenticate to succeed (so we have the session_id).
156 157 158 159 160 161 162 163 164 165 |
# File 'lib/workos/session.rb', line 156 def get_logout_url(return_to: nil) result = authenticate raise WorkOS::Error.new(message: "Failed to extract session ID for logout URL: #{result.reason}") if result.is_a?(SessionManager::AuthError) base = @client.base_url params = {"session_id" => result.session_id} params["return_to"] = return_to if return_to uri = URI.join(base, "/user_management/sessions/logout") uri.query = URI.encode_www_form(params) uri.to_s end |
#refresh(organization_id: nil, cookie_password: nil) ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/workos/session.rb', line 86 def refresh(organization_id: nil, cookie_password: nil) effective_password = || @cookie_password # Validate up front so a caller-supplied short password raises ArgumentError # (matching Session#initialize) instead of being swallowed by the # unseal_data rescue and surfacing as INVALID_SESSION_COOKIE. raise ArgumentError, "cookie_password is required" if effective_password.nil? || effective_password.empty? raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if effective_password.bytesize < MIN_COOKIE_PASSWORD_BYTES session = begin @manager.unseal_data(@seal_data, effective_password) rescue ArgumentError, OpenSSL::Cipher::CipherError return SessionManager::RefreshError.new(authenticated: false, reason: SessionManager::INVALID_SESSION_COOKIE) end return SessionManager::RefreshError.new(authenticated: false, reason: SessionManager::INVALID_SESSION_COOKIE) unless session.is_a?(Hash) && session["refresh_token"] # Uses auth: true (Bearer token) to match authenticate_with_refresh_token. # client_id is included in the body as required by the OAuth2 token exchange. body = { "grant_type" => "refresh_token", "client_id" => @client.client_id, "refresh_token" => session["refresh_token"] } body["organization_id"] = organization_id if organization_id response = @client.request(method: :post, path: "/user_management/authenticate", auth: true, body: body) auth_response = JSON.parse(response.body) sealed = @manager.seal_session_from_auth_response( access_token: auth_response["access_token"], refresh_token: auth_response["refresh_token"], cookie_password: effective_password, user: auth_response["user"], impersonator: auth_response["impersonator"] ) # Persist the new seal/password BEFORE decoding the JWT, so a transient # JWKS fetch error (or any decode failure on the freshly-minted token) # leaves the Session with a usable sealed cookie that the caller can # re-#authenticate against, rather than half-updated state. @seal_data = sealed @cookie_password = effective_password decoded = @manager.decode_jwt(auth_response["access_token"]) SessionManager::RefreshSuccess.new( authenticated: true, sealed_session: sealed, session_id: decoded["sid"], organization_id: auth_response["organization_id"] || decoded["org_id"], role: decoded["role"], roles: decoded["roles"], permissions: decoded["permissions"], entitlements: decoded["entitlements"], user: auth_response["user"], impersonator: auth_response["impersonator"], feature_flags: decoded["feature_flags"] ) rescue WorkOS::AuthenticationError, WorkOS::InvalidRequestError => e SessionManager::RefreshError.new(authenticated: false, reason: e.) rescue JWT::DecodeError => e # The refresh token was already rotated server-side before decode failed, # so @seal_data holds the freshly-minted cookie. Surface it on the error # struct so the caller can write the rotated cookie back to the browser # and recover on a subsequent #authenticate, rather than re-sending the # now-revoked refresh token. SessionManager::RefreshError.new(authenticated: false, reason: e., sealed_session: @seal_data) end |