Class: WorkOS::Session

Inherits:
Object
  • Object
show all
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.

Examples:

Authenticate and refresh

session = client.session_manager.load(seal_data: cookie, cookie_password: pw)
result = session.authenticate
if result.is_a?(SessionManager::AuthError) && result.reason == SessionManager::EXPIRED_JWT
  refresh = session.refresh
end

Build a logout URL

url = session.get_logout_url(return_to: "https://app.example.com")

Constant Summary collapse

32

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(manager, seal_data:, cookie_password:) ⇒ Session

Returns a new instance of Session.

Raises:

  • (ArgumentError)


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 cookie_password.nil? || cookie_password.empty?
  raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if cookie_password.bytesize < MIN_COOKIE_PASSWORD_BYTES
  @manager = manager
  @client = manager.client
  @seal_data = seal_data
  @cookie_password = cookie_password
end

Instance Attribute Details

Returns the value of attribute cookie_password.



40
41
42
# File 'lib/workos/session.rb', line 40

def cookie_password
  @cookie_password
end

#seal_dataObject (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

Parameters:

  • include_expired (Boolean) (defaults to: false)

    If true, returns decoded token data even when expired (default: false)

  • block (Proc)

    Optional block to call to extract additional claims from the decoded JWT

Returns:

  • (Hash)

    A hash containing the authentication response and a reason if the authentication failed



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

Raises:



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 || @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.message)
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.message, sealed_session: @seal_data)
end