Skip to content

Instantly share code, notes, and snippets.

@jneen
Created May 16, 2024 12:53
Show Gist options
  • Save jneen/2737b60fd500581952bcb8bc6e61038b to your computer and use it in GitHub Desktop.
Save jneen/2737b60fd500581952bcb8bc6e61038b to your computer and use it in GitHub Desktop.
OmniAuth strategy for Itch.io
module OmniAuth
module Strategies
class ItchIO
include OmniAuth::Strategy
option :name, 'itch_io'
option :client_options,
site: 'https://itch.io/',
authorize_url: 'https://itch.io/user/oauth'
args [:client_id, :client_secret]
attr_reader :player, :access_token
def client
::OAuth2::Client.new(options.client_id, options.client_secret, options.client_options.to_hash.transform_keys(&:to_sym))
end
def request_phase
redirect client.implicit.authorize_url({ redirect_uri: callback_url }.merge(authorize_params))
end
def authorize_params
state = SecureRandom.hex(24)
session['omniauth.state'] = state
{ state: state, scope: 'profile:me' }
end
# [jneen] This is a quirk of the implicit OAuth2 flow which itch.io
# uses. A redirect from an external site can only be a GET request,
# and cannot contain any data in the body. This would normally cause
# a leak of the client's token to the entire https stack.
#
# So instead it's passed as a hash param, which doesn't get sent to
# the server. We have to serve up a web page that parses this info
# out and makes a proper POST request to ourselves.
#
# As far as I can tell this is the standards-compliant way to do it,
# and it's why they recommend only using the Implicit flow for
# client-side apps. But this is the only flow implemented by Itch,
# so it's what we're stuck with.
def bounce_to_javascript
[200, {'Content-Type' => 'text/html; charset=utf-8'}, <<~CONTENT]
<html>
<head>
<title>redirecting...</title>
</head>
<body>
<form method="post">
<input type="hidden" name="state" />
<input type="hidden" name="access_token" />
<input type="submit" value="Click here if not redirected..." />
</form>
<script type="text/javascript">
var params = new URLSearchParams(window.location.hash.slice(1));
var form = document.getElementsByTagName('form')[0];
form.action = window.location.pathname;
form.state.value = params.get('state');
form.access_token.value = params.get('access_token');
form.submit();
</script>
</body>
</html>
CONTENT
end
def callback_phase
return bounce_to_javascript if request.get?
error = request.params['error_reason'] || request.params['error']
state = request.params['state']
if !state || state.empty? || state != session.delete('omniauth.state')
return fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
elsif error
err = CallbackError.new(
request.params['error'],
request.params['error_description'] || request.params['error_reason'],
request.params['error_uri'],
)
return fail!(error, err)
end
token = request.params['access_token']
fail!(:bad_params, 'missing access_token') unless token
@access_token = ::OAuth2::AccessToken.new(client, token)
response = @access_token.get('https://itch.io/api/1/key/me')
if response.status != 200
return fail!(:invalid_token, CallbackError.new(:invalid_token, "Invalid Token"))
end
parsed = response.parsed || {}
@player = parsed['user'] || {}
super
end
info do
{
"nickname" => @player['username'],
"url" => @player["url"],
"image" => @player["cover_url"],
}
end
extra do
@player.slice('gamer', 'press_user', 'developer')
end
uid do
@player['id']
end
class CallbackError < StandardError
attr_accessor :error, :error_reason, :error_uri
def initialize(error, error_reason = nil, error_uri = nil)
self.error = error
self.error_reason = error_reason
self.error_uri = error_uri
end
def message
[error, error_reason, error_uri].compact.join(" | ")
end
end
end
end
end
OmniAuth.config.add_camelization 'itch_io', 'ItchIO'
OmniAuth.config.add_camelization 'itchio', 'ItchIO'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment