├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── setup.md ├── shard.yml ├── spec ├── providers │ ├── discord_spec.cr │ ├── facebook_spec.cr │ ├── github_spec.cr │ ├── gitlab_spec.cr │ ├── google_spec.cr │ ├── restream_spec.cr │ ├── twitter_spec.cr │ └── vk_spec.cr ├── spec_helper.cr └── support │ ├── discord.json │ ├── github.json │ ├── gitlab.json │ ├── google.json │ ├── google_api_disabled.json │ ├── google_without_names.json │ └── restream.json └── src ├── multi_auth.cr └── multi_auth ├── engine.cr ├── provider.cr ├── providers ├── discord.cr ├── facebook.cr ├── github.cr ├── gitlab.cr ├── google.cr ├── restream.cr ├── twitter.cr └── vk.cr ├── user.cr └── version.cr /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | schedule: 9 | - cron: "0 3 * * MON" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | LintAndTest: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | crystal_version: 18 | - 0.35.1 19 | - 0.36.1 20 | - latest 21 | experimental: [false] 22 | include: 23 | - crystal_version: nightly 24 | experimental: true 25 | 26 | runs-on: ubuntu-latest 27 | continue-on-error: ${{ matrix.experimental }} 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | 32 | - uses: oprypin/install-crystal@v1 33 | with: 34 | crystal: ${{matrix.crystal_version}} 35 | 36 | - name: Check format 37 | run: crystal tool format --check 38 | 39 | - name: Set up Crystal cache 40 | uses: actions/cache@v2 41 | id: crystal-cache 42 | with: 43 | path: | 44 | ~/.cache/crystal 45 | lib 46 | key: ${{ runner.os }}-crystal-${{ matrix.crystal_version }}-${{ hashFiles('**/shard.lock') }} 47 | restore-keys: | 48 | ${{ runner.os }}-crystal-${{ matrix.crystal_version }} 49 | 50 | - name: Install shards 51 | if: steps.crystal-cache.outputs.cache-hit != 'true' 52 | run: shards check || shards install --ignore-crystal-version 53 | 54 | - name: Run tests 55 | run: crystal spec 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | 6 | # Libraries don't need dependency lock 7 | # Dependencies will be locked in application that uses them 8 | /shard.lock 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RUN := docker-compose run --rm 2 | RUN_APP := $(RUN) app 3 | 4 | help: ## This help 5 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 6 | 7 | setup: ## setup app 8 | docker-compose pull 9 | docker-compose build --force-rm app 10 | $(RUN_APP) shards install 11 | l: ## linter 12 | $(RUN) -e MULTI_AUTH_ENV=test app bash -c "crystal tool format" 13 | t: ## tests 14 | $(RUN) -e MULTI_AUTH_ENV=test app bash -c "crystal spec $(c)" 15 | sh: ## shell into app container c="pwd" 16 | $(RUN_APP) $(or $(c),bash) 17 | c: ## run console.cr 18 | $(RUN_APP) crystal run src/console.cr 19 | update_dependency: ## update_dependency 20 | docker-compose build --force-rm --no-cache --pull 21 | $(RUN_APP) rm -rf /app/lib/* 22 | $(RUN_APP) shards update 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiAuth 2 | 3 | ![Build Status](https://github.com/msa7/multi_auth/workflows/CI/badge.svg) 4 | 5 | MultiAuth is a library that standardizes multi-provider authentication for web applications. Currently supported providers: 6 | 7 | - Github.com 8 | - Gitlab.com (or [own instance](https://github.com/msa7/multi_auth/blob/master/setup.md#gitlab)) 9 | - Facebook.com 10 | - Vk.com 11 | - Google.com, [setup google](https://github.com/msa7/multi_auth/blob/master/setup.md#google) 12 | - Twitter.com 13 | - Restream.io 14 | 15 | ## Installation 16 | 17 | Add this to your application's `shard.yml`: 18 | 19 | ```yaml 20 | dependencies: 21 | multi_auth: 22 | github: msa7/multi_auth 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### MultiAuth public interface 28 | 29 | ```crystal 30 | require "multi_auth" 31 | 32 | MultiAuth.config("github", ENV['ID'], ENV['SECRET']) # configuration 33 | 34 | multi_auth = MultiAuth.make(provider, redirect_uri) # initialize engine 35 | multi_auth.authorize_uri # URL to provider authentication dialog 36 | 37 | # on http callback, like /multi_auth/github/callback 38 | user = multi_auth.user(params) # get signed in user 39 | ``` 40 | 41 | MultiAuth build with no dependency, it can be used with any web framework. Information about signed in user described in User class here [src/multi_auth/user.cr](https://github.com/msa7/multi_auth/blob/master/src/multi_auth/user.cr). Supported providers [src/multi_auth/providers](https://github.com/msa7/multi_auth/blob/master/src/multi_auth/providers). I hope it easy to add new providers. 42 | 43 | ### [Kemal](http://kemalcr.com) integration example 44 | 45 | ```html 46 | Sign in with Github 47 | ``` 48 | 49 | ```crystal 50 | MultiAuth.config("facebook", "facebookClientID", "facebookSecretKey") 51 | MultiAuth.config("google", "googleClientID", "googleSecretKey") 52 | 53 | def self.multi_auth(env) 54 | provider = env.params.url["provider"] 55 | redirect_uri = "#{Kemal.config.scheme}://#{env.request.host_with_port.as(String)}/multi_auth/#{provider}/callback" 56 | MultiAuth.make(provider, redirect_uri) 57 | end 58 | 59 | get "/multi_auth/:provider" do |env| 60 | env.redirect(multi_auth(env).authorize_uri) 61 | end 62 | 63 | get "/multi_auth/:provider/callback" do |env| 64 | user = multi_auth(env).user(env.params.query) 65 | p user.email 66 | user 67 | end 68 | ``` 69 | 70 | ### [Lucky](https://github.com/luckyframework/lucky) integration example 71 | 72 | ```crystal 73 | # config/watch.yml 74 | host: myapp.lvh.me 75 | port: 5000 76 | 77 | # config/multi_auth_handler.cr 78 | require "multi_auth" 79 | 80 | class MultiAuthHandler 81 | MultiAuth.config("facebook", "facebookClientID", "facebookSecretKey") 82 | MultiAuth.config("google", "googleClientID", "googleSecretKey") 83 | 84 | def self.authorize_uri(provider : String) 85 | MultiAuth.make(provider, "#{Lucky::RouteHelper.settings.base_uri}/oauth/#{provider}/callback").authorize_uri(scope: "email") 86 | end 87 | 88 | def self.user(provider : String, params : Enumerable({String, String})) 89 | MultiAuth.make(provider, "#{Lucky::RouteHelper.settings.base_uri}/oauth/#{provider}/callback").user(params) 90 | end 91 | end 92 | 93 | # src/actions/oauth/handler.cr 94 | class OAuth::Handler < BrowserAction 95 | get "/oauth/:provider" do 96 | redirect to: MultiAuthHandler.authorize_uri(provider) 97 | end 98 | end 99 | 100 | # src/actions/oauth/handler/callback.cr 101 | class OAuth::Handler::Callback < BrowserAction 102 | get "/oauth/:provider/callback" do 103 | user = MultiAuthHandler.user(provider, request.query_params) 104 | text user.email.to_s 105 | end 106 | end 107 | ``` 108 | 109 | ### [Amber](https://github.com/amberframework/amber) integration example 110 | 111 | ```crystal 112 | # config/initializers/multi_auth.cr 113 | require "multi_auth" 114 | 115 | MultiAuth.config("facebook", "facebookClientID", "facebookSecretKey") 116 | MultiAuth.config("google", "googleClientID", "googleSecretKey") 117 | 118 | # config/routes.cr 119 | routes :web do 120 | ... 121 | get "/multi_auth/:provider", MultiAuthController, :new 122 | get "/multi_auth/:provider/callback", MultiAuthController, :callback 123 | end 124 | 125 | # src/controllers/multi_auth_controller.cr 126 | class MultiAuthController < ApplicationController 127 | def new 128 | redirect_to multi_auth.authorize_uri(scope: "email") 129 | end 130 | 131 | def callback 132 | multi_auth_user = multi_auth.user(request.query_params) 133 | 134 | if user = User.find_by email: multi_auth_user.email 135 | login user 136 | else 137 | user = User.create!( 138 | first_name: multi_auth_user.first_name, 139 | last_name: multi_auth_user.last_name, 140 | email: multi_auth_user.email 141 | ) 142 | login user 143 | end 144 | 145 | redirect_to "/" 146 | end 147 | 148 | def login(user) 149 | context.session["user_id"] = user.id 150 | end 151 | 152 | def provider 153 | params[:provider] 154 | end 155 | 156 | def redirect_uri 157 | "#{Amber.settings.secrets["base_url"]}/multi_auth/#{provider}/callback" 158 | end 159 | 160 | def multi_auth 161 | MultiAuth.make(provider, redirect_uri) 162 | end 163 | end 164 | ``` 165 | 166 | ### [Marten](https://github.com/martenframework/marten) integration example 167 | 168 | ```crystal 169 | # config/initializers/multi_auth.cr 170 | # ---- 171 | 172 | require "multi_auth" 173 | 174 | MultiAuth.config("github", "", "") 175 | 176 | 177 | # config/routes.cr 178 | # ---- 179 | 180 | Marten.routes.draw do 181 | path "/oauth/", OAuthInitiateHandler, name: "oauth_initiate" 182 | path "/oauth//callback", OAuthCallbackHandler, name: "oauth_callback" 183 | end 184 | 185 | 186 | # src/handlers/concerns/with_oauth.cr 187 | # ---- 188 | 189 | module WithOAuth 190 | def multi_auth 191 | MultiAuth.make(provider, redirect_uri) 192 | end 193 | 194 | private def provider 195 | params["provider"].to_s 196 | end 197 | 198 | private def redirect_uri 199 | "#{request.scheme}://#{request.host}#{reverse("oauth_callback", provider: provider)}" 200 | end 201 | end 202 | 203 | 204 | # src/handlers/oauth_initiate_handler.cr 205 | # ---- 206 | 207 | require "./concerns/**" 208 | 209 | class OAuthInitiateHandler < Marten::Handler 210 | include WithOAuth 211 | 212 | def get 213 | redirect multi_auth.authorize_uri(scope: "email") 214 | end 215 | end 216 | 217 | 218 | # src/handlers/oauth_initiate_callback.cr 219 | # ---- 220 | 221 | require "./concerns/**" 222 | 223 | class OAuthCallbackHandler < Marten::Handler 224 | include WithOAuth 225 | 226 | def get 227 | user_params = Hash(String, String).new.tap do |params| 228 | request.query_params.each { |k, v| params[k] = v.last } 229 | end 230 | 231 | multi_auth_user = multi_auth.user(user_params) 232 | 233 | unless user = Auth::User.get(email: multi_auth_user.email) 234 | user = Auth::User.create!(email: multi_auth_user.email) do |new_user| 235 | new_user.set_unusable_password 236 | end 237 | end 238 | 239 | MartenAuth.sign_in(request, user) 240 | 241 | redirect "/" 242 | end 243 | end 244 | ``` 245 | 246 | ## Development 247 | 248 | Install docker 249 | 250 | Setup everythings 251 | 252 | ``` 253 | make setup 254 | ``` 255 | 256 | Run specs 257 | 258 | ``` 259 | make t 260 | make t c=spec/providers/twitter_spec.cr 261 | ``` 262 | 263 | Run code linter 264 | 265 | ``` 266 | make l 267 | ``` 268 | 269 | ## Contributors 270 | 271 | - [Sergey Makridenkov](https://github.com/msa7) - creator, maintainer 272 | - [Vitalii Elenhaupt](https://github.com/veelenga) - contributor 273 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | image: crystallang/crystal:latest 6 | working_dir: /app 7 | environment: 8 | EDA_ENV: ${MULTI_AUTH_ENV} 9 | volumes: 10 | - shards:/app/lib 11 | - .:/app 12 | volumes: 13 | shards: 14 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | # Google 2 | 3 | - Register app at [console](https://console.developers.google.com) 4 | - Generate OAuth [credentials](https://console.developers.google.com/apis/credentials). Use `http://localhost:8080/multi_auth/google/callback` as redirect URIs 5 | - Enable [Google People API](https://console.developers.google.com/apis/api/people.googleapis.com/overview?project=test-multi-auth) 6 | 7 | # Gitlab 8 | 9 | For own instance : 10 | 11 | - set OAUTH_GITLAB_URI environment variable 12 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: multi_auth 2 | version: 1.1.1 3 | 4 | authors: 5 | - Sergey Makridenkov 6 | 7 | crystal: ">= 0.35.1, < 2.0.0" 8 | 9 | license: MIT 10 | 11 | development_dependencies: 12 | webmock: 13 | github: manastech/webmock.cr 14 | version: ~> 0.14 15 | -------------------------------------------------------------------------------- /spec/providers/discord_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Discord do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("discord", "/callback").authorize_uri 6 | uri.should start_with("https://discord.com/api/oauth2/authorize?client_id=discord_id&redirect_uri=%2Fcallback&response_type=code&scope=identify") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("discord", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should start_with("https://discord.com/api/oauth2/authorize?client_id=discord_id&redirect_uri=%2Fcallback&response_type=code&scope=identify&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock.stub(:post, "https://discord.com/api/v8/oauth2/token") 17 | .with( 18 | body: "client_id=discord_id&client_secret=discord_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 19 | headers: { 20 | "Accept" => "application/json", 21 | "Content-type" => "application/x-www-form-urlencoded", 22 | } 23 | ) 24 | .to_return( 25 | body: %({ 26 | "access_token": "6qrZcUqja7812RVdnEKjpzOL4CvHBFG", 27 | "token_type": "Bearer", 28 | "expires_in": 604800, 29 | "refresh_token": "D43f5y0ahjqew82jZ4NViEr2YafMKhue", 30 | "scope": "identify" 31 | }) 32 | ) 33 | 34 | WebMock.stub(:get, "https://discord.com/api/v8/oauth2/@me") 35 | .to_return(body: File.read("spec/support/discord.json")) 36 | 37 | user = MultiAuth.make("discord", "/callback").user({"code" => "123"}).as(MultiAuth::User) 38 | 39 | user.name.should eq("Discord") 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/providers/facebook_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Facebook do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("facebook", "/callback").authorize_uri 6 | uri.should eq("https://www.facebook.com/v2.9/dialog/oauth?client_id=facebook_id&redirect_uri=%2Fcallback&response_type=code&scope=email") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("facebook", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should eq("https://www.facebook.com/v2.9/dialog/oauth?client_id=facebook_id&redirect_uri=%2Fcallback&response_type=code&scope=email&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock 17 | .stub(:post, "https://graph.facebook.com/v2.9/oauth/access_token") 18 | .with( 19 | body: "client_id=facebook_id&client_secret=facebook_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 20 | headers: {"Accept" => "application/json", "Content-type" => "application/x-www-form-urlencoded"}) 21 | .to_return( 22 | body: %({ 23 | "access_token" : "1111", 24 | "token_type" : "Bearer", 25 | "expires_in" : 899, 26 | "refresh_token" : null, 27 | "scope" : "user" 28 | }) 29 | ) 30 | 31 | WebMock 32 | .stub(:get, "https://graph.facebook.com/v2.9/me?fields=id,name,last_name,first_name,email,location,about,website") 33 | .to_return( 34 | body: %({ 35 | "name" : "Sergey", 36 | "id" : "3333" 37 | }) 38 | ) 39 | 40 | user = MultiAuth.make("facebook", "/callback").user({"code" => "123"}).as(MultiAuth::User) 41 | 42 | user.name.should eq("Sergey") 43 | user.uid.should eq("3333") 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/providers/github_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Github do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("github", "/callback").authorize_uri 6 | uri.should eq("https://github.com/login/oauth/authorize?client_id=github_id&redirect_uri=&response_type=code&scope=user%3Aemail") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("github", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should eq("https://github.com/login/oauth/authorize?client_id=github_id&redirect_uri=&response_type=code&scope=user%3Aemail&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock.stub(:post, "https://github.com/login/oauth/access_token") 17 | .with( 18 | body: "client_id=github_id&client_secret=github_secret&redirect_uri=&grant_type=authorization_code&code=123", 19 | headers: {"Accept" => "application/json", "Content-type" => "application/x-www-form-urlencoded"} 20 | ) 21 | .to_return( 22 | body: %({ 23 | "access_token" : "1111", 24 | "token_type" : "Bearer", 25 | "expires_in" : 899, 26 | "refresh_token" : null, 27 | "scope" : "user" 28 | }) 29 | ) 30 | 31 | WebMock.stub(:get, "https://api.github.com/user") 32 | .to_return(body: File.read("spec/support/github.json")) 33 | 34 | user = MultiAuth.make("github", "/callback").user({"code" => "123"}).as(MultiAuth::User) 35 | 36 | user.email.should eq("hi@msa7.ru") 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/providers/gitlab_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Gitlab do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("gitlab", "/callback").authorize_uri 6 | uri.should eq("https://gitlab.com/oauth/authorize?client_id=gitlab_id&redirect_uri=%2Fcallback&response_type=code&scope=") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("gitlab", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should eq("https://gitlab.com/oauth/authorize?client_id=gitlab_id&redirect_uri=%2Fcallback&response_type=code&scope=&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock.stub(:post, "https://gitlab.com/oauth/token") 17 | .with( 18 | body: "client_id=gitlab_id&client_secret=gitlab_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 19 | headers: {"Accept" => "application/json", "Content-type" => "application/x-www-form-urlencoded"} 20 | ) 21 | .to_return( 22 | body: %({ 23 | "access_token" : "1111", 24 | "token_type" : "Bearer", 25 | "expires_in" : 899, 26 | "refresh_token" : null, 27 | "scope" : "user" 28 | }) 29 | ) 30 | 31 | WebMock.stub(:get, "https://gitlab.com/api/v4/user") 32 | .to_return(body: File.read("spec/support/gitlab.json")) 33 | 34 | user = MultiAuth.make("gitlab", "/callback").user({"code" => "123"}).as(MultiAuth::User) 35 | 36 | user.email.should eq("jade.kharats@gmail.com") 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/providers/google_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Google do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("google", "/callback").authorize_uri 6 | uri.should eq("https://accounts.google.com/o/oauth2/v2/auth?client_id=google_id&redirect_uri=%2Fcallback&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuser.emails.read+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuser.phonenumbers.read+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuser.addresses.read+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fplus.login+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcontacts.readonly") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("google", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should eq("https://accounts.google.com/o/oauth2/v2/auth?client_id=google_id&redirect_uri=%2Fcallback&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuser.emails.read+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuser.phonenumbers.read+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuser.addresses.read+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fplus.login+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcontacts.readonly&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock.stub(:post, "https://www.googleapis.com/oauth2/v4/token") 17 | .with( 18 | body: "client_id=google_id&client_secret=google_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 19 | headers: {"Accept" => "application/json", "Content-Length" => "111", "Host" => "www.googleapis.com", "Content-type" => "application/x-www-form-urlencoded"} 20 | ) 21 | .to_return( 22 | body: %({ 23 | "access_token" : "1111", 24 | "token_type" : "Bearer", 25 | "expires_in" : 899, 26 | "refresh_token" : null, 27 | "scope" : "user" 28 | }) 29 | ) 30 | 31 | WebMock.stub(:get, "https://people.googleapis.com/v1/people/me?personFields=addresses,biographies,bragging_rights,cover_photos,email_addresses,im_clients,interests,names,nicknames,phone_numbers,photos,urls") 32 | .to_return(body: File.read("spec/support/google.json")) 33 | 34 | user = MultiAuth.make("google", "/callback").user({"code" => "123"}).as(MultiAuth::User) 35 | 36 | user.email.should eq("smkrbr@gmail.com") 37 | end 38 | end 39 | 40 | context "when API disabled" do 41 | it "shows error" do 42 | WebMock.wrap do 43 | WebMock.stub(:post, "https://www.googleapis.com/oauth2/v4/token") 44 | .with( 45 | body: "client_id=google_id&client_secret=google_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 46 | headers: {"Accept" => "application/json", "Content-Length" => "111", "Host" => "www.googleapis.com", "Content-type" => "application/x-www-form-urlencoded"} 47 | ) 48 | .to_return( 49 | body: %({ 50 | "access_token" : "1111", 51 | "token_type" : "Bearer", 52 | "expires_in" : 899, 53 | "refresh_token" : null, 54 | "scope" : "user" 55 | }) 56 | ) 57 | 58 | WebMock.stub(:get, "https://people.googleapis.com/v1/people/me?personFields=addresses,biographies,bragging_rights,cover_photos,email_addresses,im_clients,interests,names,nicknames,phone_numbers,photos,urls") 59 | .to_return(body: File.read("spec/support/google_api_disabled.json")) 60 | 61 | expect_raises(Exception) do 62 | MultiAuth.make("google", "/callback").user({"code" => "123"}) 63 | end 64 | end 65 | end 66 | end 67 | 68 | context "when the user has no names set on their account" do 69 | it "still returns a user" do 70 | WebMock.wrap do 71 | WebMock.stub(:post, "https://www.googleapis.com/oauth2/v4/token") 72 | .with( 73 | body: "client_id=google_id&client_secret=google_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 74 | headers: {"Accept" => "application/json", "Content-Length" => "111", "Host" => "www.googleapis.com", "Content-type" => "application/x-www-form-urlencoded"} 75 | ) 76 | .to_return( 77 | body: %({ 78 | "access_token" : "1111", 79 | "token_type" : "Bearer", 80 | "expires_in" : 899, 81 | "refresh_token" : null, 82 | "scope" : "user" 83 | }) 84 | ) 85 | 86 | WebMock.stub(:get, "https://people.googleapis.com/v1/people/me?personFields=addresses,biographies,bragging_rights,cover_photos,email_addresses,im_clients,interests,names,nicknames,phone_numbers,photos,urls") 87 | .to_return(body: File.read("spec/support/google_without_names.json")) 88 | 89 | user = MultiAuth.make("google", "/callback").user({"code" => "123"}).as(MultiAuth::User) 90 | 91 | user.email.should eq("smkrbr@gmail.com") 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/providers/restream_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Restream do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("restream", "/callback").authorize_uri 6 | uri.should start_with("https://api.restream.io/login?client_id=restream_id&redirect_uri=%2Fcallback&response_type=code&state=") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("restream", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should start_with("https://api.restream.io/login?client_id=restream_id&redirect_uri=%2Fcallback&response_type=code&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock.stub(:post, "https://api.restream.io/oauth/token") 17 | .with( 18 | body: "redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 19 | headers: { 20 | "Accept" => "application/json", 21 | "Content-type" => "application/x-www-form-urlencoded", 22 | "Authorization" => "Basic cmVzdHJlYW1faWQ6cmVzdHJlYW1fc2VjcmV0", 23 | "Content-Length" => "63", 24 | "Host" => "api.restream.io", 25 | } 26 | ) 27 | .to_return( 28 | body: %({ 29 | "access_token": "7e61c8a5e2f99404730c511de6580412e618da35", 30 | "token_type" : "Bearer", 31 | "expires_in": 3600, 32 | "refresh_token": "0e633c3343a2df84b1526f4c2e6993ff17e05cab", 33 | "scopeJson" : [ 34 | "profile.default.read", 35 | "stream.default.read" 36 | ] 37 | }) 38 | ) 39 | 40 | WebMock.stub(:get, "https://api.restream.io/v2/user/profile") 41 | .to_return(body: File.read("spec/support/restream.json")) 42 | 43 | user = MultiAuth.make("restream", "/callback").user({"code" => "123"}).as(MultiAuth::User) 44 | 45 | user.email.should eq("xxxxx@email.test") 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/providers/twitter_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Twitter do 4 | request_token_params = { 5 | oauth_token: "NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0", 6 | oauth_token_secret: "veNRnAWe6inFuo8o2u8SLLZLjolYDmDP7SzL0YfYI", 7 | oauth_callback_confirmed: "true", 8 | } 9 | 10 | access_token_params = { 11 | oauth_token: "7588892-kagSNqWge8gB1WwE3plnFsJHAZVfxWD7Vb57p0b4", 12 | oauth_token_secret: "PbKfYqSryyeKDWz4ebtY3o5ogNLG11WJuZBc9fQrQo", 13 | } 14 | 15 | verify_credentials_params = { 16 | id: 38895958, 17 | name: "Sean Cook", 18 | screen_name: "theSeanCook", 19 | location: "San Francisco", 20 | url: "http://twitter.com", 21 | description: "I taught your phone that thing you like. The Mobile Partner Engineer @Twitter.", 22 | profile_image_url: "http://a0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG", 23 | email: "me@twitter.com", 24 | } 25 | 26 | describe "#authorize_uri" do 27 | it "generates authorize uri" do 28 | WebMock.allow_net_connect = true 29 | WebMock.stub(:post, "https://api.twitter.com/oauth/request_token") 30 | .to_return(body: HTTP::Params.encode request_token_params) 31 | uri = MultiAuth.make("twitter", "/callback").authorize_uri 32 | uri.should eq "https://api.twitter.com/oauth/authorize?oauth_token=NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0&oauth_callback=%2Fcallback" 33 | end 34 | end 35 | 36 | describe "#fetch_tw_user" do 37 | it "successfully fetches user params" do 38 | WebMock.stub(:post, "https://api.twitter.com/oauth/access_token") 39 | .to_return(body: HTTP::Params.encode request_token_params) 40 | 41 | WebMock.stub(:get, "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true") 42 | .to_return(body: verify_credentials_params.to_json) 43 | 44 | user = MultiAuth.make("twitter", "/callback").user({"oauth_token" => "token", "oauth_verifier" => "verifier"}) 45 | 46 | user.uid.should eq verify_credentials_params[:id].to_s 47 | user.email.should eq verify_credentials_params[:email] 48 | user.name.should eq verify_credentials_params[:name] 49 | user.nickname.should eq verify_credentials_params[:screen_name] 50 | user.location.should eq verify_credentials_params[:location] 51 | user.description.should eq verify_credentials_params[:description] 52 | user.image.should eq verify_credentials_params[:profile_image_url] 53 | user.urls.should eq({"twitter" => verify_credentials_params[:url]}) 54 | 55 | user.provider.should eq "twitter" 56 | user.raw_json.should_not be_nil 57 | user.access_token.should_not be_nil 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/providers/vk_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MultiAuth::Provider::Vk do 4 | it "generates authorize_uri" do 5 | uri = MultiAuth.make("vk", "/callback").authorize_uri 6 | uri.should eq("https://oauth.vk.com/authorize?client_id=vk_id&redirect_uri=%2Fcallback&response_type=code&scope=email") 7 | end 8 | 9 | it "generates authorize_uri with state query param" do 10 | uri = MultiAuth.make("vk", "/callback").authorize_uri(state: "random_state_value") 11 | uri.should eq("https://oauth.vk.com/authorize?client_id=vk_id&redirect_uri=%2Fcallback&response_type=code&scope=email&state=random_state_value") 12 | end 13 | 14 | it "fetch user" do 15 | WebMock.wrap do 16 | WebMock 17 | .stub(:post, "https://oauth.vk.com/access_token") 18 | .with( 19 | body: "client_id=vk_id&client_secret=vk_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", 20 | headers: {"Accept" => "application/json", "Content-Length" => "103", "Host" => "oauth.vk.com", "Content-type" => "application/x-www-form-urlencoded"}) 21 | .to_return( 22 | body: %({ 23 | "access_token" : "1111", 24 | "expires_in" : 899, 25 | "refresh_token" : null, 26 | "scope" : "email", 27 | "user_id" : "3333", 28 | "email" : "s@msa7.ru" 29 | }) 30 | ) 31 | 32 | WebMock 33 | .stub(:get, %(https://api.vk.com/method/users.get?fields=about,photo_max_orig,city,country,domain,contacts,site&user_id="3333"&v=5.52)) 34 | .to_return( 35 | body: %({"response": [{ 36 | "first_name" : "Sergey", 37 | "last_name" : "Makridenkov", 38 | "id" : 3333 39 | }]}) 40 | ) 41 | 42 | user = MultiAuth.make("vk", "/callback").user({"code" => "123"}).as(MultiAuth::User) 43 | 44 | user.name.should eq("Makridenkov Sergey") 45 | user.uid.should eq("3333") 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "webmock" 3 | require "../src/multi_auth" 4 | 5 | MultiAuth.config("google", "google_id", "google_secret") 6 | MultiAuth.config("github", "github_id", "github_secret") 7 | MultiAuth.config("gitlab", "gitlab_id", "gitlab_secret") 8 | MultiAuth.config("facebook", "facebook_id", "facebook_secret") 9 | MultiAuth.config("vk", "vk_id", "vk_secret") 10 | MultiAuth.config("twitter", "twitter_consumer_key", "twitter_consumer_secret") 11 | MultiAuth.config("restream", "restream_id", "restream_secret") 12 | MultiAuth.config("discord", "discord_id", "discord_secret") 13 | -------------------------------------------------------------------------------- /spec/support/discord.json: -------------------------------------------------------------------------------- 1 | { 2 | "application": { 3 | "id": "159799960412356608", 4 | "name": "AIRHORN SOLUTIONS", 5 | "icon": "f03590d3eb764081d154a66340ea7d6d", 6 | "description": "", 7 | "summary": "", 8 | "hook": true, 9 | "bot_public": true, 10 | "bot_require_code_grant": false, 11 | "verify_key": "c8cde6a3c8c6e49d86af3191287b3ce255872be1fff6dc285bdb420c06a2c3c8" 12 | }, 13 | "scopes": [ 14 | "guilds.join", 15 | "identify" 16 | ], 17 | "expires": "2021-01-23T02:33:17.017000+00:00", 18 | "user": { 19 | "id": "268473310986240001", 20 | "username": "Discord", 21 | "avatar": "f749bb0cbeeb26ef21eca719337d20f1", 22 | "discriminator": "0001", 23 | "public_flags": 131072 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /spec/support/github.json: -------------------------------------------------------------------------------- 1 | {"login":"msa7","id":16959850,"avatar_url":"https://avatars1.githubusercontent.com/u/16959850?v=3","gravatar_id":"","url":"https://api.github.com/users/msa7","html_url":"https://github.com/msa7","followers_url":"https://api.github.com/users/msa7/followers","following_url":"https://api.github.com/users/msa7/following{/other_user}","gists_url":"https://api.github.com/users/msa7/gists{/gist_id}","starred_url":"https://api.github.com/users/msa7/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/msa7/subscriptions","organizations_url":"https://api.github.com/users/msa7/orgs","repos_url":"https://api.github.com/users/msa7/repos","events_url":"https://api.github.com/users/msa7/events{/privacy}","received_events_url":"https://api.github.com/users/msa7/received_events","type":"User","site_admin":false,"name":"Sergey Makridenkov","company":null,"blog":"msa7.ru","location":"Prague, CZ","email":"hi@msa7.ru","hireable":null,"bio":"Motto - quality and simplicity. My works are reliable and user friendly","public_repos":10,"public_gists":1,"followers":0,"following":25,"created_at":"2016-01-29T15:54:04Z","updated_at":"2017-04-11T08:35:26Z"} 2 | -------------------------------------------------------------------------------- /spec/support/gitlab.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 54, 3 | "name": "Jade D. Kharats", 4 | "username": "jade.kharats", 5 | "state": "active", 6 | "avatar_url": "https://secure.gravatar.com/avatar/90f6b53fcd57d203613bec94fa4dcd21?s=80\u0026d=identicon", 7 | "web_url": "https://gitlab.com/jade.kharats", 8 | "created_at": "2019-04-26T16:50:12.259+02:00", 9 | "bio": "", 10 | "bio_html": "", 11 | "location": null, 12 | "public_email": "", 13 | "skype": "", 14 | "linkedin": "", 15 | "twitter": "", 16 | "website_url": "", 17 | "organization": null, 18 | "job_title": "", 19 | "work_information": null, 20 | "last_sign_in_at": "2020-10-06T19:13:46.814+02:00", 21 | "confirmed_at": "2019-04-26T16:50:12.128+02:00", 22 | "last_activity_on": "2020-10-08", 23 | "email": "jade.kharats@gmail.com", 24 | "theme_id": 1, 25 | "color_scheme_id": 1, 26 | "projects_limit": 100000, 27 | "current_sign_in_at": "2020-10-08T21:53:56.028+02:00", 28 | "can_create_group": true, 29 | "can_create_project": true, 30 | "two_factor_enabled": true, 31 | "external": false, 32 | "private_profile": false 33 | } 34 | -------------------------------------------------------------------------------- /spec/support/google.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceName": "people/107107280077356483059", 3 | "etag": "%EhIBEAUXGQkUIiUuAgoNDAsDGAY=", 4 | "metadata": { 5 | "sources": [ 6 | { 7 | "type": "CONTACT", 8 | "id": "c8734cc8cdfacf3", 9 | "etag": "#TW9Lu5USlf4=" 10 | }, 11 | { 12 | "type": "CONTACT", 13 | "id": "15ea8fcc8a89f4de", 14 | "etag": "#qpol+eC6bLA=" 15 | }, 16 | { 17 | "type": "CONTACT", 18 | "id": "93", 19 | "etag": "#538/l64v70s=" 20 | }, 21 | { 22 | "type": "CONTACT", 23 | "id": "7f57a1b18d6a6aaa", 24 | "etag": "#PC96tiMIRRk=" 25 | }, 26 | { 27 | "type": "CONTACT", 28 | "id": "95", 29 | "etag": "#0NOQmzfvzLw=" 30 | }, 31 | { 32 | "type": "PROFILE", 33 | "id": "107107280077356483059", 34 | "etag": "#4eZz2/IuMFw=", 35 | "profileMetadata": { 36 | "objectType": "PERSON" 37 | } 38 | } 39 | ], 40 | "objectType": "PERSON" 41 | }, 42 | "locales": [ 43 | { 44 | "metadata": { 45 | "primary": true, 46 | "source": { 47 | "type": "ACCOUNT", 48 | "id": "107107280077356483059" 49 | } 50 | }, 51 | "value": "en" 52 | } 53 | ], 54 | "names": [ 55 | { 56 | "metadata": { 57 | "primary": true, 58 | "source": { 59 | "type": "PROFILE", 60 | "id": "107107280077356483059" 61 | } 62 | }, 63 | "displayName": "Sergey Makridenkov", 64 | "familyName": "Makridenkov", 65 | "givenName": "Sergey", 66 | "displayNameLastFirst": "Makridenkov, Sergey" 67 | }, 68 | { 69 | "metadata": { 70 | "source": { 71 | "type": "CONTACT", 72 | "id": "c8734cc8cdfacf3" 73 | } 74 | }, 75 | "displayName": "Iam Я", 76 | "familyName": "Я", 77 | "givenName": "Iam", 78 | "displayNameLastFirst": "Я, Iam" 79 | }, 80 | { 81 | "metadata": { 82 | "source": { 83 | "type": "CONTACT", 84 | "id": "15ea8fcc8a89f4de" 85 | } 86 | }, 87 | "displayName": "Sergey Makridenkov", 88 | "familyName": "Makridenkov", 89 | "givenName": "Sergey", 90 | "displayNameLastFirst": "Makridenkov, Sergey" 91 | }, 92 | { 93 | "metadata": { 94 | "source": { 95 | "type": "CONTACT", 96 | "id": "93" 97 | } 98 | }, 99 | "displayName": "Sergey", 100 | "givenName": "Sergey", 101 | "displayNameLastFirst": "Sergey" 102 | } 103 | ], 104 | "nicknames": [ 105 | { 106 | "metadata": { 107 | "primary": true, 108 | "source": { 109 | "type": "PROFILE", 110 | "id": "107107280077356483059" 111 | } 112 | }, 113 | "value": "SergXIIIth" 114 | }, 115 | { 116 | "metadata": { 117 | "source": { 118 | "type": "CONTACT", 119 | "id": "15ea8fcc8a89f4de" 120 | } 121 | }, 122 | "value": "Sergey" 123 | }, 124 | { 125 | "metadata": { 126 | "source": { 127 | "type": "CONTACT", 128 | "id": "93" 129 | } 130 | }, 131 | "value": "Sergey" 132 | } 133 | ], 134 | "coverPhotos": [ 135 | { 136 | "metadata": { 137 | "primary": true, 138 | "source": { 139 | "type": "PROFILE", 140 | "id": "107107280077356483059" 141 | } 142 | }, 143 | "url": "https://lh3.googleusercontent.com/c5dqxl-2uHZ82ah9p7yxrVF1ZssrJNSV_15Nu0TUZwzCWqmtoLxCUJgEzLGtxsrJ6-v6R6rKU_-FYm881TTiMCJ_=s1600", 144 | "default": true 145 | } 146 | ], 147 | "photos": [ 148 | { 149 | "metadata": { 150 | "primary": true, 151 | "source": { 152 | "type": "PROFILE", 153 | "id": "107107280077356483059" 154 | } 155 | }, 156 | "url": "https://lh4.googleusercontent.com/-AD281PNjUXU/AAAAAAAAAAI/AAAAAAAALYQ/3WAUiy8jbHE/s100/photo.jpg" 157 | }, 158 | { 159 | "metadata": { 160 | "source": { 161 | "type": "CONTACT", 162 | "id": "c8734cc8cdfacf3" 163 | } 164 | }, 165 | "url": "https://lh4.googleusercontent.com/-CXdSX83lhoc/V-4T6R40_VI/AAAAAAAAAAA/T5JE4AgDpgU/s100/photo.jpg" 166 | }, 167 | { 168 | "metadata": { 169 | "source": { 170 | "type": "CONTACT", 171 | "id": "15ea8fcc8a89f4de" 172 | } 173 | }, 174 | "url": "https://lh5.googleusercontent.com/-0KHl0_KdiM4/V-4TSCrGNhI/AAAAAAAAAAA/8oI0NMAJz1c/s100/photo.jpg" 175 | }, 176 | { 177 | "metadata": { 178 | "source": { 179 | "type": "CONTACT", 180 | "id": "93" 181 | } 182 | }, 183 | "url": "https://lh3.googleusercontent.com/-ehsvcWTPv6Q/V-4TXpPyIRI/AAAAAAAAAAA/az30Nif1z_o/s100/photo.jpg" 184 | }, 185 | { 186 | "metadata": { 187 | "source": { 188 | "type": "CONTACT", 189 | "id": "7f57a1b18d6a6aaa" 190 | } 191 | }, 192 | "url": "https://lh6.googleusercontent.com/-xiYsuxbe4n4/V-4TRQn2AlI/AAAAAAAAAAA/Vl5kcSh4ajQ/s100/photo.jpg" 193 | }, 194 | { 195 | "metadata": { 196 | "source": { 197 | "type": "CONTACT", 198 | "id": "95" 199 | } 200 | }, 201 | "url": "https://lh4.googleusercontent.com/-DV151mfKFes/V-4TQJidLSI/AAAAAAAAAAA/YH54nPKjiyo/s100/photo.jpg" 202 | } 203 | ], 204 | "addresses": [ 205 | { 206 | "metadata": { 207 | "primary": true, 208 | "source": { 209 | "type": "CONTACT", 210 | "id": "c8734cc8cdfacf3" 211 | } 212 | }, 213 | "formattedValue": "Konětopy č.p. 17\n440 01 Pnětluky\nokres Louny\nkraj Ústecký", 214 | "type": "other", 215 | "formattedType": "Other", 216 | "streetAddress": "Konětopy č.p. 17", 217 | "city": "Pnětluky", 218 | "region": "okres Louny\nkraj Ústecký", 219 | "postalCode": "440 01" 220 | }, 221 | { 222 | "metadata": { 223 | "source": { 224 | "type": "CONTACT", 225 | "id": "c8734cc8cdfacf3" 226 | } 227 | }, 228 | "formattedValue": "187351\nРоссия, Ленинградская обл., Кировский район, село Путилово, ул. Братьев Пожарских, д.21, кв.10", 229 | "type": "home", 230 | "formattedType": "Home", 231 | "streetAddress": "187351\nРоссия, Ленинградская обл., Кировский район, село Путилово, ул. Братьев Пожарских, д.21, кв.10" 232 | } 233 | ], 234 | "emailAddresses": [ 235 | { 236 | "metadata": { 237 | "primary": true, 238 | "verified": true, 239 | "source": { 240 | "type": "ACCOUNT", 241 | "id": "107107280077356483059" 242 | } 243 | }, 244 | "value": "smkrbr@gmail.com" 245 | }, 246 | { 247 | "metadata": { 248 | "verified": true, 249 | "source": { 250 | "type": "PROFILE", 251 | "id": "107107280077356483059" 252 | } 253 | }, 254 | "value": "smkrbr@gmail.com", 255 | "type": "home", 256 | "formattedType": "Home" 257 | }, 258 | { 259 | "metadata": { 260 | "verified": true, 261 | "source": { 262 | "type": "ACCOUNT", 263 | "id": "107107280077356483059" 264 | } 265 | }, 266 | "value": "smkrbr@mail.ru" 267 | }, 268 | { 269 | "metadata": { 270 | "verified": true, 271 | "source": { 272 | "type": "ACCOUNT", 273 | "id": "107107280077356483059" 274 | } 275 | }, 276 | "value": "sergey@makridenkov.com" 277 | }, 278 | { 279 | "metadata": { 280 | "source": { 281 | "type": "CONTACT", 282 | "id": "c8734cc8cdfacf3" 283 | } 284 | }, 285 | "value": "smkrbr@gmail.com", 286 | "type": "home", 287 | "formattedType": "Home" 288 | }, 289 | { 290 | "metadata": { 291 | "source": { 292 | "type": "CONTACT", 293 | "id": "c8734cc8cdfacf3" 294 | } 295 | }, 296 | "value": "makridenkov@gmail.com", 297 | "type": "work", 298 | "formattedType": "Work" 299 | }, 300 | { 301 | "metadata": { 302 | "source": { 303 | "type": "CONTACT", 304 | "id": "c8734cc8cdfacf3" 305 | } 306 | }, 307 | "value": "sergey@makridenkov.com", 308 | "type": "work", 309 | "formattedType": "Work" 310 | }, 311 | { 312 | "metadata": { 313 | "source": { 314 | "type": "CONTACT", 315 | "id": "c8734cc8cdfacf3" 316 | } 317 | }, 318 | "value": "smkrbr@free.kindle.com", 319 | "type": "Kindle", 320 | "formattedType": "Kindle" 321 | }, 322 | { 323 | "metadata": { 324 | "source": { 325 | "type": "CONTACT", 326 | "id": "c8734cc8cdfacf3" 327 | } 328 | }, 329 | "value": "s@msa7.ru", 330 | "type": "other", 331 | "formattedType": "Other" 332 | }, 333 | { 334 | "metadata": { 335 | "source": { 336 | "type": "CONTACT", 337 | "id": "15ea8fcc8a89f4de" 338 | } 339 | }, 340 | "value": "smkrbr@gmail.com", 341 | "type": "home", 342 | "formattedType": "Home" 343 | }, 344 | { 345 | "metadata": { 346 | "source": { 347 | "type": "CONTACT", 348 | "id": "93" 349 | } 350 | }, 351 | "value": "smkrbr@gmail.com", 352 | "type": "other", 353 | "formattedType": "Other" 354 | }, 355 | { 356 | "metadata": { 357 | "source": { 358 | "type": "CONTACT", 359 | "id": "7f57a1b18d6a6aaa" 360 | } 361 | }, 362 | "value": "sergey@makridenkov.com" 363 | }, 364 | { 365 | "metadata": { 366 | "source": { 367 | "type": "CONTACT", 368 | "id": "95" 369 | } 370 | }, 371 | "value": "smkrbr@mail.ru" 372 | } 373 | ], 374 | "phoneNumbers": [ 375 | { 376 | "metadata": { 377 | "primary": true, 378 | "source": { 379 | "type": "CONTACT", 380 | "id": "c8734cc8cdfacf3" 381 | } 382 | }, 383 | "value": "+7 953 354-06-92", 384 | "canonicalForm": "+79533540692", 385 | "type": "work", 386 | "formattedType": "Work" 387 | }, 388 | { 389 | "metadata": { 390 | "source": { 391 | "type": "CONTACT", 392 | "id": "c8734cc8cdfacf3" 393 | } 394 | }, 395 | "value": "+420775357809", 396 | "canonicalForm": "+420775357809", 397 | "type": "mobile", 398 | "formattedType": "Mobile" 399 | }, 400 | { 401 | "metadata": { 402 | "source": { 403 | "type": "CONTACT", 404 | "id": "c8734cc8cdfacf3" 405 | } 406 | }, 407 | "value": "+79051621936", 408 | "canonicalForm": "+79051621936", 409 | "type": "home", 410 | "formattedType": "Home" 411 | }, 412 | { 413 | "metadata": { 414 | "source": { 415 | "type": "PROFILE", 416 | "id": "107107280077356483059" 417 | } 418 | }, 419 | "value": "+420774668079", 420 | "type": "mobile", 421 | "formattedType": "Mobile" 422 | } 423 | ], 424 | "biographies": [ 425 | { 426 | "metadata": { 427 | "primary": true, 428 | "source": { 429 | "type": "CONTACT", 430 | "id": "c8734cc8cdfacf3" 431 | } 432 | }, 433 | "value": "2600987645 / 2010 fio income\n\n\nmBank mKonto \nČíslo účtu 670100-2209502078/6210 \nČíslo účtu ve formátu IBAN CZ61 6210 6701 0022 0950 2078 \nČíslo BIC BREXCZPP \n\nIC 01311131\nDIC CZ8409272630", 434 | "contentType": "TEXT_PLAIN" 435 | } 436 | ], 437 | "urls": [ 438 | { 439 | "metadata": { 440 | "primary": true, 441 | "verified": true, 442 | "source": { 443 | "type": "PROFILE", 444 | "id": "107107280077356483059" 445 | } 446 | }, 447 | "value": "https://plus.google.com/107107280077356483059", 448 | "type": "profile", 449 | "formattedType": "Profile" 450 | } 451 | ], 452 | "organizations": [ 453 | { 454 | "metadata": { 455 | "primary": true, 456 | "source": { 457 | "type": "PROFILE", 458 | "id": "107107280077356483059" 459 | } 460 | }, 461 | "type": "work", 462 | "formattedType": "Work", 463 | "endDate": { 464 | "year": 2011, 465 | "month": 1, 466 | "day": 1 467 | }, 468 | "current": true, 469 | "title": "Programmer, Web developer" 470 | } 471 | ], 472 | "occupations": [ 473 | { 474 | "metadata": { 475 | "primary": true, 476 | "source": { 477 | "type": "PROFILE", 478 | "id": "107107280077356483059" 479 | } 480 | }, 481 | "value": "Programmer, Web developer" 482 | } 483 | ] 484 | } 485 | -------------------------------------------------------------------------------- /spec/support/google_api_disabled.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": 403, 4 | "message": "Google People API has not been used in project test-multi-auth before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/people.googleapis.com/overview?project=test-multi-auth then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", 5 | "status": "PERMISSION_DENIED", 6 | "details": [ 7 | { 8 | "@type": "type.googleapis.com/google.rpc.Help", 9 | "links": [ 10 | { 11 | "description": "Google developers console API activation", 12 | "url": "https://console.developers.google.com/apis/api/people.googleapis.com/overview?project=test-multi-auth" 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/support/google_without_names.json: -------------------------------------------------------------------------------- 1 | { 2 | "resourceName": "people/107107280077356483059", 3 | "etag": "%EhIBEAUXGQkUIiUuAgoNDAsDGAY=", 4 | "metadata": { 5 | "sources": [ 6 | { 7 | "type": "CONTACT", 8 | "id": "c8734cc8cdfacf3", 9 | "etag": "#TW9Lu5USlf4=" 10 | }, 11 | { 12 | "type": "CONTACT", 13 | "id": "15ea8fcc8a89f4de", 14 | "etag": "#qpol+eC6bLA=" 15 | }, 16 | { 17 | "type": "CONTACT", 18 | "id": "93", 19 | "etag": "#538/l64v70s=" 20 | }, 21 | { 22 | "type": "CONTACT", 23 | "id": "7f57a1b18d6a6aaa", 24 | "etag": "#PC96tiMIRRk=" 25 | }, 26 | { 27 | "type": "CONTACT", 28 | "id": "95", 29 | "etag": "#0NOQmzfvzLw=" 30 | }, 31 | { 32 | "type": "PROFILE", 33 | "id": "107107280077356483059", 34 | "etag": "#4eZz2/IuMFw=", 35 | "profileMetadata": { 36 | "objectType": "PERSON" 37 | } 38 | } 39 | ], 40 | "objectType": "PERSON" 41 | }, 42 | "locales": [ 43 | { 44 | "metadata": { 45 | "primary": true, 46 | "source": { 47 | "type": "ACCOUNT", 48 | "id": "107107280077356483059" 49 | } 50 | }, 51 | "value": "en" 52 | } 53 | ], 54 | "names": [], 55 | "nicknames": [ 56 | { 57 | "metadata": { 58 | "primary": true, 59 | "source": { 60 | "type": "PROFILE", 61 | "id": "107107280077356483059" 62 | } 63 | }, 64 | "value": "SergXIIIth" 65 | }, 66 | { 67 | "metadata": { 68 | "source": { 69 | "type": "CONTACT", 70 | "id": "15ea8fcc8a89f4de" 71 | } 72 | }, 73 | "value": "Sergey" 74 | }, 75 | { 76 | "metadata": { 77 | "source": { 78 | "type": "CONTACT", 79 | "id": "93" 80 | } 81 | }, 82 | "value": "Sergey" 83 | } 84 | ], 85 | "coverPhotos": [ 86 | { 87 | "metadata": { 88 | "primary": true, 89 | "source": { 90 | "type": "PROFILE", 91 | "id": "107107280077356483059" 92 | } 93 | }, 94 | "url": "https://lh3.googleusercontent.com/c5dqxl-2uHZ82ah9p7yxrVF1ZssrJNSV_15Nu0TUZwzCWqmtoLxCUJgEzLGtxsrJ6-v6R6rKU_-FYm881TTiMCJ_=s1600", 95 | "default": true 96 | } 97 | ], 98 | "photos": [ 99 | { 100 | "metadata": { 101 | "primary": true, 102 | "source": { 103 | "type": "PROFILE", 104 | "id": "107107280077356483059" 105 | } 106 | }, 107 | "url": "https://lh4.googleusercontent.com/-AD281PNjUXU/AAAAAAAAAAI/AAAAAAAALYQ/3WAUiy8jbHE/s100/photo.jpg" 108 | }, 109 | { 110 | "metadata": { 111 | "source": { 112 | "type": "CONTACT", 113 | "id": "c8734cc8cdfacf3" 114 | } 115 | }, 116 | "url": "https://lh4.googleusercontent.com/-CXdSX83lhoc/V-4T6R40_VI/AAAAAAAAAAA/T5JE4AgDpgU/s100/photo.jpg" 117 | }, 118 | { 119 | "metadata": { 120 | "source": { 121 | "type": "CONTACT", 122 | "id": "15ea8fcc8a89f4de" 123 | } 124 | }, 125 | "url": "https://lh5.googleusercontent.com/-0KHl0_KdiM4/V-4TSCrGNhI/AAAAAAAAAAA/8oI0NMAJz1c/s100/photo.jpg" 126 | }, 127 | { 128 | "metadata": { 129 | "source": { 130 | "type": "CONTACT", 131 | "id": "93" 132 | } 133 | }, 134 | "url": "https://lh3.googleusercontent.com/-ehsvcWTPv6Q/V-4TXpPyIRI/AAAAAAAAAAA/az30Nif1z_o/s100/photo.jpg" 135 | }, 136 | { 137 | "metadata": { 138 | "source": { 139 | "type": "CONTACT", 140 | "id": "7f57a1b18d6a6aaa" 141 | } 142 | }, 143 | "url": "https://lh6.googleusercontent.com/-xiYsuxbe4n4/V-4TRQn2AlI/AAAAAAAAAAA/Vl5kcSh4ajQ/s100/photo.jpg" 144 | }, 145 | { 146 | "metadata": { 147 | "source": { 148 | "type": "CONTACT", 149 | "id": "95" 150 | } 151 | }, 152 | "url": "https://lh4.googleusercontent.com/-DV151mfKFes/V-4TQJidLSI/AAAAAAAAAAA/YH54nPKjiyo/s100/photo.jpg" 153 | } 154 | ], 155 | "addresses": [ 156 | { 157 | "metadata": { 158 | "primary": true, 159 | "source": { 160 | "type": "CONTACT", 161 | "id": "c8734cc8cdfacf3" 162 | } 163 | }, 164 | "formattedValue": "Konětopy č.p. 17\n440 01 Pnětluky\nokres Louny\nkraj Ústecký", 165 | "type": "other", 166 | "formattedType": "Other", 167 | "streetAddress": "Konětopy č.p. 17", 168 | "city": "Pnětluky", 169 | "region": "okres Louny\nkraj Ústecký", 170 | "postalCode": "440 01" 171 | }, 172 | { 173 | "metadata": { 174 | "source": { 175 | "type": "CONTACT", 176 | "id": "c8734cc8cdfacf3" 177 | } 178 | }, 179 | "formattedValue": "187351\nРоссия, Ленинградская обл., Кировский район, село Путилово, ул. Братьев Пожарских, д.21, кв.10", 180 | "type": "home", 181 | "formattedType": "Home", 182 | "streetAddress": "187351\nРоссия, Ленинградская обл., Кировский район, село Путилово, ул. Братьев Пожарских, д.21, кв.10" 183 | } 184 | ], 185 | "emailAddresses": [ 186 | { 187 | "metadata": { 188 | "primary": true, 189 | "verified": true, 190 | "source": { 191 | "type": "ACCOUNT", 192 | "id": "107107280077356483059" 193 | } 194 | }, 195 | "value": "smkrbr@gmail.com" 196 | }, 197 | { 198 | "metadata": { 199 | "verified": true, 200 | "source": { 201 | "type": "PROFILE", 202 | "id": "107107280077356483059" 203 | } 204 | }, 205 | "value": "smkrbr@gmail.com", 206 | "type": "home", 207 | "formattedType": "Home" 208 | }, 209 | { 210 | "metadata": { 211 | "verified": true, 212 | "source": { 213 | "type": "ACCOUNT", 214 | "id": "107107280077356483059" 215 | } 216 | }, 217 | "value": "smkrbr@mail.ru" 218 | }, 219 | { 220 | "metadata": { 221 | "verified": true, 222 | "source": { 223 | "type": "ACCOUNT", 224 | "id": "107107280077356483059" 225 | } 226 | }, 227 | "value": "sergey@makridenkov.com" 228 | }, 229 | { 230 | "metadata": { 231 | "source": { 232 | "type": "CONTACT", 233 | "id": "c8734cc8cdfacf3" 234 | } 235 | }, 236 | "value": "smkrbr@gmail.com", 237 | "type": "home", 238 | "formattedType": "Home" 239 | }, 240 | { 241 | "metadata": { 242 | "source": { 243 | "type": "CONTACT", 244 | "id": "c8734cc8cdfacf3" 245 | } 246 | }, 247 | "value": "makridenkov@gmail.com", 248 | "type": "work", 249 | "formattedType": "Work" 250 | }, 251 | { 252 | "metadata": { 253 | "source": { 254 | "type": "CONTACT", 255 | "id": "c8734cc8cdfacf3" 256 | } 257 | }, 258 | "value": "sergey@makridenkov.com", 259 | "type": "work", 260 | "formattedType": "Work" 261 | }, 262 | { 263 | "metadata": { 264 | "source": { 265 | "type": "CONTACT", 266 | "id": "c8734cc8cdfacf3" 267 | } 268 | }, 269 | "value": "smkrbr@free.kindle.com", 270 | "type": "Kindle", 271 | "formattedType": "Kindle" 272 | }, 273 | { 274 | "metadata": { 275 | "source": { 276 | "type": "CONTACT", 277 | "id": "c8734cc8cdfacf3" 278 | } 279 | }, 280 | "value": "s@msa7.ru", 281 | "type": "other", 282 | "formattedType": "Other" 283 | }, 284 | { 285 | "metadata": { 286 | "source": { 287 | "type": "CONTACT", 288 | "id": "15ea8fcc8a89f4de" 289 | } 290 | }, 291 | "value": "smkrbr@gmail.com", 292 | "type": "home", 293 | "formattedType": "Home" 294 | }, 295 | { 296 | "metadata": { 297 | "source": { 298 | "type": "CONTACT", 299 | "id": "93" 300 | } 301 | }, 302 | "value": "smkrbr@gmail.com", 303 | "type": "other", 304 | "formattedType": "Other" 305 | }, 306 | { 307 | "metadata": { 308 | "source": { 309 | "type": "CONTACT", 310 | "id": "7f57a1b18d6a6aaa" 311 | } 312 | }, 313 | "value": "sergey@makridenkov.com" 314 | }, 315 | { 316 | "metadata": { 317 | "source": { 318 | "type": "CONTACT", 319 | "id": "95" 320 | } 321 | }, 322 | "value": "smkrbr@mail.ru" 323 | } 324 | ], 325 | "phoneNumbers": [ 326 | { 327 | "metadata": { 328 | "primary": true, 329 | "source": { 330 | "type": "CONTACT", 331 | "id": "c8734cc8cdfacf3" 332 | } 333 | }, 334 | "value": "+7 953 354-06-92", 335 | "canonicalForm": "+79533540692", 336 | "type": "work", 337 | "formattedType": "Work" 338 | }, 339 | { 340 | "metadata": { 341 | "source": { 342 | "type": "CONTACT", 343 | "id": "c8734cc8cdfacf3" 344 | } 345 | }, 346 | "value": "+420775357809", 347 | "canonicalForm": "+420775357809", 348 | "type": "mobile", 349 | "formattedType": "Mobile" 350 | }, 351 | { 352 | "metadata": { 353 | "source": { 354 | "type": "CONTACT", 355 | "id": "c8734cc8cdfacf3" 356 | } 357 | }, 358 | "value": "+79051621936", 359 | "canonicalForm": "+79051621936", 360 | "type": "home", 361 | "formattedType": "Home" 362 | }, 363 | { 364 | "metadata": { 365 | "source": { 366 | "type": "PROFILE", 367 | "id": "107107280077356483059" 368 | } 369 | }, 370 | "value": "+420774668079", 371 | "type": "mobile", 372 | "formattedType": "Mobile" 373 | } 374 | ], 375 | "biographies": [ 376 | { 377 | "metadata": { 378 | "primary": true, 379 | "source": { 380 | "type": "CONTACT", 381 | "id": "c8734cc8cdfacf3" 382 | } 383 | }, 384 | "value": "2600987645 / 2010 fio income\n\n\nmBank mKonto \nČíslo účtu 670100-2209502078/6210 \nČíslo účtu ve formátu IBAN CZ61 6210 6701 0022 0950 2078 \nČíslo BIC BREXCZPP \n\nIC 01311131\nDIC CZ8409272630", 385 | "contentType": "TEXT_PLAIN" 386 | } 387 | ], 388 | "urls": [ 389 | { 390 | "metadata": { 391 | "primary": true, 392 | "verified": true, 393 | "source": { 394 | "type": "PROFILE", 395 | "id": "107107280077356483059" 396 | } 397 | }, 398 | "value": "https://plus.google.com/107107280077356483059", 399 | "type": "profile", 400 | "formattedType": "Profile" 401 | } 402 | ], 403 | "organizations": [ 404 | { 405 | "metadata": { 406 | "primary": true, 407 | "source": { 408 | "type": "PROFILE", 409 | "id": "107107280077356483059" 410 | } 411 | }, 412 | "type": "work", 413 | "formattedType": "Work", 414 | "endDate": { 415 | "year": 2011, 416 | "month": 1, 417 | "day": 1 418 | }, 419 | "current": true, 420 | "title": "Programmer, Web developer" 421 | } 422 | ], 423 | "occupations": [ 424 | { 425 | "metadata": { 426 | "primary": true, 427 | "source": { 428 | "type": "PROFILE", 429 | "id": "107107280077356483059" 430 | } 431 | }, 432 | "value": "Programmer, Web developer" 433 | } 434 | ] 435 | } 436 | -------------------------------------------------------------------------------- /spec/support/restream.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1000, 3 | "username": "xxxxx", 4 | "email": "xxxxx@email.test" 5 | } 6 | -------------------------------------------------------------------------------- /src/multi_auth.cr: -------------------------------------------------------------------------------- 1 | require "oauth" 2 | require "oauth2" 3 | require "./multi_auth/**" 4 | 5 | module MultiAuth 6 | @@configuration = Hash(String, Array(String)).new 7 | 8 | def self.make(provider, redirect_uri) 9 | MultiAuth::Engine.new(provider, redirect_uri) 10 | end 11 | 12 | def self.configuration 13 | @@configuration 14 | end 15 | 16 | def self.config(provider, key, secret) 17 | @@configuration[provider] = [key, secret] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/multi_auth/engine.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Engine 2 | def initialize(provider : String, redirect_uri : String) 3 | provider_class = case provider 4 | when "google" then Provider::Google 5 | when "github" then Provider::Github 6 | when "gitlab" then Provider::Gitlab 7 | when "facebook" then Provider::Facebook 8 | when "vk" then Provider::Vk 9 | when "twitter" then Provider::Twitter 10 | when "restream" then Provider::Restream 11 | when "discord" then Provider::Discord 12 | else 13 | raise "Provider #{provider} not implemented" 14 | end 15 | 16 | key, secret = MultiAuth.configuration[provider] 17 | @provider = provider_class.new(redirect_uri, key, secret) 18 | end 19 | 20 | def initialize(@provider) 21 | end 22 | 23 | getter provider : Provider 24 | 25 | def authorize_uri(scope = nil, state = nil) 26 | provider.authorize_uri(scope, state) 27 | end 28 | 29 | def user(params : Enumerable({String, String})) : User 30 | provider.user(params.to_h) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/multi_auth/provider.cr: -------------------------------------------------------------------------------- 1 | abstract class MultiAuth::Provider 2 | getter redirect_uri : String 3 | getter key : String 4 | getter secret : String 5 | 6 | abstract def authorize_uri(scope = nil, state = nil) 7 | abstract def user(params : Hash(String, String)) 8 | 9 | def initialize(@redirect_uri : String, @key : String, @secret : String) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/multi_auth/providers/discord.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Discord < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | defaults = [ 4 | "identify", 5 | ] 6 | 7 | scope ||= defaults.join(" ") 8 | 9 | client = OAuth2::Client.new( 10 | "discord.com", 11 | key, 12 | secret, 13 | authorize_uri: "/api/oauth2/authorize", 14 | redirect_uri: redirect_uri 15 | ) 16 | 17 | client.get_authorize_uri(scope, state) 18 | end 19 | 20 | private class DiscordUser 21 | include JSON::Serializable 22 | 23 | property raw_json : String? 24 | property access_token : OAuth::AccessToken? 25 | 26 | @[JSON::Field(converter: String::RawConverter)] 27 | property id : String 28 | 29 | property name : String 30 | property email : String? 31 | property avatar : String? 32 | end 33 | 34 | private def fetch_discord_user(oauth_token, oauth_verifier) 35 | request_token = OAuth::RequestToken.new(oauth_token, "") 36 | access_token = consumer.get_access_token(request_token, oauth_verifier) 37 | 38 | client = HTTP::Client.new("discord.com", tls: true) 39 | access_token.authenticate(client, key, secret) 40 | 41 | raw_json = client.get("/oauth2/authorize").body 42 | 43 | DiscordUser.from_json(raw_json).tap do |user| 44 | user.access_token = access_token 45 | user.raw_json = raw_json 46 | end 47 | end 48 | 49 | def user(params : Hash(String, String)) 50 | client = OAuth2::Client.new( 51 | "discord.com", 52 | key, 53 | secret, 54 | token_uri: "/api/v8/oauth2/token", 55 | redirect_uri: redirect_uri, 56 | auth_scheme: :request_body 57 | ) 58 | 59 | access_token = client.get_access_token_using_authorization_code(params["code"]) 60 | 61 | api = HTTP::Client.new("discord.com", tls: true) 62 | access_token.authenticate(api) 63 | 64 | raw_json = api.get("/api/v8/oauth2/@me").body 65 | 66 | build_user(raw_json, access_token) 67 | end 68 | 69 | private def json 70 | @json.as(JSON::Any) 71 | end 72 | 73 | private def build_user(raw_json, access_token) 74 | @json = JSON.parse(raw_json) 75 | 76 | user = User.new( 77 | "discord", 78 | json["user"].as_h["id"].as_s, 79 | json["user"].as_h["username"].as_s, 80 | raw_json, 81 | access_token 82 | ) 83 | 84 | if avatar = json["user"].as_h["avatar"]? 85 | user.image = "https://cdn.discordapp.com/avatars/#{json["user"].as_h["id"]}/#{avatar}" 86 | end 87 | 88 | user 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /src/multi_auth/providers/facebook.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Facebook < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | scope ||= "email" 4 | client.get_authorize_uri(scope, state) 5 | end 6 | 7 | def user(params : Hash(String, String)) 8 | fb_user = fetch_fb_user(params["code"]) 9 | 10 | user = User.new( 11 | "facebook", 12 | fb_user.id, 13 | fb_user.name, 14 | fb_user.raw_json.as(String), 15 | fb_user.access_token.not_nil! 16 | ) 17 | 18 | user.email = fb_user.email 19 | user.first_name = fb_user.first_name 20 | user.last_name = fb_user.last_name 21 | user.location = fb_user.location 22 | user.description = fb_user.about 23 | 24 | urls = {} of String => String 25 | urls["web"] = fb_user.website.as(String) if fb_user.website 26 | user.urls = urls unless urls.empty? 27 | 28 | user 29 | end 30 | 31 | private class FbUser 32 | include JSON::Serializable 33 | 34 | property raw_json : String? 35 | property access_token : OAuth2::AccessToken? 36 | property picture_url : String? 37 | 38 | property id : String 39 | property name : String 40 | property last_name : String? 41 | property first_name : String? 42 | property email : String? 43 | property location : String? 44 | property about : String? 45 | property website : String? 46 | end 47 | 48 | private def fetch_fb_user(code) 49 | access_token = token_client.get_access_token_using_authorization_code(code) 50 | api = HTTP::Client.new("graph.facebook.com", tls: true) 51 | access_token.authenticate(api) 52 | 53 | raw_json = api.get("/v2.9/me?fields=id,name,last_name,first_name,email,location,about,website").body 54 | 55 | fb_user = FbUser.from_json(raw_json) 56 | fb_user.access_token = access_token 57 | fb_user.raw_json = raw_json 58 | 59 | fb_user 60 | end 61 | 62 | private def client 63 | OAuth2::Client.new( 64 | "www.facebook.com", 65 | key, 66 | secret, 67 | redirect_uri: redirect_uri, 68 | authorize_uri: "/v2.9/dialog/oauth", 69 | ) 70 | end 71 | 72 | private def token_client 73 | OAuth2::Client.new( 74 | "graph.facebook.com", 75 | key, 76 | secret, 77 | redirect_uri: redirect_uri, 78 | token_uri: "/v2.9/oauth/access_token", 79 | auth_scheme: :request_body 80 | ) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/multi_auth/providers/github.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Github < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | scope ||= "user:email" 4 | client.get_authorize_uri(scope, state) 5 | end 6 | 7 | def user(params : Hash(String, String)) 8 | gh_user = fetch_gh_user(params["code"]) 9 | 10 | user = User.new( 11 | "github", 12 | gh_user.id, 13 | gh_user.name, 14 | gh_user.raw_json.as(String), 15 | gh_user.access_token.not_nil! 16 | ) 17 | 18 | user.email = gh_user.email 19 | user.nickname = gh_user.login 20 | user.location = gh_user.location 21 | user.description = gh_user.bio 22 | user.image = gh_user.avatar_url 23 | 24 | urls = {} of String => String 25 | urls["blog"] = gh_user.blog.as(String) if gh_user.blog 26 | urls["github"] = gh_user.html_url.as(String) if gh_user.html_url 27 | user.urls = urls unless urls.empty? 28 | 29 | user 30 | end 31 | 32 | private class GhUser 33 | include JSON::Serializable 34 | 35 | property raw_json : String? 36 | property access_token : OAuth2::AccessToken? 37 | 38 | @[JSON::Field(converter: String::RawConverter)] 39 | property id : String 40 | 41 | property name : String? 42 | property email : String? 43 | property login : String 44 | property location : String? 45 | property bio : String? 46 | property avatar_url : String? 47 | property blog : String? 48 | property html_url : String? 49 | end 50 | 51 | private def fetch_gh_user(code) 52 | access_token = client.get_access_token_using_authorization_code(code) 53 | 54 | api = HTTP::Client.new("api.github.com", tls: true) 55 | access_token.authenticate(api) 56 | 57 | raw_json = api.get("/user").body 58 | gh_user = GhUser.from_json(raw_json) 59 | gh_user.access_token = access_token 60 | gh_user.raw_json = raw_json 61 | gh_user 62 | end 63 | 64 | private def client 65 | OAuth2::Client.new( 66 | "github.com", 67 | key, 68 | secret, 69 | authorize_uri: "/login/oauth/authorize", 70 | token_uri: "/login/oauth/access_token", 71 | auth_scheme: :request_body 72 | ) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /src/multi_auth/providers/gitlab.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Gitlab < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | scope ||= "" 4 | client.get_authorize_uri(scope, state) 5 | end 6 | 7 | def user(params : Hash(String, String)) 8 | gitlab_user = fetch_gitlab_user(params["code"]) 9 | 10 | user = User.new( 11 | "gitlab", 12 | gitlab_user.id, 13 | gitlab_user.name, 14 | gitlab_user.raw_json.as(String), 15 | gitlab_user.access_token.not_nil! 16 | ) 17 | 18 | user.email = gitlab_user.email 19 | user.nickname = gitlab_user.username 20 | user.location = gitlab_user.location 21 | user.description = gitlab_user.bio 22 | user.image = gitlab_user.avatar_url 23 | 24 | urls = {} of String => String 25 | urls["gitlab"] = gitlab_user.web_url.as(String) if gitlab_user.web_url 26 | 27 | user 28 | end 29 | 30 | private class GitlabUser 31 | include JSON::Serializable 32 | 33 | property raw_json : String? 34 | property access_token : OAuth2::AccessToken? 35 | 36 | @[JSON::Field(converter: String::RawConverter)] 37 | property id : String 38 | 39 | property name : String 40 | property username : String 41 | property avatar_url : String? 42 | property web_url : String? 43 | property bio : String? 44 | property location : String? 45 | property email : String? 46 | end 47 | 48 | private def fetch_gitlab_user(code) 49 | access_token = client.get_access_token_using_authorization_code(code) 50 | 51 | api = HTTP::Client.new(gitlab_url, tls: true) 52 | access_token.authenticate(api) 53 | 54 | raw_json = api.get("/api/v4/user").body 55 | gitlab_user = GitlabUser.from_json(raw_json) 56 | gitlab_user.access_token = access_token 57 | gitlab_user.raw_json = raw_json 58 | gitlab_user 59 | end 60 | 61 | private def gitlab_url 62 | ENV["OAUTH_GITLAB_URI"]? || "gitlab.com" 63 | end 64 | 65 | private def client 66 | OAuth2::Client.new( 67 | gitlab_url, 68 | key, 69 | secret, 70 | authorize_uri: "/oauth/authorize", 71 | token_uri: "/oauth/token", 72 | redirect_uri: redirect_uri, 73 | auth_scheme: :request_body 74 | ) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /src/multi_auth/providers/google.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Google < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | defaults = [ 4 | "https://www.googleapis.com/auth/user.emails.read", 5 | "https://www.googleapis.com/auth/user.phonenumbers.read", 6 | "https://www.googleapis.com/auth/user.addresses.read", 7 | "https://www.googleapis.com/auth/plus.login", 8 | "https://www.googleapis.com/auth/contacts.readonly", 9 | ] 10 | 11 | scope ||= defaults.join(" ") 12 | 13 | client = OAuth2::Client.new( 14 | "accounts.google.com", 15 | key, 16 | secret, 17 | authorize_uri: "/o/oauth2/v2/auth", 18 | redirect_uri: redirect_uri 19 | ) 20 | 21 | client.get_authorize_uri(scope, state) 22 | end 23 | 24 | def user(params : Hash(String, String)) 25 | client = OAuth2::Client.new( 26 | "www.googleapis.com", 27 | key, 28 | secret, 29 | token_uri: "/oauth2/v4/token", 30 | redirect_uri: redirect_uri, 31 | auth_scheme: :request_body 32 | ) 33 | 34 | access_token = client.get_access_token_using_authorization_code(params["code"]) 35 | 36 | # https://developers.google.com/people/api/rest/v1/people/get 37 | # enable Google People API 38 | 39 | api = HTTP::Client.new("people.googleapis.com", tls: true) 40 | access_token.authenticate(api) 41 | 42 | fields = [ 43 | "addresses", 44 | "biographies", 45 | "bragging_rights", 46 | "cover_photos", 47 | "email_addresses", 48 | "im_clients", 49 | "interests", 50 | "names", 51 | "nicknames", 52 | "phone_numbers", 53 | "photos", 54 | "urls", 55 | ].join(",") 56 | 57 | raw_json = api.get("/v1/people/me?personFields=#{fields}").body 58 | 59 | build_user(raw_json, access_token) 60 | end 61 | 62 | private def primary(field) 63 | primary = primary?(field) 64 | raise "No primary in field #{field}" unless primary 65 | primary 66 | end 67 | 68 | private def primary?(field) 69 | field = json[field]? 70 | return unless field 71 | 72 | field.as_a.each do |item| 73 | return item if item["metadata"]["primary"].as_bool? 74 | end 75 | 76 | nil 77 | end 78 | 79 | private def json 80 | @json.as(JSON::Any) 81 | end 82 | 83 | private def build_user(raw_json, access_token) 84 | @json = JSON.parse(raw_json) 85 | raise json["error"]["message"].as_s if json["error"]? 86 | 87 | name = if primary?("names") 88 | primary("names") 89 | else 90 | JSON::Any.new({} of String => JSON::Any) 91 | end 92 | 93 | display_name = name["displayName"]?.to_s 94 | 95 | user = User.new( 96 | "google", 97 | json["resourceName"].as_s, 98 | display_name, 99 | raw_json, 100 | access_token 101 | ) 102 | 103 | user.first_name = name["givenName"].as_s? if name["givenName"]? 104 | user.last_name = name["familyName"].as_s? if name["familyName"]? 105 | 106 | user.nickname = primary("nicknames")["value"].as_s if primary?("nicknames") 107 | user.image = primary("photos")["url"].as_s if primary?("photos") 108 | user.location = primary("addresses")["formattedValue"].as_s if primary?("addresses") 109 | user.email = primary("emailAddresses")["value"].as_s if primary?("emailAddresses") 110 | user.phone = primary("phoneNumbers")["canonicalForm"].as_s if primary?("phoneNumbers") 111 | user.description = primary("biographies")["value"].as_s if primary?("biographies") 112 | 113 | if json["urls"]? 114 | urls = {} of String => String 115 | json["urls"].as_a.each do |url| 116 | urls[url["type"].as_s] = url["value"].as_s 117 | end 118 | user.urls = urls 119 | end 120 | 121 | user 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /src/multi_auth/providers/restream.cr: -------------------------------------------------------------------------------- 1 | require "uuid" 2 | 3 | class MultiAuth::Provider::Restream < MultiAuth::Provider 4 | def authorize_uri(scope = nil, state = nil) 5 | state ||= UUID.random.to_s 6 | uri = client.get_authorize_uri(scope, state) 7 | end 8 | 9 | def user(params : Hash(String, String)) 10 | rs_user = fetch_rs_user(params["code"]) 11 | 12 | user = User.new( 13 | "restream", 14 | rs_user.id, 15 | rs_user.username, 16 | rs_user.raw_json.as(String), 17 | rs_user.access_token.not_nil! 18 | ) 19 | 20 | user.email = rs_user.email 21 | 22 | user 23 | end 24 | 25 | private class RestreamUser 26 | include JSON::Serializable 27 | 28 | property raw_json : String? 29 | property access_token : OAuth2::AccessToken? 30 | 31 | @[JSON::Field(converter: String::RawConverter)] 32 | property id : String 33 | 34 | property username : String? 35 | property email : String? 36 | end 37 | 38 | private def fetch_rs_user(code) 39 | access_token = client.get_access_token_using_authorization_code(code) 40 | 41 | api = HTTP::Client.new("api.restream.io", tls: true) 42 | access_token.authenticate(api) 43 | 44 | raw_json = api.get("/v2/user/profile").body 45 | rs_user = RestreamUser.from_json(raw_json) 46 | rs_user.access_token = access_token 47 | rs_user.raw_json = raw_json 48 | rs_user 49 | end 50 | 51 | private def client 52 | OAuth2::Client.new( 53 | "api.restream.io", 54 | key, 55 | secret, 56 | authorize_uri: "/login", 57 | token_uri: "/oauth/token", 58 | redirect_uri: redirect_uri, 59 | auth_scheme: :http_basic 60 | ) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /src/multi_auth/providers/twitter.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Twitter < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | request_token = consumer.get_request_token(redirect_uri) 4 | consumer.get_authorize_uri(request_token, redirect_uri) 5 | end 6 | 7 | def user(params : Hash(String, String)) 8 | tw_user = fetch_tw_user(params["oauth_token"], params["oauth_verifier"]) 9 | 10 | user = User.new( 11 | "twitter", 12 | tw_user.id, 13 | tw_user.name, 14 | tw_user.raw_json.to_s, 15 | tw_user.access_token.not_nil! 16 | ) 17 | 18 | user.email = tw_user.email 19 | user.nickname = tw_user.screen_name 20 | user.location = tw_user.location 21 | user.description = tw_user.description 22 | user.image = tw_user.profile_image_url 23 | if url = tw_user.url 24 | user.urls = {"twitter" => url} 25 | end 26 | 27 | user 28 | end 29 | 30 | private class TwUser 31 | include JSON::Serializable 32 | 33 | property raw_json : String? 34 | property access_token : OAuth::AccessToken? 35 | 36 | @[JSON::Field(converter: String::RawConverter)] 37 | property id : String 38 | 39 | property name : String 40 | property screen_name : String 41 | property location : String? 42 | property description : String? 43 | property url : String? 44 | property profile_image_url : String? 45 | property email : String? 46 | end 47 | 48 | private def fetch_tw_user(oauth_token, oauth_verifier) 49 | request_token = OAuth::RequestToken.new(oauth_token, "") 50 | 51 | access_token = consumer.get_access_token(request_token, oauth_verifier) 52 | 53 | client = HTTP::Client.new("api.twitter.com", tls: true) 54 | access_token.authenticate(client, key, secret) 55 | 56 | raw_json = client.get("/1.1/account/verify_credentials.json?include_email=true").body 57 | 58 | TwUser.from_json(raw_json).tap do |user| 59 | user.access_token = access_token 60 | user.raw_json = raw_json 61 | end 62 | end 63 | 64 | private def consumer 65 | @consumer ||= OAuth::Consumer.new("api.twitter.com", key, secret) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /src/multi_auth/providers/vk.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::Provider::Vk < MultiAuth::Provider 2 | def authorize_uri(scope = nil, state = nil) 3 | scope ||= "email" 4 | client.get_authorize_uri(scope, state) 5 | end 6 | 7 | def user(params : Hash(String, String)) 8 | vk_user = fetch_vk_user(params["code"]) 9 | 10 | user = User.new( 11 | "vk", 12 | vk_user.id, 13 | vk_user.name, 14 | vk_user.raw_json.not_nil!, 15 | vk_user.access_token.not_nil! 16 | ) 17 | 18 | user.email = vk_user.email 19 | user.first_name = vk_user.first_name 20 | user.last_name = vk_user.last_name 21 | user.nickname = vk_user.domain 22 | user.description = vk_user.about 23 | user.image = vk_user.photo_max_orig 24 | user.phone = vk_user.mobile_phone || vk_user.home_phone 25 | 26 | location = [] of String 27 | location << vk_user.city.not_nil!.title if vk_user.city 28 | location << vk_user.country.not_nil!.title if vk_user.country 29 | user.location = location.join(", ") unless location.empty? 30 | 31 | urls = {} of String => String 32 | urls["web"] = vk_user.site.not_nil! if vk_user.site 33 | user.urls = urls unless urls.empty? 34 | 35 | user 36 | end 37 | 38 | class VkTitle 39 | include JSON::Serializable 40 | property title : String 41 | end 42 | 43 | class VkUser 44 | include JSON::Serializable 45 | 46 | property raw_json : String? 47 | property access_token : OAuth2::AccessToken? 48 | property email : String? 49 | property id : String? 50 | 51 | def name 52 | "#{last_name} #{first_name}" 53 | end 54 | 55 | @[JSON::Field(converter: String::RawConverter)] 56 | property id : String 57 | 58 | property last_name : String? 59 | property first_name : String? 60 | property site : String? 61 | property city : VkTitle? 62 | property country : VkTitle? 63 | property domain : String? 64 | property about : String? 65 | property photo_max_orig : String? 66 | property mobile_phone : String? 67 | property home_phone : String? 68 | end 69 | 70 | class VkResponse 71 | include JSON::Serializable 72 | property response : Array(VkUser) 73 | end 74 | 75 | private def fetch_vk_user(code) 76 | access_token = client.get_access_token_using_authorization_code(code) 77 | 78 | api = HTTP::Client.new("api.vk.com", tls: true) 79 | access_token.authenticate(api) 80 | 81 | user_id = access_token.extra.not_nil!["user_id"] 82 | user_email = access_token.extra.not_nil!["email"] 83 | 84 | fields = "about,photo_max_orig,city,country,domain,contacts,site" 85 | raw_json = api.get("/method/users.get?fields=#{fields}&user_id=#{user_id}&v=5.52").body 86 | 87 | vk_user = VkResponse.from_json(raw_json).response.first 88 | vk_user.email = user_email 89 | vk_user.access_token = access_token 90 | vk_user.raw_json = raw_json 91 | 92 | vk_user 93 | end 94 | 95 | private def client 96 | OAuth2::Client.new( 97 | "oauth.vk.com", 98 | key, 99 | secret, 100 | redirect_uri: redirect_uri, 101 | authorize_uri: "/authorize", 102 | token_uri: "/access_token", 103 | auth_scheme: :request_body 104 | ) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /src/multi_auth/user.cr: -------------------------------------------------------------------------------- 1 | class MultiAuth::User 2 | def initialize(@provider, @uid, @name, @raw_json, @access_token) 3 | end 4 | 5 | getter provider : String 6 | getter uid : String 7 | getter name : String? 8 | getter raw_json : String 9 | getter access_token : OAuth::AccessToken | OAuth2::AccessToken 10 | 11 | property email : String? 12 | property nickname : String? 13 | property first_name : String? 14 | property last_name : String? 15 | property location : String? 16 | property description : String? 17 | property image : String? 18 | property phone : String? 19 | property urls : Hash(String, String)? 20 | end 21 | -------------------------------------------------------------------------------- /src/multi_auth/version.cr: -------------------------------------------------------------------------------- 1 | module MultiAuth 2 | VERSION = "1.0.0" 3 | end 4 | --------------------------------------------------------------------------------