logto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a84ba84aa4ec632c26f51845b19b030d0e193d5db43551927ee402fd7861ca63
4
+ data.tar.gz: 62e89ab3e00a01560d55f718efdb72aca4f18325a99b84b4425b3504f7f22308
5
+ SHA512:
6
+ metadata.gz: 52359dd0b6d5923460dfd7b31d6b01c67e5a0aa747616684e018458d8231c6fef7d07bb864de86837e8f3f6d8e1129e43a805532c508d6d132355e0cde9c137c
7
+ data.tar.gz: 3a23d56c2fc5de83b5162859b2ea013724b193e8966060f8f47084ace7d8714f545726d5805be2a05184dbb2f7efb37a78b1dbd8c8a9dcbd422972857836dccc
data/README.md ADDED
@@ -0,0 +1,44 @@
1
+ <p align="center">
2
+ <a href="https://logto.io" target="_blank" align="center" alt="Logto Logo">
3
+ <picture>
4
+ <source height="60" media="(prefers-color-scheme: dark)" srcset="https://github.com/logto-io/.github/raw/master/profile/logto-logo-dark.svg">
5
+ <source height="60" media="(prefers-color-scheme: light)" srcset="https://github.com/logto-io/.github/raw/master/profile/logto-logo-light.svg">
6
+ <img height="60" src="https://github.com/logto-io/logto/raw/master/logo.png" alt="Logto logo">
7
+ </picture>
8
+ </a>
9
+ <br/><br/>
10
+ <span><i><a href="https://logto.io" target="_blank">Logto</a> is an open-source Auth0 alternative designed for modern apps and SaaS products.</i></span>
11
+ </p>
12
+
13
+ # Logto Ruby SDK
14
+
15
+ [![Version](https://img.shields.io/gem/v/logto)][RubyGems]
16
+ [![Downloads](https://img.shields.io/gem/dt/logto)][RubyGems]
17
+ [![Logto](https://img.shields.io/badge/for-logto-7958ff)][Website]
18
+ [![Discord](https://img.shields.io/badge/discord-join-5865f2?logo=discord)][Discord]
19
+
20
+ This is the official Ruby SDK for Logto. It provides a simple way to integrate Logto with your Ruby applications.
21
+
22
+ This SDK is designed as framework-agnostic, so it can be used with any Ruby web framework. Check out our [docs](https://docs.logto.io/quick-starts/ruby/) for more information.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ bundle add logto
28
+ ```
29
+
30
+ Or whatever your preferred method of adding gems is.
31
+
32
+ ## Sample
33
+
34
+ A sample Ruby on Rails application is available [here](https://github.com/logto-io/ruby/tree/HEAD/logto-sample).
35
+
36
+ ## Resources
37
+
38
+ - [Documentation](https://docs.logto.io/quick-starts/ruby/)
39
+ - [Website][Website]
40
+ - [Discord][Discord]
41
+
42
+ [RubyGems]: https://rubygems.org/gems/logto
43
+ [Website]: https://logto.io/
44
+ [Discord]: https://discord.gg/vRvwuwgpVX
@@ -0,0 +1,15 @@
1
+ require_relative "../core/errors"
2
+
3
+ class LogtoError
4
+ # Raise when the session is not found in the storage.
5
+ class SessionNotFoundError < LogtoError
6
+ end
7
+
8
+ # Raise when the session is found but the parameters are mismatched.
9
+ class SessionMismatchError < LogtoError
10
+ end
11
+
12
+ # Raise when the session is found but the callback URI contains an error parameter.
13
+ class ServerCallbackError < LogtoError
14
+ end
15
+ end
@@ -0,0 +1,312 @@
1
+ require "jwt"
2
+ require_relative "index_constants"
3
+ require_relative "index_types"
4
+ require_relative "index_storage"
5
+ require_relative "errors"
6
+
7
+ # The main client class for the Logto client.
8
+ #
9
+ # It provides the main functionalities for the client to interact with the Logto server.
10
+ #
11
+ # @attr_reader config [LogtoClient::Config] The configuration object for the Logto client.
12
+ class LogtoClient
13
+ attr_reader :config
14
+
15
+ # @param config [LogtoClient::Config] The configuration object for the Logto client.
16
+ # @param navigate [Proc] The navigation function to be used for the sign-in experience.
17
+ # It should accept a URI string as the only argument. You can use the `redirect_to` method in Rails.
18
+ # @example
19
+ # ->(uri) { redirect_to(uri, allow_other_host: true) }
20
+ # @param storage [LogtoClient::AbstractStorage] The storage object for the Logto client.
21
+ # You can use the `LogtoClient::SessionStorage` for Rails applications.
22
+ # @example
23
+ # LogtoClient::SessionStorage.new(session)
24
+ # @param cache [LogtoClient::AbstractStorage] The cache object for the Logto client.
25
+ # By default, it will use the Rails cache.
26
+ def initialize(config:, navigate:, storage:, cache: RailsCacheStorage.new(app_id: config.app_id))
27
+ raise ArgumentError, "Config must be a LogtoClient::Config" unless config.is_a?(LogtoClient::Config)
28
+ raise ArgumentError, "Navigate must be a Proc" unless navigate.is_a?(Proc)
29
+ raise ArgumentError, "Storage must be a LogtoClient::AbstractStorage" unless storage.is_a?(LogtoClient::AbstractStorage)
30
+ @config = config
31
+ @navigate = navigate
32
+ @storage = storage
33
+ @cache = cache
34
+ @core = LogtoCore.new(endpoint: @config.endpoint, cache: cache)
35
+ # A local access token map cache
36
+ @access_token_map = @storage.get(STORAGE_KEY[:access_token_map]) || {}
37
+ end
38
+
39
+ # Triggers the sign-in experience.
40
+ #
41
+ # @param redirect_uri [String] The redirect URI that the user will be redirected to after the sign-in experience is completed.
42
+ # @param first_screen [String] The first screen that the user will see in the sign-in experience. Can be `signIn` or `register`.
43
+ # @param login_hint [String] The login hint to be used for the sign-in experience.
44
+ # @param direct_sign_in [Hash] The direct sign-in configuration to be used for the sign-in experience. It should contain the `method` and `target` keys.
45
+ # @param post_redirect_uri [String] The URI that the user will be redirected to after the redirect URI has successfully handled the sign-in callback.
46
+ # @param extra_params [Hash] Extra parameters to be used for the sign-in experience.
47
+ def sign_in(redirect_uri:, first_screen: nil, login_hint: nil, direct_sign_in: nil, post_redirect_uri: nil, extra_params: nil)
48
+ code_verifier = LogtoUtils.generate_code_verifier
49
+ code_challenge = LogtoUtils.generate_code_challenge(code_verifier)
50
+
51
+ state = LogtoUtils.generate_state
52
+ sign_in_uri = @core.generate_sign_in_uri(
53
+ client_id: @config.app_id,
54
+ redirect_uri: redirect_uri,
55
+ code_challenge: code_challenge,
56
+ state: state,
57
+ scopes: @config.scopes,
58
+ resources: @config.resources,
59
+ prompt: @config.prompt,
60
+ first_screen: first_screen,
61
+ login_hint: login_hint,
62
+ direct_sign_in: direct_sign_in,
63
+ extra_params: extra_params
64
+ )
65
+
66
+ save_sign_in_session(SignInSession.new(
67
+ redirect_uri: redirect_uri,
68
+ code_verifier: code_verifier,
69
+ state: state,
70
+ post_redirect_uri: post_redirect_uri
71
+ ))
72
+ clear_all_tokens
73
+
74
+ @navigate.call(sign_in_uri)
75
+ end
76
+
77
+ # Start the sign-out flow with the specified redirect URI. The URI must be
78
+ # registered in the Logto Console.
79
+ #
80
+ # It will also revoke all the tokens and clean up the storage.
81
+ #
82
+ # The user will be redirected to that URI after the sign-out flow is completed.
83
+ # If the `post_logout_redirect_uri` is not specified, the user will be redirected
84
+ # to a default page.
85
+ #
86
+ # @param post_logout_redirect_uri [String] The URI that the user will be redirected to after the sign-out flow is completed.
87
+ def sign_out(post_logout_redirect_uri: nil)
88
+ if refresh_token
89
+ @core.revoke_token(client_id: @config.app_id, client_secret: @config.app_secret, token: refresh_token)
90
+ end
91
+
92
+ uri = @core.generate_sign_out_uri(
93
+ client_id: @config.app_id, post_logout_redirect_uri: post_logout_redirect_uri
94
+ )
95
+ clear_all_tokens
96
+ @navigate.call(uri)
97
+ end
98
+
99
+ # Handle the sign-in callback from the redirect URI.
100
+ #
101
+ # @param url [String] The URL of the callback from the redirect URI. It should contain the query parameters.
102
+ # @return [String, nil] The URI that the user will be redirected to after the redirect URI has successfully handled the sign-in callback.
103
+ # It should be the same as the `post_redirect_uri` in the `sign_in` method. If it was not set, no redirection will happen.
104
+ def handle_sign_in_callback(url:)
105
+ query_params = URI.decode_www_form(URI(url).query).to_h
106
+ data = @storage.get(STORAGE_KEY[:sign_in_session])
107
+ raise LogtoError::SessionNotFoundError, "No sign-in session found" unless data
108
+
109
+ error = query_params[LogtoCore::QUERY_KEY[:error]]
110
+ error_description = query_params[LogtoCore::QUERY_KEY[:error_description]]
111
+ raise LogtoError::ServerCallbackError, "Error: #{error}, Description: #{error_description}" if error
112
+
113
+ current_session = data.is_a?(SignInSession) ? data : SignInSession.new(**data)
114
+ # A loose URI check here
115
+ raise LogtoError::SessionMismatchError, "Redirect URI mismatch" unless url.start_with?(current_session.redirect_uri)
116
+ raise LogtoError::SessionMismatchError, "No state found in query parameters" unless query_params[LogtoCore::QUERY_KEY[:state]]
117
+ raise LogtoError::SessionMismatchError, "Session state mismatch" unless current_session.state == query_params[LogtoCore::QUERY_KEY[:state]]
118
+ raise LogtoError::SessionMismatchError, "No code found in query parameters" unless query_params[LogtoCore::QUERY_KEY[:code]]
119
+
120
+ token_response = @core.fetch_token_by_authorization_code(
121
+ client_id: @config.app_id,
122
+ client_secret: @config.app_secret,
123
+ redirect_uri: current_session.redirect_uri,
124
+ code_verifier: current_session.code_verifier,
125
+ code: query_params[LogtoCore::QUERY_KEY[:code]]
126
+ )
127
+
128
+ verify_jwt(token: token_response[:id_token])
129
+ handle_token_response(token_response, resource: nil)
130
+ clear_sign_in_session
131
+
132
+ @navigate.call(current_session.post_redirect_uri)
133
+ current_session.post_redirect_uri
134
+ end
135
+
136
+ # Verify the JWT token with the configured client ID and the OIDC issuer.
137
+ #
138
+ # @param token [String] The JWT token to be verified.
139
+ def verify_jwt(token:)
140
+ raise ArgumentError, "Token must be a string" unless token.is_a?(String)
141
+
142
+ JWT.decode(
143
+ token,
144
+ nil,
145
+ true,
146
+ # List our current and future possibilities. It could use the `alg` header from the token,
147
+ # but it will be tricky to handle the case of caching.
148
+ algorithms: ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "ES256K"],
149
+ jwks: fetch_jwks,
150
+ iss: @core.oidc_config[:issuer],
151
+ verify_iss: true,
152
+ aud: @config.app_id,
153
+ verify_aud: true
154
+ )
155
+ end
156
+
157
+ # Get the raw ID token from the storage.
158
+ #
159
+ # @return [String, nil] The raw ID token.
160
+ def id_token
161
+ @storage.get(STORAGE_KEY[:id_token])
162
+ end
163
+
164
+ # Get the ID token claims from the storage.
165
+ # It will return nil if the ID token is not found.
166
+ #
167
+ # @return [LogtoCore::IdTokenClaims, nil] The ID token claims.
168
+ def id_token_claims
169
+ return nil unless (token = id_token)
170
+ LogtoUtils.parse_json_safe(JWT.decode(token, nil, false).first, LogtoCore::IdTokenClaims)
171
+ end
172
+
173
+ # Get the access token for the specified resource and organization ID. If both are nil,
174
+ # it will return the opaque access token for the OpenID Connect UserInfo endpoint.
175
+ #
176
+ # If the access token is not found or expired, it will try to use the refresh token to
177
+ # fetch a new access token, if possible.
178
+ #
179
+ # @param resource [String, nil] The resource to be accessed.
180
+ # @param organization_id [String, nil] The organization ID to be accessed.
181
+ # @return [String, nil] The access token.
182
+ def access_token(resource: nil, organization_id: nil)
183
+ raise LogtoError::NotAuthenticatedError, "Not authenticated" unless is_authenticated?
184
+ key = LogtoUtils.build_access_token_key(resource: resource, organization_id: organization_id)
185
+ token = @access_token_map[key]
186
+
187
+ # Give it some leeway
188
+ if token&.[]("expires_at")&.> Time.now + 10
189
+ return token["token"]
190
+ end
191
+
192
+ @access_token_map.delete(key)
193
+ return nil unless refresh_token
194
+
195
+ # Try to use refresh token to fetch a new access token
196
+ token_response = @core.fetch_token_by_refresh_token(
197
+ client_id: @config.app_id,
198
+ client_secret: @config.app_secret,
199
+ refresh_token: refresh_token,
200
+ resource: resource,
201
+ organization_id: organization_id
202
+ )
203
+ handle_token_response(token_response, resource: resource, organization_id: organization_id)
204
+ token_response[:access_token]
205
+ end
206
+
207
+ # Get the access token claims for the specified resource and organization ID. If both are nil,
208
+ # an ArgumentError will be raised.
209
+ #
210
+ # @param resource [String, nil] The resource to be accessed.
211
+ # @param organization_id [String, nil] The organization ID to be accessed.
212
+ # @return [LogtoCore::AccessTokenClaims, nil] The access token claims.
213
+ def access_token_claims(resource: nil, organization_id: nil)
214
+ raise ArgumentError, "Resource and organization ID cannot be nil at the same time" if
215
+ resource.nil? && organization_id.nil?
216
+ return nil unless (token = access_token(resource: resource, organization_id: organization_id))
217
+ LogtoUtils.parse_json_safe(
218
+ JWT.decode(token, nil, false).first,
219
+ LogtoCore::AccessTokenClaims
220
+ )
221
+ end
222
+
223
+ # Fetch the user information from the OpenID Connect UserInfo endpoint.
224
+ #
225
+ # @return [LogtoCore::UserInfoResponse] The user information.
226
+ def fetch_user_info
227
+ @core.fetch_user_info(access_token: access_token)
228
+ end
229
+
230
+ # Get the raw refresh token from the storage.
231
+ #
232
+ # @return [String, nil] The raw refresh token.
233
+ def refresh_token
234
+ @storage.get(STORAGE_KEY[:refresh_token])
235
+ end
236
+
237
+ # Check if the client is authenticated by checking if the ID token is present.
238
+ #
239
+ # @return [Boolean] Whether the client is authenticated.
240
+ def is_authenticated?
241
+ id_token ? true : false
242
+ end
243
+
244
+ # Clear all the tokens from the storage.
245
+ #
246
+ # It will also clear the access token map cache.
247
+ def clear_all_tokens
248
+ @access_token_map = {}
249
+ @storage.remove(STORAGE_KEY[:access_token_map])
250
+ @storage.remove(STORAGE_KEY[:id_token])
251
+ @storage.remove(STORAGE_KEY[:refresh_token])
252
+ end
253
+
254
+ protected
255
+
256
+ def handle_token_response(response, resource:, organization_id: nil)
257
+ raise ArgumentError, "Response must be a TokenResponse" unless response.is_a?(LogtoCore::TokenResponse)
258
+ response[:refresh_token] && save_refresh_token(response[:refresh_token])
259
+ response[:id_token] && save_id_token(response[:id_token])
260
+ # The response should have access token
261
+ save_access_token(
262
+ key: LogtoUtils.build_access_token_key(resource: resource, organization_id: organization_id),
263
+ token: LogtoCore::AccessToken.new(
264
+ token: response[:access_token],
265
+ scope: response[:scope],
266
+ expires_at: Time.now + response[:expires_in].to_i
267
+ )
268
+ )
269
+ end
270
+
271
+ def save_refresh_token(token)
272
+ raise ArgumentError, "Token must be a String" unless token.is_a?(String)
273
+ @storage.set(STORAGE_KEY[:refresh_token], token)
274
+ end
275
+
276
+ def save_id_token(token)
277
+ raise ArgumentError, "Token must be a String" unless token.is_a?(String)
278
+ @storage.set(STORAGE_KEY[:id_token], token)
279
+ end
280
+
281
+ def save_access_token(key:, token:)
282
+ raise ArgumentError, "Token must be an AccessToken" unless token.is_a?(LogtoCore::AccessToken)
283
+ @access_token_map[key] = token
284
+ @storage.set(STORAGE_KEY[:access_token_map], @access_token_map)
285
+ end
286
+
287
+ def save_sign_in_session(data)
288
+ raise ArgumentError, "Data must be a SignInSession" unless data.is_a?(SignInSession)
289
+ @storage.set(STORAGE_KEY[:sign_in_session], data)
290
+ end
291
+
292
+ def clear_sign_in_session
293
+ @storage.remove(STORAGE_KEY[:sign_in_session])
294
+ end
295
+
296
+ def fetch_jwks(options = {})
297
+ if options[:kid_not_found] && ((@cache&.get("jwks_last_update") || 0) < Time.now.to_i - 300)
298
+ @cache&.remove("jwks")
299
+ end
300
+
301
+ jwks_hash = @cache&.get("jwks") || begin
302
+ response = JSON.parse(Net::HTTP.get(URI.parse(@core.oidc_config[:jwks_uri])))
303
+ @cache&.set("jwks", response)
304
+ @cache&.set("jwks_last_update", Time.now.to_i)
305
+ response
306
+ end
307
+
308
+ jwks = JWT::JWK::Set.new(jwks_hash)
309
+ jwks.select! { |key| key[:use] == "sig" } # Signing Keys only
310
+ jwks
311
+ end
312
+ end
@@ -0,0 +1,8 @@
1
+ class LogtoClient
2
+ STORAGE_KEY = {
3
+ id_token: "id_token",
4
+ refresh_token: "refresh_token",
5
+ access_token_map: "access_token_map",
6
+ sign_in_session: "sign_in_session"
7
+ }
8
+ end
@@ -0,0 +1,77 @@
1
+ class LogtoClient
2
+ # :nocov:
3
+
4
+ # An abstract class for storing data.
5
+ #
6
+ # This class is used by the Logto client to store the session and token data.
7
+ #
8
+ # @abstract
9
+ class AbstractStorage
10
+ def initialize
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def get(key)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def set(key, value)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def remove(key)
23
+ raise NotImplementedError
24
+ end
25
+ end
26
+ # :nocov:
27
+
28
+ # A storage class that stores data in Rails session.
29
+ class SessionStorage < AbstractStorage
30
+ def initialize(session, app_id: nil)
31
+ @session = session
32
+ @app_id = app_id
33
+ end
34
+
35
+ def get(key)
36
+ @session[getSessionKey(key)]
37
+ end
38
+
39
+ def set(key, value)
40
+ @session[getSessionKey(key)] = value
41
+ end
42
+
43
+ def remove(key)
44
+ @session.delete(getSessionKey(key))
45
+ end
46
+
47
+ protected
48
+
49
+ def getSessionKey(key)
50
+ "logto_#{@app_id || "default"}_#{key}"
51
+ end
52
+ end
53
+
54
+ class RailsCacheStorage < AbstractStorage
55
+ def initialize(app_id: nil)
56
+ @app_id = app_id
57
+ end
58
+
59
+ def get(key)
60
+ Rails.cache.read(getCacheKey(key))
61
+ end
62
+
63
+ def set(key, value)
64
+ Rails.cache.write(getCacheKey(key), value, force: true)
65
+ end
66
+
67
+ def remove(key)
68
+ Rails.cache.delete(getCacheKey(key))
69
+ end
70
+
71
+ protected
72
+
73
+ def getCacheKey(key)
74
+ "logto_cache_#{@app_id || "default"}_#{key}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,53 @@
1
+ require_relative "../core"
2
+ require_relative "../core/utils"
3
+
4
+ class LogtoClient
5
+ # The configuration object for the Logto client.
6
+ #
7
+ # @attr [URI] endpoint The endpoint for the Logto server, you can get it from the integration guide
8
+ # or the team settings page of the Logto Console.
9
+ # @example
10
+ # 'https://foo.logto.app'
11
+ # @attr [String] app_id The client ID of your application, you can get it from the integration guide
12
+ # or the application details page of the Logto Console.
13
+ # @attr [String] app_secret The client secret of your application, you can get it from the application
14
+ # details page of the Logto Console.
15
+ # @attr [Array<String>] scopes The scopes (permissions) that your application needs to access.
16
+ # Scopes that will be added by default: `openid`, `offline_access` and `profile`.
17
+ # If resources are specified, scopes will be applied to every resource.
18
+ #
19
+ # See {https://docs.logto.io/quick-starts/rails/#scopes-and-claims Scopes and claims}
20
+ # for more information of available scopes for user information.
21
+ # @attr [Array<String>] resources The API resources that your application needs to access. You can specify
22
+ # multiple resources by providing an array of strings.
23
+ #
24
+ # See {https://docs.logto.io/docs/recipes/rbac RBAC} to learn more about how to use role-based access control (RBAC) to protect API resources.
25
+ # @attr [Array<String>] prompt The prompt parameter to be used for the authorization request.
26
+ class Config
27
+ attr_reader :endpoint, :app_id, :app_secret, :scopes, :resources, :prompt
28
+
29
+ # @param endpoint [String, URI] The endpoint for the Logto server.
30
+ # @param app_id [String] The client ID of your application.
31
+ # @param app_secret [String] The client secret of your application.
32
+ # @param scopes [Array<String>] The scopes that your application needs to access.
33
+ # @param resources [Array<String>] The API resources that your application needs to access.
34
+ # @param prompt [String, Array<String>] The prompt parameter to be used for the authorization request.
35
+ # @param include_reserved_scopes [Boolean] Whether to include reserved scopes (`openid`, `offline_access` and `profile`) in the scopes.
36
+ def initialize(endpoint:, app_id:, app_secret:, scopes: [], resources: [], prompt: LogtoCore::PROMPT[:consent], include_reserved_scopes: true)
37
+ raise ArgumentError, "Scopes must be an array" if scopes && !scopes.is_a?(Array)
38
+ raise ArgumentError, "Resources must be an array" if resources && !resources.is_a?(Array)
39
+ raise ArgumentError, "Endpoint must not be empty" if endpoint.nil? || endpoint == ""
40
+
41
+ computed_scopes = include_reserved_scopes ? LogtoUtils.with_reserved_scopes(scopes) : scopes
42
+
43
+ @endpoint = endpoint.is_a?(URI) ? endpoint : URI.parse(endpoint)
44
+ @app_id = app_id
45
+ @app_secret = app_secret
46
+ @scopes = computed_scopes
47
+ @resources = computed_scopes.include?(LogtoCore::USER_SCOPE[:organizations]) ? ([LogtoCore::RESERVED_RESOURCE[:organization]] + resources).uniq : resources
48
+ @prompt = prompt.is_a?(Array) ? prompt : [prompt]
49
+ end
50
+ end
51
+
52
+ SignInSession = Struct.new(:redirect_uri, :code_verifier, :state, :post_redirect_uri, keyword_init: true)
53
+ end
@@ -0,0 +1 @@
1
+ require_relative "client/index"
@@ -0,0 +1,35 @@
1
+ # The base class for all errors in the Logto SDK.
2
+ class LogtoError < StandardError
3
+ def initialize(message)
4
+ super
5
+ end
6
+
7
+ # The base class for response errors from Logto server.
8
+ #
9
+ # @attr [Net::HTTPResponse, nil] response The response object that caused this error.
10
+ class ResponseError < LogtoError
11
+ attr_reader :response
12
+
13
+ def initialize(message, response: nil)
14
+ raise ArgumentError, "response must be a Net::HTTPResponse or nil" unless response.nil? || response.is_a?(Net::HTTPResponse)
15
+ super(message)
16
+ @response = response
17
+ end
18
+ end
19
+
20
+ # Raise when token response is invalid.
21
+ class TokenError < ResponseError
22
+ end
23
+
24
+ # Raise when revocation response is invalid.
25
+ class RevocationError < ResponseError
26
+ end
27
+
28
+ # Raise when the userinfo response is invalid.
29
+ class UserInfoError < ResponseError
30
+ end
31
+
32
+ # Raise when the current user is not authenticated but the operation requires authentication.
33
+ class NotAuthenticatedError < LogtoError
34
+ end
35
+ end
@@ -0,0 +1,155 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "jwt"
4
+ require_relative "index_types"
5
+ require_relative "index_constants"
6
+ require_relative "utils"
7
+ require_relative "errors"
8
+
9
+ class LogtoCore
10
+ attr_reader :endpoint, :oidc_config
11
+
12
+ def initialize(endpoint:, cache: nil)
13
+ @endpoint = endpoint
14
+ @cache = cache
15
+ @oidc_config = fetch_oidc_config
16
+ end
17
+
18
+ def revoke_token(client_id:, client_secret:, token:)
19
+ response = Net::HTTP.post_form(
20
+ URI.parse(oidc_config.revocation_endpoint),
21
+ {
22
+ QUERY_KEY[:token] => token,
23
+ QUERY_KEY[:client_id] => client_id,
24
+ QUERY_KEY[:client_secret] => client_secret
25
+ }
26
+ )
27
+
28
+ raise LogtoError::RevocationError.new(response.message, response: response) unless
29
+ response.is_a?(Net::HTTPSuccess)
30
+ end
31
+
32
+ def fetch_token_by_authorization_code(client_id:, client_secret:, redirect_uri:, code_verifier:, code:, resource: nil)
33
+ parameters = {
34
+ QUERY_KEY[:client_id] => client_id,
35
+ QUERY_KEY[:client_secret] => client_secret,
36
+ QUERY_KEY[:code] => code,
37
+ QUERY_KEY[:code_verifier] => code_verifier,
38
+ QUERY_KEY[:redirect_uri] => redirect_uri,
39
+ QUERY_KEY[:grant_type] => TOKEN_GRANT_TYPE[:authorization_code]
40
+ }
41
+ parameters[QUERY_KEY[:resource]] = resource if resource
42
+
43
+ response = Net::HTTP.post_form(
44
+ URI.parse(oidc_config.token_endpoint),
45
+ parameters
46
+ )
47
+
48
+ raise LogtoError::TokenError.new(response.message, response: response) unless
49
+ response.is_a?(Net::HTTPSuccess)
50
+
51
+ LogtoUtils.parse_json_safe(response.body, TokenResponse)
52
+ end
53
+
54
+ def fetch_token_by_refresh_token(client_id:, client_secret:, refresh_token:, resource: nil, organization_id: nil, scopes: nil)
55
+ raise ArgumentError, "Scopes must be an array" if scopes && !scopes.is_a?(Array)
56
+
57
+ parameters = {
58
+ QUERY_KEY[:client_id] => client_id,
59
+ QUERY_KEY[:client_secret] => client_secret,
60
+ QUERY_KEY[:refresh_token] => refresh_token,
61
+ QUERY_KEY[:grant_type] => TOKEN_GRANT_TYPE[:refresh_token]
62
+ }
63
+ parameters[QUERY_KEY[:resource]] = resource if resource
64
+ parameters[QUERY_KEY[:organization_id]] = organization_id if organization_id
65
+ parameters[QUERY_KEY[:scope]] = scopes.join(" ") if scopes&.any?
66
+
67
+ response = Net::HTTP.post_form(
68
+ URI.parse(oidc_config.token_endpoint),
69
+ parameters
70
+ )
71
+
72
+ raise LogtoError::TokenError.new(response.message, response: response) unless
73
+ response.is_a?(Net::HTTPSuccess)
74
+ LogtoUtils.parse_json_safe(response.body, TokenResponse)
75
+ end
76
+
77
+ def fetch_user_info(access_token:)
78
+ uri = URI.parse(oidc_config.userinfo_endpoint)
79
+ request = Net::HTTP::Get.new(uri)
80
+ request["Authorization"] = "Bearer #{access_token}"
81
+
82
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
83
+ http.request(request)
84
+ end
85
+
86
+ raise LogtoError::UserInfoError.new(response.message, response: response) unless
87
+ response.is_a?(Net::HTTPSuccess)
88
+ LogtoUtils.parse_json_safe(response.body, UserInfoResponse)
89
+ end
90
+
91
+ def generate_sign_in_uri(client_id:, redirect_uri:, code_challenge:, state:, scopes: nil, resources: nil, prompt: nil, first_screen: nil, interaction_mode: nil, login_hint: nil, direct_sign_in: nil, extra_params: nil, include_reserved_scopes: true)
92
+ parameters = {
93
+ QUERY_KEY[:client_id] => client_id,
94
+ QUERY_KEY[:redirect_uri] => redirect_uri,
95
+ QUERY_KEY[:code_challenge] => code_challenge,
96
+ QUERY_KEY[:code_challenge_method] => CODE_CHALLENGE_METHOD[:S256],
97
+ QUERY_KEY[:state] => state,
98
+ QUERY_KEY[:response_type] => "code"
99
+ }
100
+
101
+ parameters[QUERY_KEY[:prompt]] = prompt&.any? ? prompt.join(" ") : PROMPT[:consent]
102
+
103
+ computed_scopes = include_reserved_scopes ? LogtoUtils.with_reserved_scopes(scopes).join(" ") : scopes&.join(" ")
104
+ parameters[QUERY_KEY[:scope]] = computed_scopes if computed_scopes
105
+
106
+ parameters[QUERY_KEY[:login_hint]] = login_hint if login_hint
107
+
108
+ if direct_sign_in
109
+ parameters[QUERY_KEY[:direct_sign_in]] = "#{direct_sign_in[:method]}:#{direct_sign_in[:target]}"
110
+ end
111
+
112
+ parameters[QUERY_KEY[:resource]] = resources if resources&.any?
113
+
114
+ if first_screen
115
+ parameters[QUERY_KEY[:first_screen]] = first_screen
116
+ elsif interaction_mode
117
+ parameters[QUERY_KEY[:interaction_mode]] = interaction_mode
118
+ end
119
+
120
+ extra_params&.each do |key, value|
121
+ parameters[key] = value
122
+ end
123
+
124
+ parameters.each_key do |key|
125
+ raise ArgumentError, "Parameters contain nil key, please check the input" if key.nil?
126
+ end
127
+
128
+ uri = URI.parse(oidc_config.authorization_endpoint)
129
+ uri.query = URI.encode_www_form(parameters)
130
+ uri.to_s
131
+ end
132
+
133
+ def generate_sign_out_uri(client_id:, post_logout_redirect_uri: nil)
134
+ parameters = {
135
+ QUERY_KEY[:client_id] => client_id
136
+ }
137
+ parameters[QUERY_KEY[:post_logout_redirect_uri]] = post_logout_redirect_uri if post_logout_redirect_uri
138
+
139
+ uri = URI.parse(oidc_config.end_session_endpoint)
140
+ uri.query = URI.encode_www_form(parameters)
141
+ uri.to_s
142
+ end
143
+
144
+ protected
145
+
146
+ # Function to fetch OIDC config from a Logto endpoint
147
+ def fetch_oidc_config
148
+ config_hash = @cache&.get("oidc_config") || begin
149
+ response = Net::HTTP.get(URI.join(endpoint, DISCOVERY_PATH))
150
+ @cache&.set("oidc_config", response)
151
+ response
152
+ end
153
+ LogtoUtils.parse_json_safe(config_hash, OidcConfigResponse)
154
+ end
155
+ end
@@ -0,0 +1,78 @@
1
+ class LogtoCore
2
+ DISCOVERY_PATH = "/oidc/.well-known/openid-configuration"
3
+
4
+ QUERY_KEY = {
5
+ client_id: "client_id",
6
+ client_secret: "client_secret",
7
+ token: "token",
8
+ code: "code",
9
+ code_verifier: "code_verifier",
10
+ code_challenge: "code_challenge",
11
+ code_challenge_method: "code_challenge_method",
12
+ prompt: "prompt",
13
+ redirect_uri: "redirect_uri",
14
+ post_logout_redirect_uri: "post_logout_redirect_uri",
15
+ grant_type: "grant_type",
16
+ refresh_token: "refresh_token",
17
+ scope: "scope",
18
+ state: "state",
19
+ response_type: "response_type",
20
+ resource: "resource",
21
+ organization_id: "organization_id",
22
+ login_hint: "login_hint",
23
+ direct_sign_in: "direct_sign_in",
24
+ first_screen: "first_screen",
25
+ interaction_mode: "interaction_mode",
26
+ error: "error",
27
+ error_description: "error_description"
28
+ }
29
+
30
+ TOKEN_GRANT_TYPE = {
31
+ authorization_code: "authorization_code",
32
+ refresh_token: "refresh_token"
33
+ }
34
+
35
+ CODE_CHALLENGE_METHOD = {
36
+ S256: "S256"
37
+ }
38
+
39
+ PROMPT = {
40
+ login: "login",
41
+ none: "none",
42
+ consent: "consent",
43
+ select_account: "select_account"
44
+ }
45
+
46
+ # Scopes that reserved by Logto, which will be added to the auth request automatically.
47
+ RESERVED_SCOPE = {
48
+ openid: "openid",
49
+ offline_access: "offline_access",
50
+ profile: "profile"
51
+ }
52
+
53
+ # Scopes for ID Token and Userinfo Endpoint.
54
+ USER_SCOPE = {
55
+ # Scope for basic user ingo.
56
+ profile: "profile",
57
+ # Scope for email address.
58
+ email: "email",
59
+ # Scope for phone number.
60
+ phone: "phone",
61
+ # Scope for user's custom data.
62
+ custom_data: "custom_data",
63
+ # Scope for user's social identity details.
64
+ identities: "identities",
65
+ # Scope for user's roles.
66
+ roles: "roles",
67
+ # Scope for user's organization IDs and perform organization token grant per {https://github.com/logto-io/rfcs RFC 0001}.
68
+ organizations: "urn:logto:scope:organizations",
69
+ # Scope for user's organization roles per {https://github.com/logto-io/rfcs RFC 0001}.
70
+ organization_roles: "urn:logto:scope:organization_roles"
71
+ }
72
+
73
+ # Resources that reserved by Logto, which cannot be defined by users.
74
+ RESERVED_RESOURCE = {
75
+ # The resource for organization template per {https://github.com/logto-io/rfcs RFC 0001}.
76
+ organization: "urn:logto:resource:organizations"
77
+ }
78
+ end
@@ -0,0 +1,93 @@
1
+ require "net/http"
2
+ require "json"
3
+ require_relative "utils"
4
+
5
+ class LogtoCore
6
+ # The non-exhaustive list of keys that return from the {https://openid.net/specs/openid-connect-discovery-1_0.html OpenID Connect Discovery} endpoint.
7
+ OidcConfigResponse = Struct.new(
8
+ :authorization_endpoint,
9
+ :token_endpoint,
10
+ :userinfo_endpoint,
11
+ :end_session_endpoint,
12
+ :revocation_endpoint,
13
+ :jwks_uri,
14
+ :issuer,
15
+ :unknown_keys,
16
+ keyword_init: true
17
+ )
18
+
19
+ # The response from the {https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint Token Endpoint} when fetching a token.
20
+ TokenResponse = Struct.new(
21
+ :access_token,
22
+ :refresh_token,
23
+ :id_token,
24
+ :scope,
25
+ :token_type,
26
+ :expires_in,
27
+ :unknown_keys,
28
+ keyword_init: true
29
+ )
30
+
31
+ # The claims that are returned in the {https://openid.net/specs/openid-connect-core-1_0.html#IDToken ID Token}.
32
+ #
33
+ # @attr [String] iss The issuer of this token.
34
+ # @attr [String] sub The subject (user ID) of this token.
35
+ # @attr [String] aud The audience (client ID) of this token.
36
+ # @attr [Integer] exp The expiration time of this token.
37
+ # @attr [Integer] iat The time at which this token was issued.
38
+ # @attr [String, nil] at_hash The access token hash value.
39
+ # @attr [String, nil] name The full name of the user.
40
+ # @attr [String, nil] username The username of the user.
41
+ # @attr [String, nil] picture The URL of the user's profile picture.
42
+ # @attr [String, nil] email The email address of the user.
43
+ # @attr [Boolean] email_verified Whether the user's email address has been verified.
44
+ # @attr [String, nil] phone_number The phone number of the user.
45
+ # @attr [Boolean] phone_number_verified Whether the user's phone number has been verified.
46
+ # @attr [Array<String>] organizations The organization IDs that the user has membership in.
47
+ # @attr [Array<String>] organization_roles All organization roles that the user has.
48
+ # The format is `[organizationId]:[roleName]`.
49
+ #
50
+ # Note that not all organizations are included in this list, only the ones that the user has roles in.
51
+ # @example
52
+ # ['org1:admin', 'org2:member'] # The user is an admin of org1 and a member of org2.
53
+ # @attr [Array<String>] roles The roles that the user has for API resources.
54
+ IdTokenClaims = Struct.new(
55
+ :iss, :sub, :aud, :exp, :iat, :at_hash, :name, :username, :picture,
56
+ :email, :email_verified, :phone_number, :phone_number_verified,
57
+ :organizations, :organization_roles, :roles, :unknown_keys,
58
+ keyword_init: true
59
+ )
60
+
61
+ # The claims that are returned in the {https://openid.net/specs/openid-connect-core-1_0.html#UserInfo UserInfo} response.
62
+ #
63
+ # @see IdTokenClaims for the common claims that are returned in the ID token.
64
+ #
65
+ # @attr [Hash] custom_data The custom data that is stored in the user profile.
66
+ # @attr [Hash] identities The social sign-in identities that are linked to the user.
67
+ UserInfoResponse = Struct.new(
68
+ *IdTokenClaims.members, :custom_data, :identities,
69
+ keyword_init: true
70
+ )
71
+
72
+ # The claims that are returned in the access token.
73
+ #
74
+ # @attr [String] jti The JWT ID of this token.
75
+ # @attr [String] iss The issuer of this token.
76
+ # @attr [String] sub The subject (user ID or client ID) of this token.
77
+ # @attr [String] aud The audience (API resource or organization ID) of this token.
78
+ # @attr [Integer] exp The expiration time of this token.
79
+ # @attr [Integer] iat The time at which this token was issued.
80
+ # @attr [String, nil] client_id The client ID that this token was issued to.
81
+ # @attr [String] scope The scopes that this token has.
82
+ AccessTokenClaims = Struct.new(
83
+ :jti, :iss, :sub, :aud, :exp, :iat, :client_id, :scope, :unknown_keys,
84
+ keyword_init: true
85
+ )
86
+
87
+ # The structured access token.
88
+ #
89
+ # @attr [String] token The access token string.
90
+ # @attr [String] scope The scopes that this token has.
91
+ # @attr [Integer] expires_at The epoch timestamp when this token will expire.
92
+ AccessToken = Struct.new(:token, :scope, :expires_at, keyword_init: true)
93
+ end
@@ -0,0 +1,46 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require_relative "index_constants"
4
+
5
+ module LogtoUtils
6
+ # Parses a JSON string and maps it to a given Struct class, handling unknown keys.
7
+ #
8
+ # @param json_str_or_hash [String, Hash] The JSON string or hash to be parsed.
9
+ # @param struct_class [Class] The Struct class to map the JSON data to. The strcut class must have a
10
+ # `:unknown_keys` member and a `keyword_init: true` keyword argument.
11
+ # @return [Struct] An instance of the given Struct class populated with known keys and a hash of unknown keys.
12
+ def self.parse_json_safe(json_str_or_hash, struct_class)
13
+ data = json_str_or_hash.is_a?(String) ? JSON.parse(json_str_or_hash) : json_str_or_hash
14
+ known_keys = struct_class.members - [:unknown_keys]
15
+ known_data = data.select { |key, _| known_keys.include?(key.to_sym) }
16
+ unknown_data = data.reject { |key, _| known_keys.include?(key.to_sym) }
17
+ struct_class.new(**known_data, unknown_keys: unknown_data)
18
+ end
19
+
20
+ # @param scopes [Array<String>, nil] The scopes to be added reserved scopes to.
21
+ # @return [Array<String>] The scopes with reserved scopes added.
22
+ # @example
23
+ # LogtoUtils.with_reserved_scopes(['foo', 'bar'])
24
+ # # => ['foo', 'bar', 'openid', 'offline_access', 'profile']
25
+ def self.with_reserved_scopes(scopes)
26
+ unique_scopes = scopes || []
27
+ unique_scopes += LogtoCore::RESERVED_SCOPE.values
28
+ unique_scopes.uniq
29
+ end
30
+
31
+ def self.generate_code_verifier
32
+ SecureRandom.urlsafe_base64(32)
33
+ end
34
+
35
+ def self.generate_code_challenge(code_verifier)
36
+ Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).tr("=", "")
37
+ end
38
+
39
+ def self.generate_state
40
+ SecureRandom.urlsafe_base64(32)
41
+ end
42
+
43
+ def self.build_access_token_key(resource:, organization_id: nil)
44
+ "#{organization_id ? "##{organization_id}" : ""}:#{resource || "default"}"
45
+ end
46
+ end
data/lib/logto/core.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative "core/index"
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: logto
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Silverhand Inc.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-06-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jwt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.8'
27
+ description: Logto is an open-source Auth0 alternative designed for modern apps and
28
+ SaaS products.
29
+ email: contact@logto.io
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - README.md
35
+ - lib/logto/client.rb
36
+ - lib/logto/client/errors.rb
37
+ - lib/logto/client/index.rb
38
+ - lib/logto/client/index_constants.rb
39
+ - lib/logto/client/index_storage.rb
40
+ - lib/logto/client/index_types.rb
41
+ - lib/logto/core.rb
42
+ - lib/logto/core/errors.rb
43
+ - lib/logto/core/index.rb
44
+ - lib/logto/core/index_constants.rb
45
+ - lib/logto/core/index_types.rb
46
+ - lib/logto/core/utils.rb
47
+ homepage: https://logto.io/
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ source_code_uri: https://github.com/logto-io/ruby
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 2.7.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.5.11
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: The Logto SDK for Ruby.
71
+ test_files: []