iden 0.0.1

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: 8a3b382bc495c147f8a4c8ce0714d30be1c5e46cf329314ec2415be0b577e0d1
4
+ data.tar.gz: 2ffe77bca1bea7346d67a67745f31766f9ce735aae86425a8799f5105287d19c
5
+ SHA512:
6
+ metadata.gz: 41af1d79582ca7b286b18dbf98ebdc1c706a84940a00d9487e3b20987dd39923bd2ef5bb9c102c795e84dfb539950874a7788b10bf74b8206de42eef09edc64b
7
+ data.tar.gz: 95609ca87e5d18ac50049d26888c1048688017715aea527b50d5e1ba87778da18782ccec600255daf6967069bf17b10eb4422c73443a32786235bfdc8668605c
data/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # iden Ruby SDK
2
+
3
+ Current iden release: **0.0.1**. Install `iden` from RubyGems.
4
+
5
+ > This project is derived from the [Logto Ruby SDK](https://github.com/logto-io/ruby). The `Logto` Ruby module is intentionally retained for source compatibility.
6
+
7
+ [![Version](https://img.shields.io/gem/v/iden)][RubyGems]
8
+ [![Downloads](https://img.shields.io/gem/dt/iden)][RubyGems]
9
+ [![iden](https://img.shields.io/badge/for-iden-5B5CF6)][Website]
10
+ [![Discord](https://img.shields.io/badge/discord-join-5865f2?logo=discord)][Discord]
11
+
12
+ This is the Ruby SDK for iden. It provides a simple way to integrate iden with your Ruby applications.
13
+
14
+ 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.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bundle add iden
20
+ ```
21
+
22
+ Or whatever your preferred method of adding gems is.
23
+
24
+ ## Sample
25
+
26
+ A sample Ruby on Rails application is available [here](https://github.com/ryan-iden/ruby/tree/HEAD/logto-sample).
27
+
28
+ ## Resources
29
+
30
+ - [Documentation](https://docs.logto.io/quick-starts/ruby/)
31
+ - [Website][Website]
32
+ - [Discord][Discord]
33
+
34
+ [RubyGems]: https://rubygems.org/gems/iden
35
+ [Website]: https://github.com/ryan-iden/iden
36
+ [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,314 @@
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 `sign_in`, `register`, `reset_password`, `single_sign_on`, `identifier:sign_in` or `identifier:register`.
43
+ # @param identifiers [Array<String>] The identifiers to be used for the first screen, only effective when `first_screen` is `identifier:sign_in`, `identifier:register`, or `reset_password`. Available options are `email`, `phone`, `username`.
44
+ # @param login_hint [String] The login hint to be used for the sign-in experience.
45
+ # @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.
46
+ # @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.
47
+ # @param extra_params [Hash] Extra parameters to be used for the sign-in experience.
48
+ def sign_in(redirect_uri:, first_screen: nil, identifiers: nil, login_hint: nil, direct_sign_in: nil, post_redirect_uri: nil, extra_params: nil)
49
+ code_verifier = LogtoUtils.generate_code_verifier
50
+ code_challenge = LogtoUtils.generate_code_challenge(code_verifier)
51
+
52
+ state = LogtoUtils.generate_state
53
+ sign_in_uri = @core.generate_sign_in_uri(
54
+ client_id: @config.app_id,
55
+ redirect_uri: redirect_uri,
56
+ code_challenge: code_challenge,
57
+ state: state,
58
+ scopes: @config.scopes,
59
+ resources: @config.resources,
60
+ prompt: @config.prompt,
61
+ first_screen: first_screen,
62
+ identifiers: identifiers,
63
+ login_hint: login_hint,
64
+ direct_sign_in: direct_sign_in,
65
+ extra_params: extra_params
66
+ )
67
+
68
+ save_sign_in_session(SignInSession.new(
69
+ redirect_uri: redirect_uri,
70
+ code_verifier: code_verifier,
71
+ state: state,
72
+ post_redirect_uri: post_redirect_uri
73
+ ))
74
+ clear_all_tokens
75
+
76
+ @navigate.call(sign_in_uri)
77
+ end
78
+
79
+ # Start the sign-out flow with the specified redirect URI. The URI must be
80
+ # registered in the Logto Console.
81
+ #
82
+ # It will also revoke all the tokens and clean up the storage.
83
+ #
84
+ # The user will be redirected to that URI after the sign-out flow is completed.
85
+ # If the `post_logout_redirect_uri` is not specified, the user will be redirected
86
+ # to a default page.
87
+ #
88
+ # @param post_logout_redirect_uri [String] The URI that the user will be redirected to after the sign-out flow is completed.
89
+ def sign_out(post_logout_redirect_uri: nil)
90
+ if refresh_token
91
+ @core.revoke_token(client_id: @config.app_id, client_secret: @config.app_secret, token: refresh_token)
92
+ end
93
+
94
+ uri = @core.generate_sign_out_uri(
95
+ client_id: @config.app_id, post_logout_redirect_uri: post_logout_redirect_uri
96
+ )
97
+ clear_all_tokens
98
+ @navigate.call(uri)
99
+ end
100
+
101
+ # Handle the sign-in callback from the redirect URI.
102
+ #
103
+ # @param url [String] The URL of the callback from the redirect URI. It should contain the query parameters.
104
+ # @return [String, nil] The URI that the user will be redirected to after the redirect URI has successfully handled the sign-in callback.
105
+ # It should be the same as the `post_redirect_uri` in the `sign_in` method. If it was not set, no redirection will happen.
106
+ def handle_sign_in_callback(url:)
107
+ query_params = URI.decode_www_form(URI(url).query).to_h
108
+ data = @storage.get(STORAGE_KEY[:sign_in_session])
109
+ raise LogtoError::SessionNotFoundError, "No sign-in session found" unless data
110
+
111
+ error = query_params[LogtoCore::QUERY_KEY[:error]]
112
+ error_description = query_params[LogtoCore::QUERY_KEY[:error_description]]
113
+ raise LogtoError::ServerCallbackError, "Error: #{error}, Description: #{error_description}" if error
114
+
115
+ current_session = data.is_a?(SignInSession) ? data : SignInSession.new(**data)
116
+ # A loose URI check here
117
+ raise LogtoError::SessionMismatchError, "Redirect URI mismatch" unless url.start_with?(current_session.redirect_uri)
118
+ raise LogtoError::SessionMismatchError, "No state found in query parameters" unless query_params[LogtoCore::QUERY_KEY[:state]]
119
+ raise LogtoError::SessionMismatchError, "Session state mismatch" unless current_session.state == query_params[LogtoCore::QUERY_KEY[:state]]
120
+ raise LogtoError::SessionMismatchError, "No code found in query parameters" unless query_params[LogtoCore::QUERY_KEY[:code]]
121
+
122
+ token_response = @core.fetch_token_by_authorization_code(
123
+ client_id: @config.app_id,
124
+ client_secret: @config.app_secret,
125
+ redirect_uri: current_session.redirect_uri,
126
+ code_verifier: current_session.code_verifier,
127
+ code: query_params[LogtoCore::QUERY_KEY[:code]]
128
+ )
129
+
130
+ verify_jwt(token: token_response[:id_token])
131
+ handle_token_response(token_response, resource: nil)
132
+ clear_sign_in_session
133
+
134
+ @navigate.call(current_session.post_redirect_uri)
135
+ current_session.post_redirect_uri
136
+ end
137
+
138
+ # Verify the JWT token with the configured client ID and the OIDC issuer.
139
+ #
140
+ # @param token [String] The JWT token to be verified.
141
+ def verify_jwt(token:)
142
+ raise ArgumentError, "Token must be a string" unless token.is_a?(String)
143
+
144
+ JWT.decode(
145
+ token,
146
+ nil,
147
+ true,
148
+ # List our current and future possibilities. It could use the `alg` header from the token,
149
+ # but it will be tricky to handle the case of caching.
150
+ algorithms: ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "ES256K"],
151
+ jwks: fetch_jwks,
152
+ iss: @core.oidc_config[:issuer],
153
+ verify_iss: true,
154
+ aud: @config.app_id,
155
+ verify_aud: true
156
+ )
157
+ end
158
+
159
+ # Get the raw ID token from the storage.
160
+ #
161
+ # @return [String, nil] The raw ID token.
162
+ def id_token
163
+ @storage.get(STORAGE_KEY[:id_token])
164
+ end
165
+
166
+ # Get the ID token claims from the storage.
167
+ # It will return nil if the ID token is not found.
168
+ #
169
+ # @return [LogtoCore::IdTokenClaims, nil] The ID token claims.
170
+ def id_token_claims
171
+ return nil unless (token = id_token)
172
+ LogtoUtils.parse_json_safe(JWT.decode(token, nil, false).first, LogtoCore::IdTokenClaims)
173
+ end
174
+
175
+ # Get the access token for the specified resource and organization ID. If both are nil,
176
+ # it will return the opaque access token for the OpenID Connect UserInfo endpoint.
177
+ #
178
+ # If the access token is not found or expired, it will try to use the refresh token to
179
+ # fetch a new access token, if possible.
180
+ #
181
+ # @param resource [String, nil] The resource to be accessed.
182
+ # @param organization_id [String, nil] The organization ID to be accessed.
183
+ # @return [String, nil] The access token.
184
+ def access_token(resource: nil, organization_id: nil)
185
+ raise LogtoError::NotAuthenticatedError, "Not authenticated" unless is_authenticated?
186
+ key = LogtoUtils.build_access_token_key(resource: resource, organization_id: organization_id)
187
+ token = @access_token_map[key]
188
+
189
+ # Give it some leeway
190
+ if token&.[]("expires_at")&.> Time.now + 10
191
+ return token["token"]
192
+ end
193
+
194
+ @access_token_map.delete(key)
195
+ return nil unless refresh_token
196
+
197
+ # Try to use refresh token to fetch a new access token
198
+ token_response = @core.fetch_token_by_refresh_token(
199
+ client_id: @config.app_id,
200
+ client_secret: @config.app_secret,
201
+ refresh_token: refresh_token,
202
+ resource: resource,
203
+ organization_id: organization_id
204
+ )
205
+ handle_token_response(token_response, resource: resource, organization_id: organization_id)
206
+ token_response[:access_token]
207
+ end
208
+
209
+ # Get the access token claims for the specified resource and organization ID. If both are nil,
210
+ # an ArgumentError will be raised.
211
+ #
212
+ # @param resource [String, nil] The resource to be accessed.
213
+ # @param organization_id [String, nil] The organization ID to be accessed.
214
+ # @return [LogtoCore::AccessTokenClaims, nil] The access token claims.
215
+ def access_token_claims(resource: nil, organization_id: nil)
216
+ raise ArgumentError, "Resource and organization ID cannot be nil at the same time" if
217
+ resource.nil? && organization_id.nil?
218
+ return nil unless (token = access_token(resource: resource, organization_id: organization_id))
219
+ LogtoUtils.parse_json_safe(
220
+ JWT.decode(token, nil, false).first,
221
+ LogtoCore::AccessTokenClaims
222
+ )
223
+ end
224
+
225
+ # Fetch the user information from the OpenID Connect UserInfo endpoint.
226
+ #
227
+ # @return [LogtoCore::UserInfoResponse] The user information.
228
+ def fetch_user_info
229
+ @core.fetch_user_info(access_token: access_token)
230
+ end
231
+
232
+ # Get the raw refresh token from the storage.
233
+ #
234
+ # @return [String, nil] The raw refresh token.
235
+ def refresh_token
236
+ @storage.get(STORAGE_KEY[:refresh_token])
237
+ end
238
+
239
+ # Check if the client is authenticated by checking if the ID token is present.
240
+ #
241
+ # @return [Boolean] Whether the client is authenticated.
242
+ def is_authenticated?
243
+ id_token ? true : false
244
+ end
245
+
246
+ # Clear all the tokens from the storage.
247
+ #
248
+ # It will also clear the access token map cache.
249
+ def clear_all_tokens
250
+ @access_token_map = {}
251
+ @storage.remove(STORAGE_KEY[:access_token_map])
252
+ @storage.remove(STORAGE_KEY[:id_token])
253
+ @storage.remove(STORAGE_KEY[:refresh_token])
254
+ end
255
+
256
+ protected
257
+
258
+ def handle_token_response(response, resource:, organization_id: nil)
259
+ raise ArgumentError, "Response must be a TokenResponse" unless response.is_a?(LogtoCore::TokenResponse)
260
+ response[:refresh_token] && save_refresh_token(response[:refresh_token])
261
+ response[:id_token] && save_id_token(response[:id_token])
262
+ # The response should have access token
263
+ save_access_token(
264
+ key: LogtoUtils.build_access_token_key(resource: resource, organization_id: organization_id),
265
+ token: LogtoCore::AccessToken.new(
266
+ token: response[:access_token],
267
+ scope: response[:scope],
268
+ expires_at: Time.now + response[:expires_in].to_i
269
+ )
270
+ )
271
+ end
272
+
273
+ def save_refresh_token(token)
274
+ raise ArgumentError, "Token must be a String" unless token.is_a?(String)
275
+ @storage.set(STORAGE_KEY[:refresh_token], token)
276
+ end
277
+
278
+ def save_id_token(token)
279
+ raise ArgumentError, "Token must be a String" unless token.is_a?(String)
280
+ @storage.set(STORAGE_KEY[:id_token], token)
281
+ end
282
+
283
+ def save_access_token(key:, token:)
284
+ raise ArgumentError, "Token must be an AccessToken" unless token.is_a?(LogtoCore::AccessToken)
285
+ @access_token_map[key] = token
286
+ @storage.set(STORAGE_KEY[:access_token_map], @access_token_map)
287
+ end
288
+
289
+ def save_sign_in_session(data)
290
+ raise ArgumentError, "Data must be a SignInSession" unless data.is_a?(SignInSession)
291
+ @storage.set(STORAGE_KEY[:sign_in_session], data)
292
+ end
293
+
294
+ def clear_sign_in_session
295
+ @storage.remove(STORAGE_KEY[:sign_in_session])
296
+ end
297
+
298
+ def fetch_jwks(options = {})
299
+ if options[:kid_not_found] && ((@cache&.get("jwks_last_update") || 0) < Time.now.to_i - 300)
300
+ @cache&.remove("jwks")
301
+ end
302
+
303
+ jwks_hash = @cache&.get("jwks") || begin
304
+ response = JSON.parse(Net::HTTP.get(URI.parse(@core.oidc_config[:jwks_uri])))
305
+ @cache&.set("jwks", response)
306
+ @cache&.set("jwks_last_update", Time.now.to_i)
307
+ response
308
+ end
309
+
310
+ jwks = JWT::JWK::Set.new(jwks_hash)
311
+ jwks.select! { |key| key[:use] == "sig" } # Signing Keys only
312
+ jwks
313
+ end
314
+ 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[get_session_key(key)]
37
+ end
38
+
39
+ def set(key, value)
40
+ @session[get_session_key(key)] = value
41
+ end
42
+
43
+ def remove(key)
44
+ @session.delete(get_session_key(key))
45
+ end
46
+
47
+ protected
48
+
49
+ def get_session_key(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,172 @@
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(
92
+ client_id:,
93
+ redirect_uri:,
94
+ code_challenge:,
95
+ state:,
96
+ scopes: nil,
97
+ resources: nil,
98
+ prompt: nil,
99
+ first_screen: nil,
100
+ identifiers: nil,
101
+ interaction_mode: nil,
102
+ login_hint: nil,
103
+ direct_sign_in: nil,
104
+ extra_params: nil,
105
+ include_reserved_scopes: true
106
+ )
107
+ parameters = {
108
+ QUERY_KEY[:client_id] => client_id,
109
+ QUERY_KEY[:redirect_uri] => redirect_uri,
110
+ QUERY_KEY[:code_challenge] => code_challenge,
111
+ QUERY_KEY[:code_challenge_method] => CODE_CHALLENGE_METHOD[:S256],
112
+ QUERY_KEY[:state] => state,
113
+ QUERY_KEY[:response_type] => "code"
114
+ }
115
+
116
+ parameters[QUERY_KEY[:prompt]] = prompt&.any? ? prompt.join(" ") : PROMPT[:consent]
117
+
118
+ computed_scopes = include_reserved_scopes ? LogtoUtils.with_reserved_scopes(scopes).join(" ") : scopes&.join(" ")
119
+ parameters[QUERY_KEY[:scope]] = computed_scopes if computed_scopes
120
+
121
+ parameters[QUERY_KEY[:login_hint]] = login_hint if login_hint
122
+
123
+ if direct_sign_in
124
+ parameters[QUERY_KEY[:direct_sign_in]] = "#{direct_sign_in[:method]}:#{direct_sign_in[:target]}"
125
+ end
126
+
127
+ parameters[QUERY_KEY[:resource]] = resources if resources&.any?
128
+
129
+ if first_screen
130
+ parameters[QUERY_KEY[:first_screen]] = first_screen
131
+ elsif interaction_mode
132
+ parameters[QUERY_KEY[:interaction_mode]] = interaction_mode
133
+ end
134
+
135
+ parameters[QUERY_KEY[:identifier]] = identifiers.join(" ") if identifiers&.any?
136
+
137
+ extra_params&.each do |key, value|
138
+ parameters[key] = value
139
+ end
140
+
141
+ parameters.each_key do |key|
142
+ raise ArgumentError, "Parameters contain nil key, please check the input" if key.nil?
143
+ end
144
+
145
+ uri = URI.parse(oidc_config.authorization_endpoint)
146
+ uri.query = URI.encode_www_form(parameters)
147
+ uri.to_s
148
+ end
149
+
150
+ def generate_sign_out_uri(client_id:, post_logout_redirect_uri: nil)
151
+ parameters = {
152
+ QUERY_KEY[:client_id] => client_id
153
+ }
154
+ parameters[QUERY_KEY[:post_logout_redirect_uri]] = post_logout_redirect_uri if post_logout_redirect_uri
155
+
156
+ uri = URI.parse(oidc_config.end_session_endpoint)
157
+ uri.query = URI.encode_www_form(parameters)
158
+ uri.to_s
159
+ end
160
+
161
+ protected
162
+
163
+ # Function to fetch OIDC config from a Logto endpoint
164
+ def fetch_oidc_config
165
+ config_hash = @cache&.get("oidc_config") || begin
166
+ response = Net::HTTP.get(URI.join(endpoint, DISCOVERY_PATH))
167
+ @cache&.set("oidc_config", response)
168
+ response
169
+ end
170
+ LogtoUtils.parse_json_safe(config_hash, OidcConfigResponse)
171
+ end
172
+ end
@@ -0,0 +1,79 @@
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
+ identifier: "identifier",
26
+ interaction_mode: "interaction_mode",
27
+ error: "error",
28
+ error_description: "error_description"
29
+ }
30
+
31
+ TOKEN_GRANT_TYPE = {
32
+ authorization_code: "authorization_code",
33
+ refresh_token: "refresh_token"
34
+ }
35
+
36
+ CODE_CHALLENGE_METHOD = {
37
+ S256: "S256"
38
+ }
39
+
40
+ PROMPT = {
41
+ login: "login",
42
+ none: "none",
43
+ consent: "consent",
44
+ select_account: "select_account"
45
+ }
46
+
47
+ # Scopes that reserved by Logto, which will be added to the auth request automatically.
48
+ RESERVED_SCOPE = {
49
+ openid: "openid",
50
+ offline_access: "offline_access",
51
+ profile: "profile"
52
+ }
53
+
54
+ # Scopes for ID Token and Userinfo Endpoint.
55
+ USER_SCOPE = {
56
+ # Scope for basic user ingo.
57
+ profile: "profile",
58
+ # Scope for email address.
59
+ email: "email",
60
+ # Scope for phone number.
61
+ phone: "phone",
62
+ # Scope for user's custom data.
63
+ custom_data: "custom_data",
64
+ # Scope for user's social identity details.
65
+ identities: "identities",
66
+ # Scope for user's roles.
67
+ roles: "roles",
68
+ # Scope for user's organization IDs and perform organization token grant per {https://github.com/logto-io/rfcs RFC 0001}.
69
+ organizations: "urn:logto:scope:organizations",
70
+ # Scope for user's organization roles per {https://github.com/logto-io/rfcs RFC 0001}.
71
+ organization_roles: "urn:logto:scope:organization_roles"
72
+ }
73
+
74
+ # Resources that reserved by Logto, which cannot be defined by users.
75
+ RESERVED_RESOURCE = {
76
+ # The resource for organization template per {https://github.com/logto-io/rfcs RFC 0001}.
77
+ organization: "urn:logto:resource:organizations"
78
+ }
79
+ 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,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: iden
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - ryan-iden
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-01 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: Ruby authentication and authorization SDK for iden.
28
+ email: wangsiyuan19990317@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - README.md
34
+ - lib/logto/client.rb
35
+ - lib/logto/client/errors.rb
36
+ - lib/logto/client/index.rb
37
+ - lib/logto/client/index_constants.rb
38
+ - lib/logto/client/index_storage.rb
39
+ - lib/logto/client/index_types.rb
40
+ - lib/logto/core.rb
41
+ - lib/logto/core/errors.rb
42
+ - lib/logto/core/index.rb
43
+ - lib/logto/core/index_constants.rb
44
+ - lib/logto/core/index_types.rb
45
+ - lib/logto/core/utils.rb
46
+ homepage: https://github.com/ryan-iden/ruby
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ source_code_uri: https://github.com/ryan-iden/ruby
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 2.7.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.5.22
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: The iden SDK for Ruby.
70
+ test_files: []