Skip to content

Instantly share code, notes, and snippets.

@easantillan
Forked from jesster2k10/README.md
Created June 28, 2023 02:51
Show Gist options
  • Save easantillan/54b158ea960812e6569b317eefe966f4 to your computer and use it in GitHub Desktop.
Save easantillan/54b158ea960812e6569b317eefe966f4 to your computer and use it in GitHub Desktop.

Revisions

  1. @jesster2k10 jesster2k10 revised this gist Apr 26, 2020. 1 changed file with 6 additions and 1 deletion.
    7 changes: 6 additions & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -97,4 +97,9 @@ gem 'google-id-token', git: 'https://github.com/google/google-id-token.git'
    gem 'koala'
    ```

    > Note: Make sure to install the google-id-token gem from the repo (as of 26th April 2020) in order to support passing an array of client_ids
    > Note: Make sure to install the google-id-token gem from the repo (as of 26th April 2020) in order to support passing an array of client_ids

    ### Specs/Tests

    The tests can be found [here](https://gist.github.com/jesster2k10/4a547c05df38d07a66b10f0b83546838)
  2. @jesster2k10 jesster2k10 revised this gist Apr 26, 2020. 1 changed file with 14 additions and 0 deletions.
    14 changes: 14 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -34,6 +34,8 @@ So there's an advantage to using this setup, it's a lot easier to work with a si

    However, if you are using a traditional Rails Application (not a rails api), you should totally go with Omniauth and save all the hastle.

    The code is quite modular so it will be extemely easy to add support for another login provider if needed. It's a matter of creating another `Provider::Base` super class and including the provider name in your `Identity.providers` array.

    ## Basic Structure

    There are quite a lot of files in this, each with different roles.
    @@ -84,3 +86,15 @@ The identities table is structured like so:

    add_index :identities, %i[provider uid], unique: true
    ```

    ### Dependencies

    Right now, I've implemented only two providers - Google and Facebook auth.
    The dependencies that I'm using are

    ```ruby
    gem 'google-id-token', git: 'https://github.com/google/google-id-token.git'
    gem 'koala'
    ```

    > Note: Make sure to install the google-id-token gem from the repo (as of 26th April 2020) in order to support passing an array of client_ids
  3. @jesster2k10 jesster2k10 revised this gist Apr 26, 2020. 7 changed files with 339 additions and 2 deletions.
    46 changes: 44 additions & 2 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -38,7 +38,49 @@ However, if you are using a traditional Rails Application (not a rails api), you

    There are quite a lot of files in this, each with different roles.

    ### base_provider.rb
    ### base_provider.rb

    An abstract class that serves as a base for all the different login providers making it easy to implement new providers if needed.
    Provides common extractions for methods.
    Provides common extractions for methods.

    ### facebook_provider.rb

    A provider class for handling login with facebook.

    > Note: The fields requested must match those requested on the native side otherwise you're going to get a run-time error becuase the granted acccess token won't have adequate permissions
    ### google_provider.rb

    A provider class for handling login with google.
    You need to provide an array of client_ids for the different clients you will have needing to sign in e.g. iOS App, Android App, React App, Windows App etc.

    In this example, I only have one (ios_client_id)

    ### provider_credentials.rb

    Just an object to handle the credentials (access token/refresh token)

    ### provider_info.rb

    This provides a common interface for the User Information retrieved from the external APIs. Similar to the Omniauth env['auth']

    ### identity.rb

    So each user has what I call an `Identity` which is just an external login. If you want your app to only support one external login e.g. facebook only, this identities table wouldn't be needed as you could just store the `uid/provider/access_token` on the user model.

    The identities table is structured like so:

    ```ruby
    create_table :identities do |t|
    t.belongs_to :user, null: false, foreign_key: true
    t.string :provider, null: false
    t.string :uid, null: false
    t.string :access_token
    t.string :refresh_token
    t.jsonb :auth_hash, default: {}

    t.timestamps
    end

    add_index :identities, %i[provider uid], unique: true
    ```
    33 changes: 33 additions & 0 deletions facebook_provider.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,33 @@
    module External
    module Provider
    class Facebook < Base
    def info
    ProviderInfo.new do |i|
    i.provider = External::Provider::Facebook.name
    i.date_format = '%m/%d/%Y'
    i.uid = raw_info[:id]
    i.email = raw_info[:email]
    i.birthday = raw_info[:birthday]
    i.first_name = raw_info[:first_name]
    i.last_name = raw_info[:last_name]
    i.name = raw_info[:name]
    end
    end

    def retrieve_client
    Koala::Facebook::API.new access_token
    end

    def retrieve_user_info
    client.get_object(
    :me,
    fields: %w[id email birthday first_name last_name]
    )
    end

    def self.name
    :facebook
    end
    end
    end
    end
    42 changes: 42 additions & 0 deletions google_provider.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,42 @@
    module External
    module Provider
    class Google < Base
    def info
    ProviderInfo.new do |i|
    i.provider = External::Provider::Google.name
    i.avatar = raw_info[:picture]
    i.first_name = raw_info[:given_name]
    i.last_name = raw_info[:family_name]
    i.email = raw_info[:email]
    i.name = raw_info[:name]
    i.uid = raw_info[:sub]
    end
    end

    def retrieve_client
    GoogleIDToken::Validator.new
    end

    def retrieve_user_info
    client.check(
    id_token,
    client_ids
    )
    end

    def self.name
    :google
    end

    private

    # The different clients your app needs to authenticate agains
    # E.g. iOS, Android, Web etc.
    def client_ids
    [Rails.application.credentials.google[:ios_client_id]]
    end

    alias id_token access_token
    end
    end
    end
    99 changes: 99 additions & 0 deletions identities_controller.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,99 @@
    class Api::V1::IdentitiesController < ApplicationController
    before_action :authenticate_for_identity!
    attr_reader :provider, :identity, :user, :access_token, :refresh_token

    # POST /identities/:provider
    def provider_create
    @provider = params[:provider]

    # We want to make sure we support the provider
    if Identity.valid_provider?(provider)
    set_identity
    return unless set_user

    # If the identity exists, we want to update it
    if identity.present?
    identity.update identity_params

    # Create a new identity for this user
    else
    user.identities.create identity_params
    end

    # Finally we want to create a new auth token pair for the user
    issue_tokens

    # Render the user object and the auth tokens
    render_json data: {
    token: { access_token: access_token, refresh_token: refresh_token },
    user: UserBlueprint.render_as_json(user)
    }, status: :created
    else
    # Render an error saying the provider is not supported
    render_unsupported_provider_error
    end
    end

    def set_identity
    @identity = Identity.where(provider: provider, uid: external.info.uid).first
    end

    def set_user
    # The user is already logged in (access_token present in request)
    # We want to set the user to the current user
    if current_user.present?
    @user = current_user

    # The user has signed in with this provider before
    # Return the existing account
    elsif identity.present?
    @user = identity.user

    # There is an existing user account with the same email & the user is not
    # logged in. We don't want to create an account for safety.
    # Tell the user to sign in to their account and link it there
    elsif User.where(email: external.info.email).any?
    render_existing_account_error provider: provider
    return false
    # The user has never signed in before, we want to create a new account from
    # the external info object
    else
    @user = User.create_from_provider_info(external.info)
    end

    true
    end

    # We want to update the identity object with the new uid and provider.
    # Storing the ccess_token or refresh_token is optional since that
    # can be delt with on the native clients.
    def identity_params
    {
    provider: external.info.provider,
    uid: external.info.uid,
    access_token: external.credentials.access_token,
    refresh_token: external.credentials.refresh_token,
    auth_hash: external.info.to_h
    }
    end

    def issue_tokens
    @access_token, @refresh_token = Jwt::Issuer.call user
    response.set_header 'Access-token', access_token
    response.set_header 'Refresh-token', refresh_token
    end

    private

    # Returns the External::Provider object for the current provider
    # We can access the user info object and raw_info hash from here
    def external
    @external ||= External::Provider::Base
    .for(provider)
    .new(create_params[:token])
    end

    def create_params
    params.require(:data).permit(:token)
    end
    end
    23 changes: 23 additions & 0 deletions identity.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,23 @@
    class Identity < ApplicationRecord
    belongs_to :user

    PROVIDERS = %w[facebook google].freeze

    PROVIDERS.each do |provider|
    scope provider, -> { where(provider: provider) }
    end

    validates :uid, uniqueness: { scope: :provider }, presence: true
    validates :provider, inclusion: {
    in: PROVIDERS,
    message: 'this provider is not supported'
    }, presence: true

    def self.providers
    PROVIDERS
    end

    def self.valid_provider?(provider)
    providers.include?(provider.to_s)
    end
    end
    20 changes: 20 additions & 0 deletions provider_credentials.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,20 @@
    module External
    class ProviderCredentials
    attr_accessor :access_token, :refresh_token, :expires, :expires_at

    def initialize
    yield self if block_given?
    end

    def to_h
    {
    access_token: access_token,
    refresh_token: refresh_token,
    expires: expires,
    expires_at: expires_at
    }
    end

    alias hash to_h
    end
    end
    78 changes: 78 additions & 0 deletions provider_info.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,78 @@
    module External
    class ProviderInfo
    attr_accessor :uid, :email, :username, :date_format, :avatar, :provider
    attr_writer :birthday, :first_name, :last_name, :name

    def initialize
    yield self if block_given?
    end

    def first_name
    raw_name = @first_name

    if raw_name.present?
    raw_name
    elsif @name.present?
    split_name = @name.split(' ')
    if split_name.length > 1
    @name.split(' ')[0..-2].join(' ')
    else
    split_name.first
    end
    end
    end

    def last_name
    raw_name = @last_name

    if raw_name.present?
    raw_name
    elsif @name.present?
    split_name = @name.split(' ')
    return split_name if split_name.length > 1
    end
    end

    def birthday
    raw_value = @birthday

    if raw_value.is_a?(String)
    if date_format.present?
    Date.strptime raw_value, date_format
    else
    Date.parse raw_value
    end
    elsif raw_value.is_a?(Integer)
    Time.at(raw_value).to_date
    else
    raw_value
    end
    end

    def name
    raw_name = @name

    if raw_name.present?
    raw_name
    elsif @first_name.present? && @last_name.present?
    "#{@first_name} #{@last_name}"
    elsif @first_name.present?
    @first_name
    end
    end

    def to_h
    {
    uid: uid,
    email: email,
    username: username,
    birthday: birthday,
    first_name: first_name,
    last_name: last_name,
    name: name
    }
    end

    alias hash to_h
    end
    end
  4. @jesster2k10 jesster2k10 revised this gist Apr 26, 2020. 2 changed files with 77 additions and 1 deletion.
    15 changes: 14 additions & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -28,4 +28,17 @@ In comparison, if you were using Omniauth you would have to:
    - The user is redirected back to your backend
    - The backend generates a user account
    - Then it generates an auth token (assuming you're using JWT auth)
    - The backend redirects the user back to the native app with the access token.
    - The backend redirects the user back to the native app with the access token.

    So there's an advantage to using this setup, it's a lot easier to work with a single JSON API endpoint than it is to work with a bunch of redirects in a web view.

    However, if you are using a traditional Rails Application (not a rails api), you should totally go with Omniauth and save all the hastle.

    ## Basic Structure

    There are quite a lot of files in this, each with different roles.

    ### base_provider.rb

    An abstract class that serves as a base for all the different login providers making it easy to implement new providers if needed.
    Provides common extractions for methods.
    63 changes: 63 additions & 0 deletions base_provider.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,63 @@
    module External
    # A base class for handling external logins
    module Provider
    class Base
    attr_reader :access_token, :refresh_token, :client

    def initialize(access_token, refresh_token = nil)
    handle_missing_access_token unless access_token.present?

    @access_token = access_token
    @refresh_token = refresh_token
    @client = retrieve_client
    end

    def self.for(provider)
    class_name = provider.to_s.classify
    "External::Provider::#{class_name}".safe_constantize
    end

    # The name of the provider
    def self.name
    :base
    end

    # The user's info
    def info
    handle_unimplmeneted_method
    end

    # The user's credentials
    def credentials
    ProviderCredentials.new do |c|
    c.access_token = access_token
    c.refresh_token = refresh_token
    end
    end

    # The raw info returned from the hash
    def raw_info
    @raw_info ||= retrieve_user_info.deep_symbolize_keys
    end

    # Returns the raw info hash from the client
    def retrieve_user_info
    handle_unimplmeneted_method
    end

    def retrieve_client
    handle_unimplmeneted_method
    end

    private

    def handle_missing_access_token
    raise Errors::Domain, status: 422, message: 'missing token'
    end

    def handle_unimplmeneted_method(method = :method)
    raise StandardError, "Expected super class to override #{method}"
    end
    end
    end
    end
  5. @jesster2k10 jesster2k10 created this gist Apr 26, 2020.
    31 changes: 31 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,31 @@
    # Rails API-Only Social Login

    This is another piece of code I've extrapolated from a Ruby on Rails project I'm currently working on.
    The code implmenets social login with a RoR API-based application, targeted at API clients.

    The setup does not involve any browser-redirects or sessions as you would have to use working with Omniauth.
    Instead, what it does is takes an access_token generated on client-side SDKs, retireves user info from the access token
    and creates a new user and `Identity` in the database.

    This setup works with native applications as described in the [Google iOS Sign In Docs (see Authenticating with a backend server)](https://developers.google.com/identity/sign-in/ios/backend-auth)
    A quote from that page pretty much sums up how this works:

    > After you have verified the token, check if the user is already in your user database. If so, establish an authenticated session for the user. If the user isn't yet in your user database, create a new user record from the information in the ID token payload, and establish a session for the user. You can prompt the user for any additional profile information you require when you detect a newly created user in your app.
    The basic flow of the login is:

    - You sign in using the respective native sdks on your mobile/js clients
    - An access_token/id_token is retireved from the mobile SDKs
    - A request is made to your Rails Application `POST /identities/:provider` with the token
    - The server then fetches the user data from the token after validating it with the provider
    - The server then creates a user profile & `Identity` based on that information.

    In comparison, if you were using Omniauth you would have to:

    - Open up a web view in your mobile app linking to your backend
    - Your user is redirected from your backend to the external provider
    - The user signs in with the external provider
    - The user is redirected back to your backend
    - The backend generates a user account
    - Then it generates an auth token (assuming you're using JWT auth)
    - The backend redirects the user back to the native app with the access token.