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 +7 -0
- data/README.md +44 -0
- data/lib/logto/client/errors.rb +15 -0
- data/lib/logto/client/index.rb +312 -0
- data/lib/logto/client/index_constants.rb +8 -0
- data/lib/logto/client/index_storage.rb +77 -0
- data/lib/logto/client/index_types.rb +53 -0
- data/lib/logto/client.rb +1 -0
- data/lib/logto/core/errors.rb +35 -0
- data/lib/logto/core/index.rb +155 -0
- data/lib/logto/core/index_constants.rb +78 -0
- data/lib/logto/core/index_types.rb +93 -0
- data/lib/logto/core/utils.rb +46 -0
- data/lib/logto/core.rb +1 -0
- metadata +71 -0
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
|
+
[][RubyGems]
|
16
|
+
[][RubyGems]
|
17
|
+
[][Website]
|
18
|
+
[][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,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
|
data/lib/logto/client.rb
ADDED
@@ -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: []
|