rito 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fce60ac9fceb3d24d509974a37fd0d30ab79b68ce34ada151dc2025408b34bcc
4
- data.tar.gz: 7878f3040ebf057a4f3695ee2c03c3800ce868b97f2ea4ac0a5c015bd196b1b7
3
+ metadata.gz: 690356f1167c30f7662373519e4806837f61a9c63f9b5ebde867bd98d435176d
4
+ data.tar.gz: 9aabb8d8a1b21d828e37594b174d938898281e056994e384da1f0261bbb69d5d
5
5
  SHA512:
6
- metadata.gz: bc14b36f75ab02bbb49ba392196cebc7d6a5864625febd766322e26604d2271ab7e928599c984322190e8299f47fce491faa7216ce83590db9417591b33d761b
7
- data.tar.gz: 7bd631fe97715c33a496b8c2e408b711289e90eab82627d66c4f67d3ded895b6d94bb882791bba2d8ac158258e1d024d026a6f1b83d529f2433ec5a33e58a57b
6
+ metadata.gz: 91ed7d221b18a711a3f4370fec9c65d6092c01048a202750c6108dadfcbb4baa2c83f58fbbe658bcdd6bd8c87380f8f54904abaca5ff62e7bc5470064658ee38
7
+ data.tar.gz: 5aac876d18b27fb577889e7218689c179bc04d5fcff5c0bb657d42292f0a7b9f88900e00e8613bd3b38013d1ebb1f7a664e4cdd9e372f8cac54b10742c68e166
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
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
+
15
+ ## 0.1.3 (2026-09-05)
16
+
17
+ - CI: publish via `rubygems/release-gem` so releases carry a Sigstore
18
+ provenance attestation ("Built and signed on GitHub Actions"). No
19
+ user-facing changes.
20
+
3
21
  ## 0.1.2 (2026-09-05)
4
22
 
5
23
  - CI: release workflow uses valid `trusted-publisher` input and Node 24
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rito
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.0'
5
5
  end
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.1.2
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: