Last active
May 27, 2024 15:34
-
-
Save fractaledmind/c74165ec02aa94a1c9ec689b6643e0b9 to your computer and use it in GitHub Desktop.
Basic OAuth implementation for a Rails app
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module OAuth | |
extend ActiveSupport::Concern | |
SIGN_UP = "sign_up" | |
SIGN_IN = "sign_in" | |
DESTINATION_PARAMS_KEY = :destination | |
DESTINATION_SESSION_KEY = "oauth.destination" | |
ORIGIN_PARAMS_KEY = :origin | |
ORIGIN_SESSION_KEY = "oauth.origin" | |
included do | |
rescue_from ActionController::InvalidAuthenticityToken do |exception| | |
redirect_to new_session_path, alert: "Authentication with #{provider_label} failed: invalid state token" | |
end | |
rescue_from ActionController::ParameterMissing do |exception| | |
redirect_to new_session_path, alert: "Authentication with #{provider_label} failed: invalid code token" | |
end | |
end | |
def create | |
store_origin | |
store_destination | |
redirect_to authorization_url, allow_other_host: true | |
end | |
def show | |
validate_state_token | |
access_credentials = request_access_credentials! | |
user_info = request_user_info!(access_token: access_credentials.access_token) | |
auth_info = { | |
"provider" => provider_key, | |
"uid" => user_info.id, | |
"credentials" => access_credentials.parsed_body.as_json["table"], | |
"info" => user_info.parsed_body.as_json["table"] | |
} | |
if auth_info.any? | |
authorization_succeeded auth_info | |
else | |
authorization_failed | |
end | |
rescue ApplicationClient::Error => error | |
Rails.error.report(error) | |
error_response = begin | |
JSON.parse(error.message, symbolize_names: true) | |
rescue JSON::ParserError | |
error.message | |
end | |
redirect_back fallback_location: new_session_path, | |
alert: "Authentication with #{provider_label} failed: #{error_response}" | |
end | |
private | |
# If the request includes an `origin` param, the app may want to use this | |
# to change behavior after users complete the full OAuth flow. So store it | |
# in the session for later use. | |
def store_origin | |
return unless params.key?(ORIGIN_PARAMS_KEY) | |
session[ORIGIN_SESSION_KEY] = params[ORIGIN_PARAMS_KEY] | |
end | |
# If the request includes a `destination` param, the app may want to use | |
# this as the location to redirect users to after they complete the full | |
# OAuth flow. So store it in the session for later use. | |
def store_destination | |
return unless params.key?(DESTINATION_PARAMS_KEY) | |
session[DESTINATION_SESSION_KEY] = params[DESTINATION_PARAMS_KEY] | |
end | |
def validate_state_token | |
state_token = params.fetch(:state, nil) | |
unless valid_authenticity_token?(session, state_token) | |
raise ActionController::InvalidAuthenticityToken, "The state=#{state_token} token is inauthentic." | |
end | |
end | |
# Generates the OAuth authorization URL that will redirect the user to the OAuth provider. | |
def authorization_url | |
uri = authorize_url | |
uri.query = Rack::Utils.build_query({ | |
client_id: client_id, | |
redirect_uri: callback_url, | |
response_type: "code", | |
scope: scope, | |
state: form_authenticity_token # prevent CSRF | |
}) | |
uri.to_s | |
end | |
# Requests an OAuth access token from the OAuth provider. The access token is used for subsequent | |
# requests to gather information like a users name, email, address, or whatever other information | |
# The OAuth provider makes available. | |
def request_access_credentials! | |
client = ApplicationClient.new | |
client.post(token_url, body: { | |
client_id: client_id, | |
client_secret: client_secret, | |
code: params.fetch(:code), | |
grant_type: "authorization_code", | |
redirect_uri: callback_url | |
}) | |
end | |
def request_user_info!(access_token:) | |
client = ApplicationClient.new(token: access_token) | |
client.get(user_info_url) | |
end | |
# The URL the OAuth provider will redirect the user back to after authenticating. | |
def callback_url | |
url_for(action: :show, only_path: false) | |
end | |
# These methods can be overriden in the host controller, if needed | |
def provider_key = self.class::PROVIDER_KEY | |
def provider_label = self.class::PROVIDER_LABEL | |
def authorize_url = self.class::AUTHORIZE_URL | |
def token_url = self.class::TOKEN_URL | |
def user_info_url = self.class::USER_INFO_URL | |
def client_id = self.class::CLIENT_ID | |
def client_secret = self.class::CLIENT_SECRET | |
def scope = self.class::SCOPE | |
def after_oauth_path = session.delete(DESTINATION_SESSION_KEY) || user_root_path | |
def oauth_origin = session.delete(ORIGIN_SESSION_KEY) | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class User < ApplicationRecord | |
has_many :sessions, dependent: :destroy | |
has_many :connected_accounts, | |
class_name: "User::ConnectedAccount", | |
dependent: :destroy | |
validates :email, presence: true, uniqueness: true | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class User::ConnectedAccount < ApplicationRecord | |
belongs_to :user, optional: true | |
validates :provider, presence: true | |
validates :uid, presence: true, | |
uniqueness: { scope: :provider } | |
validates :auth, presence: true | |
encrypts :access_token | |
encrypts :access_token_secret | |
# Tokens that expire very soon should be consider expired | |
def expired? | |
expires_at? && expires_at <= 30.minutes.from_now | |
end | |
# Use this method to retrieve the latest access_token. | |
# Token will be automatically renewed as necessary | |
def token | |
renew_token! if expired? | |
access_token | |
end | |
# Force a renewal of the access token | |
def renew_token! | |
client = ApplicationClient.new | |
response = client.post(Current.testio_credentials.token_url, body: { | |
client_id: Current.testio_credentials.client_id, | |
client_secret: Current.testio_credentials.client_secret, | |
grant_type: "refresh_token", | |
refresh_token: refresh_token | |
}) | |
new_token = response.parsed_body | |
expires_at = begin | |
within = (new_token.expires_in || new_token.expires)&.to_i | |
latency = new_token.expires_latency&.to_i | |
at = if new_token.expires_at | |
begin | |
Time.parse(new_token.expires_at).to_i | |
rescue ArgumentError | |
new_token.expires_at.to_i | |
end | |
elsif within && !within.zero? | |
Time.now.to_i + within | |
end | |
at -= latency if latency | |
at | |
end | |
update_columns( | |
access_token: new_token.access_token, | |
refresh_token: new_token.refresh_token, | |
expires_at: expires_at | |
) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div class="text-center mt-12"> | |
<h1 class="text-4xl font-bold leading-7 sm:text-5xl"> | |
Log in | |
</h1> | |
<p class="text-gray-800 text-lg mt-4 mb-12"> | |
You'll be taken to Example to authenticate. | |
</p> | |
<div class="flex flex-col items-center gap-3"> | |
<%= button_to example_authorization_path(origin: session['origin']), data: { turbo: false }, class: button_classes(type: :primary) do %> | |
<span>Sign in with Example</span> | |
<% end %> | |
</div> | |
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
oauth_provider ||= OAuthProvider.new | |
Rails.application.routes.draw do | |
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html | |
resources :sessions, only: [ :new, :create ] | |
namespace :example do | |
resource :authorization, only: [ :create, :show ] | |
end | |
match "/example/oauth/*phase", to: oauth_provider, via: :all | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class OAuthProvider | |
def initialize | |
@name = nil | |
@email = nil | |
@req = nil | |
end | |
def call(env) | |
@req = Rack::Request.new(env) | |
case [ @req.request_method, @req.path_info ] | |
when [ "GET", "/developer/oauth/authorize" ] | |
authorize | |
when [ "GET", "/developer/oauth/authorized" ] | |
authorized | |
when [ "POST", "/developer/oauth/access_token" ] | |
access_token | |
when [ "GET", "/developer/oauth/user_info" ] | |
user_info | |
end | |
end | |
private | |
def authorize | |
response = <<~HTML | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> | |
<title>User Info</title> | |
</head> | |
<body style='text-align:center;'> | |
<h1>User Info</h1> | |
<form method='get' action='/developer/oauth/authorized' noValidate='noValidate' style='display:flex;flex-direction:column;width:fit-content;margin:1rem auto;text-align:left;gap:1rem;'> | |
<input type='hidden' name='state' value='#{@req.params["state"]}' /> | |
<input type='hidden' name='callback_url' value='#{@req.params["redirect_uri"]}' /> | |
<label for='name'>Name:</label> | |
<input type='text' id='name' name='name' /> | |
<label for='email'>Email:</label> | |
<input type='text' id='email' name='email' /> | |
<button type='submit'>Submit</button> | |
</form> | |
</body> | |
</html> | |
HTML | |
[ 200, { "content-type" => "text/html" }, [ response ] ] | |
end | |
def authorized | |
@name = @req.params["name"] | |
@email = @req.params["email"] | |
uri = URI(@req.params["callback_url"]) | |
uri.query = Rack::Utils.build_query({ | |
state: @req.params["state"], | |
code: "CODE" | |
}) | |
[ 302, { "location" => uri.to_s }, [] ] | |
end | |
def access_token | |
response = { | |
access_token: "ACCESS TOKEN" | |
}.to_json | |
[ 200, { "content-type" => "application/json" }, [ response ] ] | |
end | |
def user_info | |
response = { | |
id: rand(1000), | |
name: @name, | |
email: @email | |
}.to_json | |
[ 200, { "content-type" => "application/json" }, [ response ] ] | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment