Skip to content

Instantly share code, notes, and snippets.

@fractaledmind
Last active May 27, 2024 15:34
Show Gist options
  • Save fractaledmind/c74165ec02aa94a1c9ec689b6643e0b9 to your computer and use it in GitHub Desktop.
Save fractaledmind/c74165ec02aa94a1c9ec689b6643e0b9 to your computer and use it in GitHub Desktop.
Basic OAuth implementation for a Rails app
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
class AuthorizationsController < PublicController
include OAuth
PROVIDER_KEY = :example
PROVIDER_LABEL = "Example"
SCOPE = "public"
private
def authorize_url = URI(Rails.application.credentials.example.authorize_url)
def token_url = Rails.application.credentials.example.token_url
def user_info_url = Rails.application.credentials.example.user_info_url
def client_id = Rails.application.credentials.example.public_key
def client_secret = Rails.application.credentials.example.private_key
def authorization_succeeded(auth)
connected_account_params = {
provider: auth["provider"],
uid: auth["uid"],
email: auth.dig("info", "email").downcase,
access_token: auth.dig("credentials", "token"),
access_token_secret: auth.dig("credentials", "secret"),
expires_at: Time.now.to_i + auth.dig("credentials", "expires_in"),
refresh_token: auth.dig("credentials", "refresh_token"),
auth: auth
}
if (connected_account = User::ConnectedAccount.find_by(provider: auth["provider"], uid: auth["uid"])).present?
connected_account.update(connected_account_params)
sign_in(user: connected_account.user)
redirect_to after_oauth_path,
notice: "Successfully updated #{PROVIDER_LABEL} account"
elsif Current.user.present?
Current.user.connected_accounts.create(connected_account_params)
redirect_to after_oauth_path,
notice: "Successfully connected #{PROVIDER_LABEL} account"
else
user = User.new(
email: auth.dig("info", "email"),
avatar_url: auth.dig("info", "avatar_url"),
)
user.connected_accounts.new(connected_account_params)
if user.save
sign_in(user: user)
redirect_to after_oauth_path,
notice: "Signed in successfully via #{PROVIDER_LABEL}"
else
redirect_to new_session_path,
alert: "Authentication via #{PROVIDER_LABEL} failed"
end
end
end
def authorization_failed
redirect_to new_session_path,
alert: "Authentication via #{PROVIDER_LABEL} failed"
end
end
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
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
<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>
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
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