Class: LogtoClient

Inherits:
Object
  • Object
show all
Defined in:
lib/logto/client/index.rb,
lib/logto/client/index_types.rb,
lib/logto/client/index_storage.rb,
lib/logto/client/index_constants.rb

Overview

The main client class for the Logto client.

It provides the main functionalities for the client to interact with the Logto server.

Defined Under Namespace

Classes: AbstractStorage, Config, RailsCacheStorage, SessionStorage, SignInSession

Constant Summary collapse

STORAGE_KEY =
{
  id_token: "id_token",
  refresh_token: "refresh_token",
  access_token_map: "access_token_map",
  sign_in_session: "sign_in_session"
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config:, navigate:, storage:, cache: RailsCacheStorage.new(app_id: config.app_id)) ⇒ LogtoClient

Returns a new instance of LogtoClient.

Parameters:

  • config (LogtoClient::Config)

    The configuration object for the Logto client.

  • navigate (Proc)

    The navigation function to be used for the sign-in experience. It should accept a URI string as the only argument. You can use the ‘redirect_to` method in Rails. @example

    ->(uri) { redirect_to(uri, allow_other_host: true) }
    
  • storage (LogtoClient::AbstractStorage)

    The storage object for the Logto client. You can use the ‘LogtoClient::SessionStorage` for Rails applications. @example

    LogtoClient::SessionStorage.new(session)
    
  • cache (LogtoClient::AbstractStorage) (defaults to: RailsCacheStorage.new(app_id: config.app_id))

    The cache object for the Logto client. By default, it will use the Rails cache.

Raises:

  • (ArgumentError)


26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/logto/client/index.rb', line 26

def initialize(config:, navigate:, storage:, cache: RailsCacheStorage.new(app_id: config.app_id))
  raise ArgumentError, "Config must be a LogtoClient::Config" unless config.is_a?(LogtoClient::Config)
  raise ArgumentError, "Navigate must be a Proc" unless navigate.is_a?(Proc)
  raise ArgumentError, "Storage must be a LogtoClient::AbstractStorage" unless storage.is_a?(LogtoClient::AbstractStorage)
  @config = config
  @navigate = navigate
  @storage = storage
  @cache = cache
  @core = LogtoCore.new(endpoint: @config.endpoint, cache: cache)
  # A local access token map cache
  @access_token_map = @storage.get(STORAGE_KEY[:access_token_map]) || {}
end

Instance Attribute Details

#configLogtoClient::Config (readonly)

The configuration object for the Logto client.

Returns:



12
13
14
# File 'lib/logto/client/index.rb', line 12

def config
  @config
end

Instance Method Details

#access_token(resource: nil, organization_id: nil) ⇒ String?

Get the access token for the specified resource and organization ID. If both are nil, it will return the opaque access token for the OpenID Connect UserInfo endpoint.

If the access token is not found or expired, it will try to use the refresh token to fetch a new access token, if possible.

Parameters:

  • resource (String, nil) (defaults to: nil)

    The resource to be accessed.

  • organization_id (String, nil) (defaults to: nil)

    The organization ID to be accessed.

Returns:

  • (String, nil)

    The access token.

Raises:



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/logto/client/index.rb', line 182

def access_token(resource: nil, organization_id: nil)
  raise LogtoError::NotAuthenticatedError, "Not authenticated" unless is_authenticated?
  key = LogtoUtils.build_access_token_key(resource: resource, organization_id: organization_id)
  token = @access_token_map[key]

  # Give it some leeway
  if token&.[]("expires_at")&.> Time.now + 10
    return token["token"]
  end

  @access_token_map.delete(key)
  return nil unless refresh_token

  # Try to use refresh token to fetch a new access token
  token_response = @core.fetch_token_by_refresh_token(
    client_id: @config.app_id,
    client_secret: @config.app_secret,
    refresh_token: refresh_token,
    resource: resource,
    organization_id: organization_id
  )
  handle_token_response(token_response, resource: resource, organization_id: organization_id)
  token_response[:access_token]
end

#access_token_claims(resource: nil, organization_id: nil) ⇒ LogtoCore::AccessTokenClaims?

Get the access token claims for the specified resource and organization ID. If both are nil, an ArgumentError will be raised.

Parameters:

  • resource (String, nil) (defaults to: nil)

    The resource to be accessed.

  • organization_id (String, nil) (defaults to: nil)

    The organization ID to be accessed.

Returns:

Raises:

  • (ArgumentError)


213
214
215
216
217
218
219
220
221
# File 'lib/logto/client/index.rb', line 213

def access_token_claims(resource: nil, organization_id: nil)
  raise ArgumentError, "Resource and organization ID cannot be nil at the same time" if
    resource.nil? && organization_id.nil?
  return nil unless (token = access_token(resource: resource, organization_id: organization_id))
  LogtoUtils.parse_json_safe(
    JWT.decode(token, nil, false).first,
    LogtoCore::AccessTokenClaims
  )
end

#clear_all_tokensObject

Clear all the tokens from the storage.

It will also clear the access token map cache.



247
248
249
250
251
252
# File 'lib/logto/client/index.rb', line 247

def clear_all_tokens
  @access_token_map = {}
  @storage.remove(STORAGE_KEY[:access_token_map])
  @storage.remove(STORAGE_KEY[:id_token])
  @storage.remove(STORAGE_KEY[:refresh_token])
end

#fetch_user_infoLogtoCore::UserInfoResponse

Fetch the user information from the OpenID Connect UserInfo endpoint.

Returns:



226
227
228
# File 'lib/logto/client/index.rb', line 226

def 
  @core.(access_token: access_token)
end

#handle_sign_in_callback(url:) ⇒ String?

Handle the sign-in callback from the redirect URI.

Parameters:

  • url (String)

    The URL of the callback from the redirect URI. It should contain the query parameters.

Returns:

  • (String, nil)

    The URI that the user will be redirected to after the redirect URI has successfully handled the sign-in callback. It should be the same as the ‘post_redirect_uri` in the `sign_in` method. If it was not set, no redirection will happen.

Raises:



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
# File 'lib/logto/client/index.rb', line 104

def (url:)
  query_params = URI.decode_www_form(URI(url).query).to_h
  data = @storage.get(STORAGE_KEY[:sign_in_session])
  raise LogtoError::SessionNotFoundError, "No sign-in session found" unless data

  error = query_params[LogtoCore::QUERY_KEY[:error]]
  error_description = query_params[LogtoCore::QUERY_KEY[:error_description]]
  raise LogtoError::ServerCallbackError, "Error: #{error}, Description: #{error_description}" if error

  current_session = data.is_a?(SignInSession) ? data : SignInSession.new(**data)
  # A loose URI check here
  raise LogtoError::SessionMismatchError, "Redirect URI mismatch" unless url.start_with?(current_session.redirect_uri)
  raise LogtoError::SessionMismatchError, "No state found in query parameters" unless query_params[LogtoCore::QUERY_KEY[:state]]
  raise LogtoError::SessionMismatchError, "Session state mismatch" unless current_session.state == query_params[LogtoCore::QUERY_KEY[:state]]
  raise LogtoError::SessionMismatchError, "No code found in query parameters" unless query_params[LogtoCore::QUERY_KEY[:code]]

  token_response = @core.fetch_token_by_authorization_code(
    client_id: @config.app_id,
    client_secret: @config.app_secret,
    redirect_uri: current_session.redirect_uri,
    code_verifier: current_session.code_verifier,
    code: query_params[LogtoCore::QUERY_KEY[:code]]
  )

  verify_jwt(token: token_response[:id_token])
  handle_token_response(token_response, resource: nil)
  

  @navigate.call(current_session.post_redirect_uri)
  current_session.post_redirect_uri
end

#id_tokenString?

Get the raw ID token from the storage.

Returns:

  • (String, nil)

    The raw ID token.



160
161
162
# File 'lib/logto/client/index.rb', line 160

def id_token
  @storage.get(STORAGE_KEY[:id_token])
end

#id_token_claimsLogtoCore::IdTokenClaims?

Get the ID token claims from the storage. It will return nil if the ID token is not found.

Returns:



168
169
170
171
# File 'lib/logto/client/index.rb', line 168

def id_token_claims
  return nil unless (token = id_token)
  LogtoUtils.parse_json_safe(JWT.decode(token, nil, false).first, LogtoCore::IdTokenClaims)
end

#is_authenticated?Boolean

Check if the client is authenticated by checking if the ID token is present.

Returns:

  • (Boolean)

    Whether the client is authenticated.



240
241
242
# File 'lib/logto/client/index.rb', line 240

def is_authenticated?
  id_token ? true : false
end

#refresh_tokenString?

Get the raw refresh token from the storage.

Returns:

  • (String, nil)

    The raw refresh token.



233
234
235
# File 'lib/logto/client/index.rb', line 233

def refresh_token
  @storage.get(STORAGE_KEY[:refresh_token])
end

#sign_in(redirect_uri:, first_screen: nil, login_hint: nil, direct_sign_in: nil, post_redirect_uri: nil, extra_params: nil) ⇒ Object

Triggers the sign-in experience.

Parameters:

  • redirect_uri (String)

    The redirect URI that the user will be redirected to after the sign-in experience is completed.

  • first_screen (String) (defaults to: nil)

    The first screen that the user will see in the sign-in experience. Can be ‘signIn` or `register`.

  • login_hint (String) (defaults to: nil)

    The login hint to be used for the sign-in experience.

  • direct_sign_in (Hash) (defaults to: nil)

    The direct sign-in configuration to be used for the sign-in experience. It should contain the ‘method` and `target` keys.

  • post_redirect_uri (String) (defaults to: nil)

    The URI that the user will be redirected to after the redirect URI has successfully handled the sign-in callback.

  • extra_params (Hash) (defaults to: nil)

    Extra parameters to be used for the sign-in experience.



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
# File 'lib/logto/client/index.rb', line 47

def (redirect_uri:, first_screen: nil, login_hint: nil, direct_sign_in: nil, post_redirect_uri: nil, extra_params: nil)
  code_verifier = LogtoUtils.generate_code_verifier
  code_challenge = LogtoUtils.generate_code_challenge(code_verifier)

  state = LogtoUtils.generate_state
   = @core.(
    client_id: @config.app_id,
    redirect_uri: redirect_uri,
    code_challenge: code_challenge,
    state: state,
    scopes: @config.scopes,
    resources: @config.resources,
    prompt: @config.prompt,
    first_screen: first_screen,
    login_hint: ,
    direct_sign_in: ,
    extra_params: extra_params
  )

  (SignInSession.new(
    redirect_uri: redirect_uri,
    code_verifier: code_verifier,
    state: state,
    post_redirect_uri: post_redirect_uri
  ))
  clear_all_tokens

  @navigate.call()
end

#sign_out(post_logout_redirect_uri: nil) ⇒ Object

Start the sign-out flow with the specified redirect URI. The URI must be registered in the Logto Console.

It will also revoke all the tokens and clean up the storage.

The user will be redirected to that URI after the sign-out flow is completed. If the ‘post_logout_redirect_uri` is not specified, the user will be redirected to a default page.

Parameters:

  • post_logout_redirect_uri (String) (defaults to: nil)

    The URI that the user will be redirected to after the sign-out flow is completed.



87
88
89
90
91
92
93
94
95
96
97
# File 'lib/logto/client/index.rb', line 87

def sign_out(post_logout_redirect_uri: nil)
  if refresh_token
    @core.revoke_token(client_id: @config.app_id, client_secret: @config.app_secret, token: refresh_token)
  end

  uri = @core.generate_sign_out_uri(
    client_id: @config.app_id, post_logout_redirect_uri: post_logout_redirect_uri
  )
  clear_all_tokens
  @navigate.call(uri)
end

#verify_jwt(token:) ⇒ Object

Verify the JWT token with the configured client ID and the OIDC issuer.

Parameters:

  • token (String)

    The JWT token to be verified.

Raises:

  • (ArgumentError)


139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/logto/client/index.rb', line 139

def verify_jwt(token:)
  raise ArgumentError, "Token must be a string" unless token.is_a?(String)

  JWT.decode(
    token,
    nil,
    true,
    # List our current and future possibilities. It could use the `alg` header from the token,
    # but it will be tricky to handle the case of caching.
    algorithms: ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "ES256K"],
    jwks: fetch_jwks,
    iss: @core.oidc_config[:issuer],
    verify_iss: true,
    aud: @config.app_id,
    verify_aud: true
  )
end