rito 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +75 -0
- data/lib/rito/rso/client.rb +187 -0
- data/lib/rito/rso/credential.rb +70 -0
- data/lib/rito/rso/errors.rb +19 -0
- data/lib/rito/rso/jwks.rb +20 -0
- data/lib/rito/rso/jwt.rb +45 -0
- data/lib/rito/rso/models.rb +39 -0
- data/lib/rito/rso.rb +30 -0
- data/lib/rito/version.rb +1 -1
- data/lib/rito.rb +1 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 690356f1167c30f7662373519e4806837f61a9c63f9b5ebde867bd98d435176d
|
|
4
|
+
data.tar.gz: 9aabb8d8a1b21d828e37594b174d938898281e056994e384da1f0261bbb69d5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 91ed7d221b18a711a3f4370fec9c65d6092c01048a202750c6108dadfcbb4baa2c83f58fbbe658bcdd6bd8c87380f8f54904abaca5ff62e7bc5470064658ee38
|
|
7
|
+
data.tar.gz: 5aac876d18b27fb577889e7218689c179bc04d5fcff5c0bb657d42292f0a7b9f88900e00e8613bd3b38013d1ebb1f7a664e4cdd9e372f8cac54b10742c68e166
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-09-05)
|
|
4
|
+
|
|
5
|
+
- **Riot Sign On (RSO)**: new `Rito::RSO` module implementing the OAuth2
|
|
6
|
+
authorization code flow against `auth.riotgames.com`:
|
|
7
|
+
`authorization_url` (with generated CSRF state), `exchange_code`,
|
|
8
|
+
`refresh`, `userinfo`, and `verify_id_token` (RS256 signature +
|
|
9
|
+
iss/aud/exp validation via `/jwks.json`, with key-rotation-aware
|
|
10
|
+
refetch). Token requests support `client_secret` basic, a static
|
|
11
|
+
`client_assertion` (the RSO "100 year token"), or minted private-key
|
|
12
|
+
JWT client assertions from an RSA `private_key`. Zero new runtime
|
|
13
|
+
dependencies (stdlib `openssl`).
|
|
14
|
+
|
|
3
15
|
## 0.1.3 (2026-09-05)
|
|
4
16
|
|
|
5
17
|
- CI: publish via `rubygems/release-gem` so releases carry a Sigstore
|
data/README.md
CHANGED
|
@@ -44,6 +44,81 @@ Rito.rate_limiter # rate limit strategy (default: AdaptiveLimiter)
|
|
|
44
44
|
client = Rito::Client.new(api_key: "RGAPI-...", region: :kr)
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
## Riot Sign On (RSO)
|
|
48
|
+
|
|
49
|
+
`Rito::RSO` implements the OAuth2 authorization code flow against
|
|
50
|
+
`https://auth.riotgames.com`. Register a client with Riot first; the
|
|
51
|
+
redirect URI must be on its allowlist.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
rso = Rito::RSO::Client.new(
|
|
55
|
+
client_id: ENV.fetch("RIOT_RSO_CLIENT_ID"),
|
|
56
|
+
client_secret: ENV.fetch("RIOT_RSO_CLIENT_SECRET"), # or client_assertion: / private_key:
|
|
57
|
+
redirect_uri: "https://app.example.com/oauth2-callback"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 1. Send the player to Riot; persist request.state to compare on callback
|
|
61
|
+
login = rso.authorization_url(login_hint: "na1|daguava")
|
|
62
|
+
login.url # => "https://auth.riotgames.com/authorize?..."
|
|
63
|
+
login.state
|
|
64
|
+
|
|
65
|
+
# 2. Exchange the ?code= query param from the callback for tokens
|
|
66
|
+
tokens = rso.exchange_code(params[:code])
|
|
67
|
+
tokens.access_token # Bearer token for RSO resources (encrypted, opaque)
|
|
68
|
+
tokens.id_token # signed JWT identity token
|
|
69
|
+
tokens.refresh_token # signed JWT, self-contained
|
|
70
|
+
tokens.expires_in # 600
|
|
71
|
+
|
|
72
|
+
# 3. Identify the player
|
|
73
|
+
me = rso.userinfo(tokens.access_token)
|
|
74
|
+
me.sub # player sub claim
|
|
75
|
+
me.cpid # "NA1" when the cpid scope was requested
|
|
76
|
+
|
|
77
|
+
# 4. Rotate when the access token expires
|
|
78
|
+
tokens = rso.refresh(tokens.refresh_token)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Newer RSO clients authenticate with private-key JWT instead of a secret
|
|
82
|
+
(the gem mints a fresh signed assertion per token request):
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
rso = Rito::RSO::Client.new(
|
|
86
|
+
client_id: ENV.fetch("RIOT_RSO_CLIENT_ID"),
|
|
87
|
+
private_key: OpenSSL::PKey::RSA.new(ENV.fetch("RIOT_RSO_PRIVATE_KEY")),
|
|
88
|
+
redirect_uri: "https://app.example.com/oauth2-callback"
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
or pass the pre-signed client assertion (the "100 year token") via
|
|
93
|
+
`client_assertion:` and treat it like a password.
|
|
94
|
+
|
|
95
|
+
### Verifying the ID token
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
claims = rso.verify_id_token(tokens.id_token)
|
|
99
|
+
claims["sub"] # => signature (RS256 via /jwks.json) + iss/aud/exp validated
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`verify_id_token` caches the JWKS document and refetches it once when a
|
|
103
|
+
`kid` is unknown (Riot rotates keypairs without disabling old ones).
|
|
104
|
+
Any failure raises `Rito::RSO::InvalidToken`. Token and userinfo failures
|
|
105
|
+
raise `Rito::RSO::OAuthError` with `.error_code` / `.error_description`.
|
|
106
|
+
|
|
107
|
+
### RSO configuration
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
Rito::RSO.client_id # default: ENV["RIOT_RSO_CLIENT_ID"]
|
|
111
|
+
Rito::RSO.client_secret # default: ENV["RIOT_RSO_CLIENT_SECRET"]
|
|
112
|
+
Rito::RSO.client_assertion # default: ENV["RIOT_RSO_CLIENT_ASSERTION"]
|
|
113
|
+
Rito::RSO.private_key
|
|
114
|
+
Rito::RSO.redirect_uri # default: ENV["RIOT_RSO_REDIRECT_URI"]
|
|
115
|
+
Rito::RSO.scope # default: "openid" (add cpid / offline_access)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Per-instance kwargs override the module config. Token requests are never
|
|
119
|
+
retried (authorization codes and refresh tokens are one-time), so handle
|
|
120
|
+
failures explicitly.
|
|
121
|
+
|
|
47
122
|
## Routing values
|
|
48
123
|
|
|
49
124
|
Endpoints declare whether they are platform-routed (`na1`, `kr`, ...) or
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RSO
|
|
5
|
+
class Client
|
|
6
|
+
attr_reader :provider, :client_id, :redirect_uri, :scope,
|
|
7
|
+
:open_timeout, :timeout, :adapter
|
|
8
|
+
|
|
9
|
+
def initialize(provider: nil, client_id: nil, client_secret: nil, client_assertion: nil,
|
|
10
|
+
private_key: nil, redirect_uri: nil, scope: nil,
|
|
11
|
+
open_timeout: nil, timeout: nil, adapter: nil)
|
|
12
|
+
@provider = (provider || RSO.provider).delete_suffix('/')
|
|
13
|
+
@client_id = client_id || RSO.client_id
|
|
14
|
+
@redirect_uri = redirect_uri || RSO.redirect_uri
|
|
15
|
+
@scope = scope || RSO.scope || 'openid'
|
|
16
|
+
@credential = Credential.new(
|
|
17
|
+
client_secret: client_secret || RSO.client_secret,
|
|
18
|
+
client_assertion: client_assertion || RSO.client_assertion,
|
|
19
|
+
private_key: private_key || RSO.private_key
|
|
20
|
+
)
|
|
21
|
+
@open_timeout = open_timeout || Rito.open_timeout
|
|
22
|
+
@timeout = timeout || Rito.timeout
|
|
23
|
+
@adapter = adapter || Rito.adapter
|
|
24
|
+
@jwks_mutex = Mutex.new
|
|
25
|
+
@jwks = nil
|
|
26
|
+
|
|
27
|
+
validate!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def authorization_url(scope: @scope, state: nil, login_hint: nil, ui_locales: nil)
|
|
31
|
+
ensure_redirect_uri!
|
|
32
|
+
state ||= SecureRandom.hex(32)
|
|
33
|
+
params = {
|
|
34
|
+
'client_id' => @client_id,
|
|
35
|
+
'redirect_uri' => @redirect_uri,
|
|
36
|
+
'response_type' => 'code',
|
|
37
|
+
'scope' => scope,
|
|
38
|
+
'state' => state
|
|
39
|
+
}
|
|
40
|
+
params['login_hint'] = login_hint if login_hint
|
|
41
|
+
params['ui_locales'] = ui_locales if ui_locales
|
|
42
|
+
AuthorizationRequest.new(url: "#{@provider}/authorize?#{URI.encode_www_form(params)}", state: state)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def exchange_code(code)
|
|
46
|
+
request_token(grant_type: 'authorization_code', code: code, redirect_uri: @redirect_uri)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def refresh(refresh_token, scope: nil)
|
|
50
|
+
params = { grant_type: 'refresh_token', refresh_token: refresh_token }
|
|
51
|
+
params[:scope] = scope if scope
|
|
52
|
+
request_token(**params)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def userinfo(access_token)
|
|
56
|
+
response = http do
|
|
57
|
+
connection.get('/userinfo') do |req|
|
|
58
|
+
req.headers['Authorization'] = "Bearer #{access_token}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
raise_rso_error!(response, 'GET', '/userinfo') unless response.status == 200
|
|
62
|
+
Userinfo.from_api(response.body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def jwks(force: false)
|
|
66
|
+
@jwks_mutex.synchronize do
|
|
67
|
+
@jwks = nil if force
|
|
68
|
+
@jwks ||= fetch_jwks
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def verify_id_token(id_token, leeway: 60)
|
|
73
|
+
header = Jwt.header(id_token)
|
|
74
|
+
claims = Jwt.claims(id_token)
|
|
75
|
+
verify_signature!(id_token, header)
|
|
76
|
+
validate_claims!(claims, leeway)
|
|
77
|
+
claims
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def request_token(params)
|
|
83
|
+
params = @credential.token_params(params, client_id: @client_id, aud: @provider)
|
|
84
|
+
response = http do
|
|
85
|
+
connection.post('/token') do |req|
|
|
86
|
+
req.headers['Authorization'] = @credential.authorization_header(@client_id) if @credential.basic?
|
|
87
|
+
req.body = params
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
raise_rso_error!(response, 'POST', '/token') unless response.status == 200
|
|
91
|
+
Tokens.from_api(response.body)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fetch_jwks
|
|
95
|
+
response = http { connection.get('/jwks.json') }
|
|
96
|
+
raise_rso_error!(response, 'GET', '/jwks.json') unless response.status == 200
|
|
97
|
+
response.body
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def verify_signature!(id_token, header)
|
|
101
|
+
raise InvalidToken, "unsupported alg: #{header['alg'].inspect}" unless header['alg'] == 'RS256'
|
|
102
|
+
|
|
103
|
+
key = signing_key_for(header['kid'])
|
|
104
|
+
segments = id_token.split('.')
|
|
105
|
+
signature = Jwt.base64url_decode(segments[2].to_s)
|
|
106
|
+
valid = key.verify(OpenSSL::Digest.new('SHA256'), signature, "#{segments[0]}.#{segments[1]}")
|
|
107
|
+
raise InvalidToken, 'invalid signature' unless valid
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def signing_key_for(kid)
|
|
111
|
+
key = key_from_jwks(jwks, kid)
|
|
112
|
+
return key if key
|
|
113
|
+
|
|
114
|
+
# Keypairs can be rotated without disabling the old one, so a miss on
|
|
115
|
+
# a cached document triggers exactly one refresh.
|
|
116
|
+
key_from_jwks(jwks(force: true), kid) || raise(InvalidToken, "unknown kid: #{kid.inspect}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def key_from_jwks(document, kid)
|
|
120
|
+
keys = document.is_a?(Hash) ? (document['keys'] || []) : []
|
|
121
|
+
jwk = keys.find { |key| key['kid'] == kid && key['kty'] == 'RSA' }
|
|
122
|
+
jwk && Jwks.public_key(jwk)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_claims!(claims, leeway)
|
|
126
|
+
exp = claims['exp']
|
|
127
|
+
raise InvalidToken, 'missing exp claim' unless exp
|
|
128
|
+
raise InvalidToken, 'token expired' if exp + leeway < Time.now.to_i
|
|
129
|
+
|
|
130
|
+
raise InvalidToken, "unexpected issuer: #{claims['iss'].inspect}" unless claims['iss'] == @provider
|
|
131
|
+
|
|
132
|
+
aud = claims['aud']
|
|
133
|
+
audiences = aud.is_a?(Array) ? aud : [aud].compact
|
|
134
|
+
return if audiences.include?(@client_id)
|
|
135
|
+
|
|
136
|
+
raise InvalidToken, 'audience does not match client_id'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def raise_rso_error!(response, method, path)
|
|
140
|
+
body = parse_error_body(response.body)
|
|
141
|
+
detail = [body['error'], body['error_description']].compact.join(': ')
|
|
142
|
+
base = "#{response.status} #{method} #{@provider}#{path}"
|
|
143
|
+
raise OAuthError.new(
|
|
144
|
+
detail.empty? ? base : "#{base}: #{detail}",
|
|
145
|
+
error_code: body['error'],
|
|
146
|
+
error_description: body['error_description'],
|
|
147
|
+
response: response
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_error_body(body)
|
|
152
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
153
|
+
body.is_a?(Hash) ? body : {}
|
|
154
|
+
rescue JSON::ParserError
|
|
155
|
+
{}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def http
|
|
159
|
+
yield
|
|
160
|
+
rescue Faraday::ConnectionFailed => e
|
|
161
|
+
raise Rito::ConnectionError.new("#{e.class}: #{e.message}", wrapped: e)
|
|
162
|
+
rescue Faraday::TimeoutError, Timeout::Error => e
|
|
163
|
+
raise Rito::TimeoutError.new("#{e.class}: #{e.message}", wrapped: e)
|
|
164
|
+
rescue Faraday::SSLError => e
|
|
165
|
+
raise Rito::SSLError.new("#{e.class}: #{e.message}", wrapped: e)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def connection
|
|
169
|
+
@connection ||= Faraday.new(@provider) do |f|
|
|
170
|
+
f.options.open_timeout = @open_timeout
|
|
171
|
+
f.options.timeout = @timeout
|
|
172
|
+
f.request :url_encoded
|
|
173
|
+
f.response :json, content_type: /\bjson$/
|
|
174
|
+
f.adapter @adapter
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def ensure_redirect_uri!
|
|
179
|
+
raise ConfigError, 'redirect_uri is required' if @redirect_uri.to_s.empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate!
|
|
183
|
+
raise ConfigError, 'client_id is required' if @client_id.to_s.empty?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RSO
|
|
5
|
+
class Credential
|
|
6
|
+
ASSERTION_TTL = 300
|
|
7
|
+
|
|
8
|
+
def initialize(client_secret: nil, client_assertion: nil, private_key: nil)
|
|
9
|
+
provided = [client_secret, client_assertion, private_key].count { |value| value && !value.to_s.empty? }
|
|
10
|
+
raise ConfigError, 'provide only one of client_secret, client_assertion, or private_key' if provided > 1
|
|
11
|
+
|
|
12
|
+
@client_secret = client_secret
|
|
13
|
+
@client_assertion = client_assertion
|
|
14
|
+
@private_key = normalize_key(private_key)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configured?
|
|
18
|
+
@client_secret || @client_assertion || @private_key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def basic?
|
|
22
|
+
!!@client_secret
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def authorization_header(client_id)
|
|
26
|
+
"Basic #{["#{client_id}:#{@client_secret}"].pack('m0')}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def token_params(params, client_id:, aud:)
|
|
30
|
+
unless configured?
|
|
31
|
+
raise ConfigError,
|
|
32
|
+
'client_secret, client_assertion, or private_key is required for token requests'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return params if @client_secret
|
|
36
|
+
|
|
37
|
+
params.merge(
|
|
38
|
+
client_assertion_type: JWT_BEARER_TYPE,
|
|
39
|
+
client_assertion: @client_assertion || mint_assertion(client_id, aud)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def mint_assertion(client_id, aud)
|
|
46
|
+
now = Time.now.to_i
|
|
47
|
+
Jwt.sign_rs256(
|
|
48
|
+
{
|
|
49
|
+
'iss' => client_id,
|
|
50
|
+
'sub' => client_id,
|
|
51
|
+
'aud' => aud,
|
|
52
|
+
'exp' => now + ASSERTION_TTL,
|
|
53
|
+
'iat' => now,
|
|
54
|
+
'jti' => SecureRandom.uuid
|
|
55
|
+
},
|
|
56
|
+
@private_key
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_key(key)
|
|
61
|
+
return nil if key.nil? || key.to_s.empty?
|
|
62
|
+
return key if key.is_a?(OpenSSL::PKey::RSA)
|
|
63
|
+
|
|
64
|
+
OpenSSL::PKey::RSA.new(key)
|
|
65
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
66
|
+
raise ConfigError, 'private_key is not a valid RSA key'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RSO
|
|
5
|
+
class Error < Rito::Error; end
|
|
6
|
+
|
|
7
|
+
class OAuthError < Error
|
|
8
|
+
attr_reader :error_code, :error_description
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, error_code: nil, error_description: nil, response: nil)
|
|
11
|
+
@error_code = error_code
|
|
12
|
+
@error_description = error_description
|
|
13
|
+
super(message, response: response)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class InvalidToken < Error; end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RSO
|
|
5
|
+
module Jwks
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def public_key(jwk)
|
|
9
|
+
raise InvalidToken, "unsupported key type: #{jwk['kty'].inspect}" unless jwk['kty'] == 'RSA'
|
|
10
|
+
|
|
11
|
+
modulus = OpenSSL::BN.new(Jwt.base64url_decode(jwk.fetch('n')), 2)
|
|
12
|
+
exponent = OpenSSL::BN.new(Jwt.base64url_decode(jwk.fetch('e')), 2)
|
|
13
|
+
sequence = OpenSSL::ASN1::Sequence.new(
|
|
14
|
+
[OpenSSL::ASN1::Integer.new(modulus), OpenSSL::ASN1::Integer.new(exponent)]
|
|
15
|
+
)
|
|
16
|
+
OpenSSL::PKey::RSA.new(sequence.to_der)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/rito/rso/jwt.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RSO
|
|
5
|
+
module Jwt
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def header(jwt)
|
|
9
|
+
parse_segment(jwt, 0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def claims(jwt)
|
|
13
|
+
parse_segment(jwt, 1)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sign_rs256(payload_claims, key, header_claims: { 'alg' => 'RS256', 'typ' => 'JWT' })
|
|
17
|
+
signing_input = "#{encode(header_claims)}.#{encode(payload_claims)}"
|
|
18
|
+
signature = key.sign(OpenSSL::Digest.new('SHA256'), signing_input)
|
|
19
|
+
"#{signing_input}.#{base64url_encode(signature)}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def base64url_encode(bytes)
|
|
23
|
+
[bytes].pack('m0').tr('+/', '-_').sub(/=+\z/, '')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def base64url_decode(str)
|
|
27
|
+
str.to_s.tr('-_', '+/').unpack1('m')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def encode(hash)
|
|
31
|
+
base64url_encode(JSON.generate(hash))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_segment(jwt, index)
|
|
35
|
+
segments = jwt.to_s.split('.')
|
|
36
|
+
segment = segments[index]
|
|
37
|
+
raise InvalidToken, 'malformed JWT' if segment.to_s.empty?
|
|
38
|
+
|
|
39
|
+
JSON.parse(base64url_decode(segment))
|
|
40
|
+
rescue JSON::ParserError, ArgumentError
|
|
41
|
+
raise InvalidToken, 'malformed JWT'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rito
|
|
4
|
+
module RSO
|
|
5
|
+
Tokens = Data.define(
|
|
6
|
+
:access_token, :id_token, :refresh_token, :token_type, :scope,
|
|
7
|
+
:expires_in, :sub_sid, :raw
|
|
8
|
+
) do
|
|
9
|
+
def self.from_api(hash)
|
|
10
|
+
new(
|
|
11
|
+
access_token: hash['access_token'],
|
|
12
|
+
id_token: hash['id_token'],
|
|
13
|
+
refresh_token: hash['refresh_token'],
|
|
14
|
+
token_type: hash['token_type'],
|
|
15
|
+
scope: hash['scope'],
|
|
16
|
+
expires_in: hash['expires_in'],
|
|
17
|
+
sub_sid: hash['sub_sid'],
|
|
18
|
+
raw: hash.freeze
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def id_token_claims
|
|
23
|
+
Jwt.claims(id_token) if id_token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def refresh_token_claims
|
|
27
|
+
Jwt.claims(refresh_token) if refresh_token
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Userinfo = Data.define(:sub, :cpid, :raw) do
|
|
32
|
+
def self.from_api(hash)
|
|
33
|
+
new(sub: hash['sub'], cpid: hash['cpid'], raw: hash.freeze)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
AuthorizationRequest = Data.define(:url, :state)
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/rito/rso.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Rito
|
|
7
|
+
module RSO
|
|
8
|
+
JWT_BEARER_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_accessor :provider, :client_id, :client_secret, :client_assertion,
|
|
12
|
+
:private_key, :redirect_uri, :scope
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
self.provider = 'https://auth.riotgames.com'
|
|
16
|
+
self.client_id = ENV['RIOT_RSO_CLIENT_ID']
|
|
17
|
+
self.client_secret = ENV['RIOT_RSO_CLIENT_SECRET']
|
|
18
|
+
self.client_assertion = ENV['RIOT_RSO_CLIENT_ASSERTION']
|
|
19
|
+
self.private_key = nil
|
|
20
|
+
self.redirect_uri = ENV['RIOT_RSO_REDIRECT_URI']
|
|
21
|
+
self.scope = 'openid'
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require_relative 'rso/errors'
|
|
26
|
+
require_relative 'rso/jwt'
|
|
27
|
+
require_relative 'rso/jwks'
|
|
28
|
+
require_relative 'rso/models'
|
|
29
|
+
require_relative 'rso/credential'
|
|
30
|
+
require_relative 'rso/client'
|
data/lib/rito/version.rb
CHANGED
data/lib/rito.rb
CHANGED
|
@@ -24,6 +24,7 @@ require_relative 'rito/middleware/riot_errors'
|
|
|
24
24
|
require_relative 'rito/connection'
|
|
25
25
|
require_relative 'rito/instrumentation'
|
|
26
26
|
require_relative 'rito/client'
|
|
27
|
+
require_relative 'rito/rso'
|
|
27
28
|
require_relative 'rito/models/account'
|
|
28
29
|
require_relative 'rito/models/summoner'
|
|
29
30
|
require_relative 'rito/models/match'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rito
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kodbilenadam
|
|
@@ -82,6 +82,13 @@ files:
|
|
|
82
82
|
- lib/rito/rate_limiting/null_limiter.rb
|
|
83
83
|
- lib/rito/rate_limiting/redis_limiter.rb
|
|
84
84
|
- lib/rito/routing.rb
|
|
85
|
+
- lib/rito/rso.rb
|
|
86
|
+
- lib/rito/rso/client.rb
|
|
87
|
+
- lib/rito/rso/credential.rb
|
|
88
|
+
- lib/rito/rso/errors.rb
|
|
89
|
+
- lib/rito/rso/jwks.rb
|
|
90
|
+
- lib/rito/rso/jwt.rb
|
|
91
|
+
- lib/rito/rso/models.rb
|
|
85
92
|
- lib/rito/version.rb
|
|
86
93
|
homepage: https://github.com/kodbilenadam/rito
|
|
87
94
|
licenses:
|