Add Sign in with Apple (#11934)

* Add gem omniauth-apple

* Integrate omniauth-apple

* Integrate callback

* Add fields

* Add tests, fix bugs and make it all work

* Show only enabled providers for the current user

* Add default profile image for Apple

* Remove localhost patch

* Bring over the changed Apple username if the user changes it

* More specs fixed

* Incorporate feedback from PR

* Fix specs

* Simplify code and fix spec

* Fix Broadcast generators to take into account the new provider

* Fix spec

* Generate a truly unique apple_username

* Fix user specs

* Add omniauth-apple-0.0.2 to vendor cache

* Fix merge conflict and spec

* Update VCR fastly sloan cassette

* Revert "Generate a truly unique apple_username"

This reverts commit 2462875575b0bbd6b3c1d56b25afcd3189671608.

* Fix user specs

* Fix specs

* Fix specs

* Hide Connect Apple button behind a feature flag

* Revert "Hide Connect Apple button behind a feature flag"

This reverts commit 105bde0373389a4eb9b6e948f60734c7e0e99cba.

* Fix line lengths

* Fix spec

* ES tag

* CSRF bypass for Apple callback

* custom user_nickname in Apple provider with small tweaks + omniauth-apple bump

* Fixes username specs

* Makes Apple users default image Users::ProfileImageGenerator

* Fallback to mascot_image_url in test environment to avoid breaking Travis

* Fixes Apple CSRF error + makes default nickname more readable

* Trigger Travis

* Better devise config

* Apple SiteConfig entires in /admin/config

* Fixing specs

* Adds beta_access? to Authentication::Providers::Provider

* Fixes specs

* Codeclimate double quote fix in Gemfile

* Fixes /admin/config allowed params & adds feature flag for provider beta_access?

* Remove Enfile & adds temporary docs

* Adds custom apple auth provider settings

* Fix authenticator spec

* Fix configs spec (use last instead of first to avoid apple special case)

* Remove dangling fields from /admin/config

* updates feature flag

* More test fixes

* Hide config behind feature flag too

* omniauth-apple bump

* Takes care of edge case fallback

* Reverse apple_username update

* Adds auth_time to info hash in apple omniauth mock

* Switch to next instead of nesting for feature flag

* Fixes CVE-2015-9284

* Fixes specs after auth providers initiatior refactor from GET to POST

* Spec fixes

* More spec fixes

* Fix Rails codebase reference link

Co-authored-by: rhymes <rhymesete@gmail.com>
Co-authored-by: rhymes <rhymes@hey.com>
This commit is contained in:
Fernando Valverde 2020-12-18 10:22:33 -06:00 committed by GitHub
parent f31470d8e5
commit fcc5c0d2a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1064 additions and 134 deletions

View file

@ -61,6 +61,7 @@ gem "nokogiri", "~> 1.10" # HTML, XML, SAX, and Reader parser
gem "octokit", "~> 4.19" # Simple wrapper for the GitHub API
gem "oj", "~> 3.10" # JSON parser and object serializer
gem "omniauth", "~> 1.9" # A generalized Rack framework for multiple-provider authentication
gem "omniauth-apple", "~> 1.0" # OmniAuth strategy for Sign In with Apple
gem "omniauth-facebook", "~> 8.0" # OmniAuth strategy for Facebook
gem "omniauth-github", "~> 1.3" # OmniAuth strategy for GitHub
gem "omniauth-twitter", "~> 1.4" # OmniAuth strategy for Twitter

View file

@ -475,6 +475,9 @@ GEM
omniauth (1.9.1)
hashie (>= 3.4.6)
rack (>= 1.6.2, < 3)
omniauth-apple (1.0.1)
jwt
omniauth-oauth2
omniauth-facebook (8.0.0)
omniauth-oauth2 (~> 1.2)
omniauth-github (1.4.0)
@ -892,6 +895,7 @@ DEPENDENCIES
octokit (~> 4.19)
oj (~> 3.10)
omniauth (~> 1.9)
omniauth-apple (~> 1.0)
omniauth-facebook (~> 8.0)
omniauth-github (~> 1.3)
omniauth-twitter (~> 1.4)

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1000"
viewBox="0 0 1000 1187.198"
version="1.1"
height="1187.198"
id="svg2"
inkscape:version="0.91 r13725"
sodipodi:docname="Apple_1998.svg">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1366"
inkscape:window-height="705"
id="namedview6"
showgrid="false"
inkscape:zoom="0.1767767"
inkscape:cx="-1066.5045"
inkscape:cy="964.94669"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<path
d="m 979.04184,925.18785 c -17.95397,41.47737 -39.20563,79.65705 -63.82824,114.75895 -33.56298,47.8528 -61.04356,80.9761 -82.22194,99.3698 -32.83013,30.192 -68.00529,45.6544 -105.67203,46.5338 -27.04089,0 -59.6512,-7.6946 -97.61105,-23.3035 -38.08442,-15.5358 -73.08371,-23.2303 -105.08578,-23.2303 -33.56296,0 -69.55888,7.6945 -108.06101,23.2303 -38.5608,15.6089 -69.62484,23.7432 -93.37541,24.5493 -36.12049,1.5389 -72.1237,-14.3632 -108.06101,-47.7796 -22.93711,-20.0059 -51.62684,-54.3017 -85.99592,-102.8874 C 92.254176,984.54592 61.937588,924.38175 38.187028,855.7902 12.750995,781.70252 0,709.95986 0,640.50361 0,560.94181 17.191859,492.32094 51.626869,434.81688 78.689754,388.62753 114.69299,352.19192 159.75381,325.44413 c 45.06086,-26.74775 93.74914,-40.37812 146.18212,-41.25019 28.68971,0 66.3125,8.8744 113.06613,26.31542 46.62174,17.49964 76.55727,26.37404 89.68198,26.37404 9.8124,0 43.06758,-10.37669 99.4431,-31.06405 53.31237,-19.18512 98.30724,-27.12887 135.16787,-23.99975 99.8828,8.06098 174.92313,47.43518 224.82789,118.37174 -89.33023,54.12578 -133.51903,129.93556 -132.63966,227.18753 0.8061,75.75115 28.28668,138.78795 82.2952,188.8393 24.47603,23.23022 51.81008,41.18421 82.22186,53.93522 -6.59525,19.12648 -13.557,37.44688 -20.95846,55.03446 z M 749.96366,23.751237 c 0,59.37343 -21.69138,114.810233 -64.92748,166.121963 -52.17652,60.99961 -115.28658,96.24803 -183.72426,90.68597 -0.87204,-7.12298 -1.37769,-14.61967 -1.37769,-22.49743 0,-56.99843 24.81315,-117.99801 68.87738,-167.873453 21.99909,-25.25281 49.978,-46.25018 83.90738,-63.00018 C 686.57507,10.688027 718.59913,1.5631274 748.71783,5.2734376e-4 749.59727,7.9378274 749.96366,15.875627 749.96366,23.750467 Z"
id="path4"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -113,6 +113,10 @@ module Admin
github_secret
facebook_key
facebook_secret
apple_client_id
apple_key_id
apple_pem
apple_team_id
auth_providers_to_enable
invite_only_mode
allow_email_password_registration

View file

@ -1,6 +1,15 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include Devise::Controllers::Rememberable
# Rails actionpack only allows POST requests that come with an ORIGIN header
# that matches `request.base_url`, it raises CSRF exception otherwise.
# There is no way to allow specific ORIGIN values in order to securely bypass
# trusted origins (i.e. Apple OAuth) so `protect_from_forgery` is skipped
# ONLY when it's safe to do so (i.e. ORIGIN == 'https://appleid.apple.com').
# The hardcoded CSRF check can be found in the method `valid_request_origin?`:
# https://github.com/rails/rails/blob/901f12212c488f6edfcf6f8ad3230bce6b3d5792/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L449-L459
protect_from_forgery unless: -> { safe_apple_callback_request? }
# Each available authentication method needs a related action that will be called
# as a callback on successful redirect from the upstream OAuth provider
Authentication::Providers.available.each do |provider_name|
@ -31,6 +40,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
super
end
def passthru
redirect_to root_path(signin: "true")
end
private
def callback_for(provider)
@ -96,4 +109,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def user_persisted_but_username_taken?
@user.persisted? && @user.errors_as_sentence.include?("username has already been taken")
end
# We only bypass CSRF checks on Apple callback path & Apple trusted ORIGIN
def safe_apple_callback_request?
trusted_origin = Authentication::Providers::Apple::TRUSTED_CALLBACK_ORIGIN
request.fullpath == Authentication::Providers::Apple::CALLBACK_PATH &&
request.headers["ORIGIN"] == trusted_origin
end
end

View file

@ -120,6 +120,26 @@ module Constants
"The \"App Secret\" portion of the Basic Settings section of the App page on the Facebook Developer Portal",
placeholder: ""
},
apple_client_id: {
description:
"The \"App Bundle\" code for the Authentication Service configured in the Apple Developer Portal",
placeholder: "com.example.app"
},
apple_team_id: {
description:
"The \"Team ID\" of your Apple Developer Account",
placeholder: ""
},
apple_key_id: {
description:
"The \"Key ID\" from the Authentication Service configured in the Apple Developer Portal",
placeholder: ""
},
apple_pem: {
description:
"The \"PEM\" key from the Authentication Service configured in the Apple Developer Portal",
placeholder: "-----BEGIN PRIVATE KEY-----\nMIGTAQrux...QPe8Yb\n-----END PRIVATE KEY-----\\n"
},
favicon_url: {
description: "Used as the site favicon",
placeholder: IMAGE_PLACEHOLDER

View file

@ -38,6 +38,10 @@ class SiteConfig < RailsSettings::Base
field :github_secret, type: :string, default: ApplicationConfig["GITHUB_SECRET"]
field :facebook_key, type: :string
field :facebook_secret, type: :string
field :apple_client_id, type: :string
field :apple_key_id, type: :string
field :apple_pem, type: :string
field :apple_team_id, type: :string
# Campaign
field :campaign_call_to_action, type: :string, default: "Share your project"
@ -209,4 +213,15 @@ class SiteConfig < RailsSettings::Base
def self.dev_to?
app_domain == "dev.to"
end
# Apple uses different keys than the usual `PROVIDER_NAME_key` or
# `PROVIDER_NAME_secret` so these will help the generalized authentication
# code to work, i.e. https://github.com/forem/forem/blob/master/app/helpers/authentication_helper.rb#L26-L29
def self.apple_key
return unless apple_client_id.present? && apple_key_id.present? &&
apple_pem.present? && apple_team_id.present?
"present"
end
singleton_class.__send__(:alias_method, :apple_secret, :apple_key)
end

View file

@ -0,0 +1,88 @@
module Authentication
module Providers
# Apple authentication provider, uses omniauth-apple as backend
class Apple < Provider
OFFICIAL_NAME = "Apple".freeze
SETTINGS_URL = "https://appleid.apple.com/account/manage".freeze
TRUSTED_CALLBACK_ORIGIN = "https://appleid.apple.com".freeze
CALLBACK_PATH = "/users/auth/apple/callback".freeze
def new_user_data
# Apple sends `first_name` and `last_name` as separate fields
name = "#{info.first_name} #{info.last_name}"
timestamp = raw_info.id_info.auth_time
user_data = {
email: info.email,
apple_created_at: Time.zone.at(timestamp),
apple_username: user_nickname,
name: name
}
if Rails.env.test?
user_data[:profile_image] = SiteConfig.mascot_image_url
else
user_data[:remote_profile_image_url] = Users::ProfileImageGenerator.call
end
user_data
end
def existing_user_data
# Apple by default will send nil `first_name` and `last_name` after
# the first login. To cover the case where a user disconnects their
# Apple authorization, signs in again and then changes their name,
# we update the username only if the name is not nil
apple_username = info.first_name.present? ? info.first_name.downcase : nil
timestamp = raw_info.id_info.auth_time
data = { apple_created_at: Time.zone.at(timestamp) }
data[:apple_username] = apple_username if apple_username
data
end
# For Apple we override this method because the `info` payload doesn't
# include `nickname`. On top of not having a username, Apple allows users
# to 'choose' the first_name & last_name sent our way so they are
# definitely not assured to be unique. We still need `user_nickname` to
# always be the same on each login so we use the email hash as suffix to
# avoid collisions with other registrations with the same first_name
def user_nickname
if info.first_name.present? || info.last_name.present?
# We sometimes get `info.first_name` and `info.last_name`
[
info.first_name&.downcase,
info.last_name&.downcase,
Digest::SHA512.hexdigest(info.email),
].join("_")[0...25]
else
# This covers an edge case where the Apple Id has already given
# permissions to the forem auth and we don't have anything else
# to work with other than the email
["user", Digest::SHA512.hexdigest(info.email)].join("_")[0...15]
end
end
def self.official_name
OFFICIAL_NAME
end
def self.settings_url
SETTINGS_URL
end
def self.sign_in_path(**kwargs)
::Authentication::Paths.sign_in_path(
provider_name,
**kwargs,
)
end
protected
def cleanup_payload(auth_payload)
auth_payload
end
end
end
end

View file

@ -0,0 +1,77 @@
<!--
Apple's config is different compared to the common OAuth provider implemented
before, this makes the generalized `auth_provider_settings` not work well so it
requires a custom partial
-->
<fieldset class="config-authentication-form">
<div
class="crayons-notice crayons-notice--warning auth-warning <%= provider_keys_configured?(provider.provider_name) ? "hidden" : "" %>">
<strong>Note:</strong> This authentication provider will <strong>not</strong> be enabled if the key or secret are missing. Please make sure that you enter your key and secret correctly.
</div>
<div class="crayons-field">
<%= admin_config_label :apple_client_id %>
<p class="crayons-field__description">
<%= Constants::SiteConfig::DETAILS[:apple_client_id][:description] %>
</p>
<%= f.text_field :apple_client_id,
class: "crayons-textfield",
value: SiteConfig.apple_client_id,
placeholder: Constants::SiteConfig::DETAILS[:apple_client_id][:placeholder] %>
</div>
<div class="crayons-field">
<%= admin_config_label :apple_key_id %>
<p class="crayons-field__description">
<%= Constants::SiteConfig::DETAILS[:apple_key_id][:description] %>
<p>
<%= f.text_field :apple_key_id,
class: "crayons-textfield",
value: SiteConfig.apple_key_id,
placeholder: Constants::SiteConfig::DETAILS[:apple_key_id][:placeholder] %>
</div>
<div class="crayons-field">
<%= admin_config_label :apple_pem %>
<p class="crayons-field__description">
<%= Constants::SiteConfig::DETAILS[:apple_pem][:description] %>
<p>
<%= f.text_field :apple_pem,
class: "crayons-textfield",
value: SiteConfig.apple_pem,
placeholder: Constants::SiteConfig::DETAILS[:apple_pem][:placeholder] %>
</div>
<div class="crayons-field">
<%= admin_config_label :apple_team_id %>
<p class="crayons-field__description">
<%= Constants::SiteConfig::DETAILS[:apple_team_id][:description] %>
<p>
<%= f.text_field :apple_team_id,
class: "crayons-textfield",
value: SiteConfig.apple_team_id,
placeholder: Constants::SiteConfig::DETAILS[:apple_team_id][:placeholder] %>
</div>
<div class="flex gap-2">
<% if authentication_enabled_providers.include?(provider) %>
<button
class="crayons-btn crayons-btn--danger"
data-action="click->config#activateAuthProviderModal"
data-tooltip="<%= tooltip_text_email_or_auth_provider_btns %>"
data-auth-provider="<%= provider.provider_name %>"
data-auth-provider-official="<%= provider.official_name %>"
<%= disabled_attr_on_auth_provider_enablebtn %>>
Disable
</button>
<button
class="crayons-btn crayons-btn--secondary"
data-auth-provider="<%= provider.provider_name %>"
data-action="click->config#hideAuthProviderSettings">
Close
</button>
<% else %>
<button
class="crayons-btn crayons-btn--secondary"
data-auth-provider="<%= provider.provider_name %>"
data-action="click->config#disableAuthProvider">
Undo
</button>
<% end %>
</div>
</fieldset>

View file

@ -282,6 +282,7 @@
readonly: true %>
</div>
<% authentication_available_providers.each do |provider| %>
<% next if provider.provider_name == :apple && !Flipper.enabled?(:apple_auth) %>
<section class="config-authentication__item mb-0">
<figure class="config-authentication__item--icon">
<%= inline_svg_tag("#{provider.provider_name}.svg", class: "crayons-icon", aria: true, title: "#{provider.official_name} logo") %>
@ -313,7 +314,8 @@
</button>
</div>
<div id="<%= provider.provider_name %>-auth-settings" class="config-authentication__item--body hidden">
<%= render partial: "auth_provider_settings",
<% auth_partial = provider.provider_name == :apple ? "apple_auth_provider_settings" : "auth_provider_settings" %>
<%= render partial: auth_partial,
locals: {
f: f,
provider: provider

View file

@ -1,9 +0,0 @@
<% authentication_enabled_providers.each do |provider| %>
<a
href="<%= provider.sign_in_path(state: "navbar_basic") %>"
class="crayons-btn crayons-btn--brand-<%= provider.provider_name %> crayons-btn--icon-left"
data-no-instant>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
Sign In with <%= provider.official_name %>
</a>
<% end %>

View file

@ -1,12 +1,12 @@
<div class="registration__actions-providers">
<% authentication_enabled_providers.each do |provider| %>
<a
href="<%= provider.sign_in_path(state: "navbar_basic") %>"
class="crayons-btn crayons-btn--l crayons-btn--brand-<%= provider.provider_name %> crayons-btn--icon-left whitespace-nowrap"
data-no-instant>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
<%= params[:state] == "new-user" ? "Sign up" : "Continue" %> with <%= provider.official_name %>
</a>
<% next if provider.provider_name == :apple && !Flipper.enabled?(:apple_auth) %>
<%= form_with url: provider.sign_in_path(state: "navbar_basic"), class: "flex w-100", local: true do |f| %>
<%= f.button type: :submit, class: "crayons-btn crayons-btn--l crayons-btn--brand-#{provider.provider_name} crayons-btn--icon-left grow-1 whitespace-nowrap" do %>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
<%= params[:state] == "new-user" ? "Sign up" : "Continue" %> with <%= provider.official_name %>
<% end %>
<% end %>
<% end %>
<% if params[:state] == "new-user" && SiteConfig.allow_email_password_registration && !SiteConfig.invite_only_mode %>
<%= link_to "#{inline_svg_tag('email.svg', aria: true, class: 'crayons-icon', title: 'email')}Sign up with Email".html_safe,

View file

@ -1,14 +0,0 @@
<% authentication_enabled_providers.each do |provider| %>
<a
href="<%= provider.sign_in_path(state: "navbar_basic") %>"
class="crayons-btn crayons-btn--l crayons-btn--brand-<%= provider.provider_name %> crayons-btn--icon-left"
data-no-instant>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
Sign In with <%= provider.official_name %>
</a>
<% end %>
<a
href="<%= sign_up_path %>"
class="crayons-btn crayons-btn--secondary crayons-btn--l">
More Sign In Options
</a>

View file

@ -1,12 +0,0 @@
<% authentication_enabled_providers.each do |provider| %>
<a
href="<%= provider.sign_in_path(state: "signup-modal") %>"
class="crayons-btn crayons-btn--xl crayons-btn--brand-<%= provider.provider_name %> crayons-btn--icon-left m-1"
data-no-instant>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
Sign In with <%= provider.official_name %>
</a>
<% end %>
<a href="<%= sign_up_path %>"
class="crayons-btn crayons-btn--secondary crayons-btn--xl m-1"
data-no-instant>More Sign In Options</a>

View file

@ -1,19 +1,14 @@
<% unless @user.authenticated_with_all_providers? %>
<div class="crayons-card crayons-card--content-rows block">
<% authentication_enabled_providers.each do |provider| %>
<% next if provider.provider_name == :apple && !Flipper.enabled?(:apple_auth) %>
<% unless @user.authenticated_through?(provider.provider_name) %>
<a
href="<%= provider.sign_in_path(state: "profile") %>"
class="crayons-btn crayons-btn--icon-left crayons-btn--brand-<%= provider.provider_name %> m-1"
data-no-instant>
<%= inline_svg_tag(
"#{provider.provider_name}.svg",
aria: true,
class: "crayons-icon",
title: provider.official_name,
) %>
Connect <%= provider.official_name %> Account
</a>
<%= form_with url: provider.sign_in_path(state: "profile"), class: "flex w-100", local: true do |f| %>
<%= f.button type: :submit, class: "crayons-btn crayons-btn--icon-left crayons-btn--brand-#{provider.provider_name} m-1" do %>
<%= inline_svg_tag("#{provider.provider_name}.svg", aria: true, class: "crayons-icon", title: provider.provider_name) %>
Connect <%= provider.official_name %> Account
<% end %>
<% end %>
<% end %>
<% end %>
</div>

View file

@ -15,6 +15,15 @@ FACEBOOK_OMNIAUTH_SETUP = lambda do |env|
env["omniauth.strategy"].options[:token_params][:parse] = :json
end
APPLE_OMNIAUTH_SETUP = lambda do |env|
env["omniauth.strategy"].options[:client_id] = SiteConfig.apple_client_id
env["omniauth.strategy"].options[:scope] = "email name"
env["omniauth.strategy"].options[:key_id] = SiteConfig.apple_key_id
env["omniauth.strategy"].options[:pem] = SiteConfig.apple_pem
env["omniauth.strategy"].options[:provider_ignores_state] = true
env["omniauth.strategy"].options[:team_id] = SiteConfig.apple_team_id
end
Devise.setup do |config|
# The secret key used by Devise. Devise uses this key to generate
# random tokens. Changing this key will render invalid all existing
@ -304,6 +313,7 @@ Devise.setup do |config|
config.omniauth :facebook, setup: FACEBOOK_OMNIAUTH_SETUP
config.omniauth :github, setup: GITHUB_OMNIUATH_SETUP
config.omniauth :twitter, setup: TWITTER_OMNIAUTH_SETUP
config.omniauth :apple, setup: APPLE_OMNIAUTH_SETUP
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or

View file

@ -1,2 +1,7 @@
OmniAuth.config.logger = Rails.logger
OmniAuth.config.full_host = proc { URL.url }
# We can't use the 'omniauth-rails_csrf_protection' because of the custom way we
# handle CSRF Tokens in `config/initializers/persistent_csrf_token_cookie.rb`.
# Therefore we're manually taking the necessary steps to prevent CVE-2015-9284
# https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284
OmniAuth.config.allowed_request_methods = [:post]

View file

@ -0,0 +1,6 @@
class AddAppleAuthenticationFields < ActiveRecord::Migration[5.2]
def change
add_column :users, :apple_created_at, :datetime
add_column :users, :apple_username, :string
end
end

View file

@ -0,0 +1,7 @@
class AddIndexToUsersAppleUsername < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def change
add_index :users, :apple_username, algorithm: :concurrently
end
end

View file

@ -1204,6 +1204,8 @@ ActiveRecord::Schema.define(version: 2020_12_03_063435) do
end
create_table "users", force: :cascade do |t|
t.datetime "apple_created_at"
t.string "apple_username"
t.integer "articles_count", default: 0, null: false
t.string "available_for"
t.integer "badge_achievements_count", default: 0, null: false
@ -1344,6 +1346,7 @@ ActiveRecord::Schema.define(version: 2020_12_03_063435) do
t.boolean "welcome_notifications", default: true, null: false
t.datetime "workshop_expiration"
t.string "youtube_url"
t.index ["apple_username"], name: "index_users_on_apple_username"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_at"], name: "index_users_on_created_at"
t.index ["email"], name: "index_users_on_email", unique: true

View file

@ -0,0 +1,83 @@
---
title: Apple Authentication (beta)
---
# Sign in with Apple Authentication
Forem allows you to authenticate using
[Sign in with Apple](https://developer.apple.com/sign-in-with-apple/). In order
to use this authentication method you'll need to be enrolled to the
[Apple Developer Program](https://developer.apple.com/programs/) in order to
retrieve the necessary credentials and an HTTPS supported URL for the callback
configuration (HTTP won't work). Then you'll need to provide the keys to the
Rails application.
#### Beta support
This authentication provider is currently marked as beta. This means it will be
available but hidden from public access until more thoroughly tested.
If you want to make this feature publicly available (without the state
parameter) you can enable the `apple_auth` feature flag from the Flipper
dashboard or the Rails console with `Flipper.enable(:apple_auth)`.
# Apple Developer Portal Configuration
[Register/Sign in](https://developer.apple.com/account) to your Apple Developer
Account.
## Service ID Configuration
1. [Create a Service ID](https://developer.apple.com/account/resources/identifiers/list/serviceId)
![Create Service ID](https://user-images.githubusercontent.com/6045239/92610177-a5cc9e00-f274-11ea-9f63-20d8356d0bee.png)
2. Name the Service and finalize the registration
![Naming Service ID](https://user-images.githubusercontent.com/6045239/92610168-a36a4400-f274-11ea-8f79-7516c0c6c9c3.png)
3. Configure Domains and Subdomains & the callback URL. This example uses
[ngrok](https://ngrok.io) for HTTPS support.
![Callback URLS](https://user-images.githubusercontent.com/6045239/92610184-a8c78e80-f274-11ea-9439-a98c6b627567.png)
## Key Configuration
1. [Register a new Key](https://developer.apple.com/account/resources/authkeys/add).
Enable the "Sign in with Apple" option and configure it so it's associated
with the corresponding App ID
![Register a new Key](https://user-images.githubusercontent.com/6045239/92611125-b3ceee80-f275-11ea-9c00-e1b5ca2f9af0.png)
2. Download the Key
![Download the Key](https://user-images.githubusercontent.com/6045239/92611466-0f00e100-f276-11ea-912d-f8a74b6dfb04.png)
# Configuring the Rails Application
Now with both the Service ID and Key you'll need to enable Apple Authentication
and pass in the credentials in the admin dashboard `/admin/config`.
![Admin Authentication Configuration Dashboard](https://user-images.githubusercontent.com/6045239/92613383-25a83780-f278-11ea-94a7-b710da544c9d.png)
Add the corresponding configuration data. Make sure the PEM key you downloaded
has explicit linebreaks (`\n`), don't forget the one at the very end of it.
![Apple config](https://user-images.githubusercontent.com/6045239/92614087-e0d0d080-f278-11ea-8d20-45148e1a6b59.png)
Save the changes and restart your server for these values to take effect.
## Email configuration
Apple uses what they call Private Email Relay Service to hide user's emails. For
this to work first
[create a new email source](https://developer.apple.com/account/resources/services/list).
![Email configuration](https://user-images.githubusercontent.com/6045239/92612469-22607c00-f277-11ea-918d-697cf4a18b15.png)
Emails sent need to be authenticated and the configuration depends on the
different providers available:
- [Mailchimp](https://mailchimp.com/help/set-up-custom-domain-authentication-dkim-and-spf/)
- [SendGrid](https://sendgrid.com/docs/ui/account-and-settings/how-to-set-up-domain-authentication/)
- [SES](https://docs.aws.amazon.com/es_es/ses/latest/DeveloperGuide/send-email-authentication-dkim.html)

View file

@ -10,6 +10,7 @@ and [OmniAuth](https://github.com/omniauth/omniauth).
On Forem you can authenticate through Facebook, GitHub, or Twitter. Please check out
the respective guides on how to authenticate:
- [Apple authentication](/backend/auth-apple)
- [Facebook authentication](/backend/auth-facebook)
- [Github authentication](/backend/auth-github)
- [Twitter authentication](/backend/auth-twitter)

View file

@ -3,6 +3,7 @@ title: Backend Guide
items:
- audit-log.md
- authentication.md
- auth-apple.md
- auth-facebook.md
- auth-twitter.md
- auth-github.md

View file

@ -22,13 +22,11 @@ FactoryBot.define do
end
end
factory :twitter_connect_broadcast do
title { "Welcome Notification: twitter_connect" }
factory :apple_connect_broadcast do
title { "Welcome Notification: apple_connect" }
type_of { "Welcome" }
processed_html do
"You're on a roll! 🎉 Do you have a Twitter account? Consider " \
"<a href='/settings'>connecting it</a> so we can @mention you if we share " \
"your post via our Twitter account <a href='https://twitter.com/thePracticalDev'>@thePracticalDev</a>."
"You're on a roll! 🎉 Do you have an Apple account? Consider <a href='/settings'>connecting it</a>."
end
end
@ -50,6 +48,16 @@ FactoryBot.define do
end
end
factory :twitter_connect_broadcast do
title { "Welcome Notification: twitter_connect" }
type_of { "Welcome" }
processed_html do
"You're on a roll! 🎉 Do you have a Twitter account? Consider " \
"<a href='/settings'>connecting it</a> so we can @mention you if we share " \
"your post via our Twitter account <a href='https://twitter.com/thePracticalDev'>@thePracticalDev</a>."
end
end
factory :customize_ux_broadcast do
title { "Welcome Notification: customize_experience" }
type_of { "Welcome" }

View file

@ -28,6 +28,38 @@ RSpec.describe Identity, type: :model do
omniauth_mock_providers_payload
end
context "with Apple payload" do
let(:auth_payload) { OmniAuth.config.mock_auth[:apple] }
let(:provider) { Authentication::Providers::Apple.new(auth_payload) }
it "initializes a new identity from the auth payload" do
identity = described_class.build_from_omniauth(provider)
expect(identity.new_record?).to be(true)
expect(identity.provider).to eq("apple")
expect(identity.uid).to eq(auth_payload.uid)
expect(identity.token).to eq(auth_payload.credentials.token)
expect(identity.secret).to be_nil
expect(identity.auth_data_dump).to eq(provider.payload)
end
it "finds an existing identity" do
payload = provider.payload
existing_identity = described_class.create!(
user: user,
provider: payload.provider,
uid: payload.uid,
token: payload.credentials.token,
secret: nil,
auth_data_dump: payload,
)
identity = described_class.build_from_omniauth(provider)
expect(identity).to eq(existing_identity)
end
end
context "with Github payload" do
let(:auth_payload) { OmniAuth.config.mock_auth[:github] }
let(:provider) { Authentication::Providers::Github.new(auth_payload) }

View file

@ -74,7 +74,7 @@ RSpec.describe "/admin/config", type: :request do
describe "Authentication" do
it "updates enabled authentication providers" do
enabled = Authentication::Providers.available.first.to_s
enabled = Authentication::Providers.available.last.to_s
post admin_config_path, params: {
site_config: {
"#{enabled}_key": "someKey",
@ -87,7 +87,7 @@ RSpec.describe "/admin/config", type: :request do
end
it "strips empty elements" do
provider = Authentication::Providers.available.first.to_s
provider = Authentication::Providers.available.last.to_s
enabled = "#{provider}, '', nil"
post admin_config_path, params: {
site_config: {

View file

@ -10,6 +10,7 @@ RSpec.describe "Registrations", type: :request do
Authentication::Providers.enabled.each do |provider_name|
provider = Authentication::Providers.get!(provider_name)
next if provider.provider_name == :apple && !Flipper.enabled?(:apple_auth)
expect(response.body).to include("Continue with #{provider.official_name}")
end

View file

@ -15,6 +15,204 @@ RSpec.describe Authentication::Authenticator, type: :service do
end
end
context "when authenticating through Apple", vcr: { cassette_name: "fastly_sloan" } do
let!(:auth_payload) { OmniAuth.config.mock_auth[:apple] }
let!(:service) { described_class.new(auth_payload) }
describe "new user" do
it "creates a new user" do
expect do
service.call
end.to change(User, :count).by(1)
end
it "creates a new identity" do
expect do
service.call
end.to change(Identity, :count).by(1)
end
it "extracts the proper data from the auth payload" do
user = service.call
info = auth_payload.info
raw_info = auth_payload.extra.raw_info
expect(user.email).to eq(info.email)
expect(user.name).to eq("#{info.first_name} #{info.last_name}")
expect(user.profile_image).not_to be_nil
expect(user.apple_created_at.to_i).to eq(raw_info.id_info.auth_time)
expect(user.apple_username).to match(/#{info.first_name.downcase}_\w+/)
end
it "sets default fields" do
user = service.call
expect(user.password).to be_present
expect(user.signup_cta_variant).to be_nil
expect(user.saw_onboarding).to be(false)
expect(user.editor_version).to eq("v2")
end
it "sets the correct sign up cta variant" do
user = described_class.call(auth_payload, cta_variant: "awesome")
expect(user.signup_cta_variant).to eq("awesome")
end
it "sets remember_me for the new user" do
user = service.call
expect(user.remember_me).to be(true)
expect(user.remember_token).to be_present
expect(user.remember_created_at).to be_present
end
it "sets confirmed_at" do
user = service.call
expect(user.confirmed_at).to be_present
end
it "queues a slack message to be sent for a user whose identity is brand new" do
auth_payload.extra.raw_info.id_info.auth_time = 1.minute.ago.to_i
sidekiq_assert_enqueued_with(job: Slack::Messengers::Worker) do
described_class.call(auth_payload)
end
end
it "records successful identity creation metric" do
allow(DatadogStatsClient).to receive(:increment)
service.call
expect(DatadogStatsClient).to have_received(:increment).with(
"identity.created", tags: ["provider:apple"]
)
end
end
describe "existing user" do
let(:user) { create(:user, :with_identity, identities: [:apple]) }
before do
auth_payload.info.email = user.email
end
it "doesn't create a new user" do
expect do
service.call
end.not_to change(User, :count)
end
it "creates a new identity if the user doesn't have one" do
user = create(:user)
auth_payload.info.email = user.email
auth_payload.uid = "#{user.email}-#{rand(10_000)}"
expect do
described_class.call(auth_payload)
end.to change(Identity, :count).by(1)
end
it "does not create a new identity if the user has one" do
expect do
service.call
end.not_to change(Identity, :count)
end
it "does not record an identity creation metric" do
allow(DatadogStatsClient).to receive(:increment)
service.call
expect(DatadogStatsClient).not_to have_received(:increment)
end
it "updates the proper data from the auth payload" do
# simulate changing apple data
auth_payload.extra.raw_info.id_info.auth_time = 10.days.ago.to_i
user = described_class.call(auth_payload)
raw_info = auth_payload.extra.raw_info
expect(user.apple_created_at.to_i).to eq(raw_info.id_info.auth_time)
end
it "sets remember_me for the existing user" do
user.update_columns(remember_token: nil, remember_created_at: nil)
service.call
user.reload
expect(user.remember_me).to be(true)
expect(user.remember_token).to be_present
expect(user.remember_created_at).to be_present
end
it "updates confirmed_at with the current UTC time" do
original_confirmed_at = user.confirmed_at
Timecop.travel(1.minute.from_now) do
service.call
end
user.reload
expect(
user.confirmed_at.utc.to_i > original_confirmed_at.utc.to_i,
).to be(true)
end
it "updates the username when it is changed on the provider" do
new_username = "new_username#{rand(1000)}"
auth_payload.info.first_name = new_username
user = described_class.call(auth_payload)
expect(user.apple_username).to eq(new_username)
end
it "does not update the username when the first_name is nil" do
previos_username = user.apple_username
auth_payload.info.first_name = nil
user = described_class.call(auth_payload)
expect(user.apple_username).to eq(previos_username)
end
it "updates profile_updated_at when the username is changed" do
original_profile_updated_at = user.profile_updated_at
new_username = "new_username#{rand(1000)}"
auth_payload.info.first_name = new_username
Timecop.travel(1.minute.from_now) do
described_class.call(auth_payload)
end
user.reload
expect(
user.profile_updated_at.to_i > original_profile_updated_at.to_i,
).to be(true)
end
end
describe "user already logged in" do
it "returns the current user if the identity exists" do
user = create(:user, :with_identity, identities: [:apple])
expect(described_class.call(auth_payload, current_user: user)).to eq(user)
end
it "creates the identity if for any reason it does not exist" do
user = create(:user)
expect do
described_class.call(auth_payload, current_user: user)
end.to change(Identity, :count).by(1)
end
end
end
context "when authenticating through Github" do
let!(:auth_payload) { OmniAuth.config.mock_auth[:github] }
let!(:service) { described_class.new(auth_payload) }

View file

@ -28,7 +28,7 @@ RSpec.describe Authentication::Providers, type: :service do
describe ".available" do
it "lists the available providers" do
available_providers = %i[facebook github twitter]
available_providers = %i[apple facebook github twitter]
expect(described_class.available).to eq(available_providers)
end
end

View file

@ -9,6 +9,7 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
let!(:twitter_connect_broadcast) { create(:twitter_connect_broadcast) }
let!(:github_connect_broadcast) { create(:github_connect_broadcast) }
let!(:facebook_connect_broadcast) { create(:facebook_connect_broadcast) }
let!(:apple_connect_broadcast) { create(:apple_connect_broadcast) }
let!(:customize_feed_broadcast) { create(:customize_feed_broadcast) }
let!(:discuss_and_ask_broadcast) { create(:discuss_and_ask_broadcast) }
let!(:customize_ux_broadcast) { create(:customize_ux_broadcast) }
@ -23,10 +24,10 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end
it "requires a valid user id" do
expect { described_class.call(User.last.id + 100) }.to raise_error(ActiveRecord::RecordNotFound)
expect { described_class.call(9999) }.to raise_error(ActiveRecord::RecordNotFound)
end
describe "::call" do
describe ".call" do
let(:user) { create(:user, :with_identity, identities: ["github"], created_at: 1.week.ago) }
it "does not send a notification to an unsubscribed user" do
@ -55,16 +56,20 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end.to change(user.notifications, :count).by(1)
expect(user.notifications.last.notifiable).to eq(welcome_broadcast)
Timecop.travel(1.day.since)
Timecop.travel(1.day.from_now)
expect do
sidekiq_perform_enqueued_jobs do
described_class.call(user.id)
end
end.to change(user.notifications, :count).by(1)
not_github = [facebook_connect_broadcast, twitter_connect_broadcast].include?(user.notifications.last.notifiable)
not_github = [
facebook_connect_broadcast,
twitter_connect_broadcast,
apple_connect_broadcast
].include?(user.notifications.last.notifiable)
expect(not_github).to be(true)
Timecop.travel(1.day.since)
Timecop.travel(1.day.from_now)
expect do
sidekiq_perform_enqueued_jobs do
described_class.call(user.id)
@ -72,7 +77,7 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end.to change(user.notifications, :count).by(1)
expect(user.notifications.last.notifiable).to eq(customize_feed_broadcast)
Timecop.travel(2.days.since)
Timecop.travel(2.days.from_now)
expect do
sidekiq_perform_enqueued_jobs do
described_class.call(user.id)
@ -80,7 +85,7 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end.to change(user.notifications, :count).by(1)
expect(user.notifications.last.notifiable).to eq(customize_ux_broadcast)
Timecop.travel(1.day.since)
Timecop.travel(1.day.from_now)
expect do
sidekiq_perform_enqueued_jobs do
described_class.call(user.id)
@ -88,13 +93,14 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end.to change(user.notifications, :count).by(1)
expect(user.notifications.last.notifiable).to eq(discuss_and_ask_broadcast)
Timecop.travel(1.day.since)
Timecop.travel(1.day.from_now)
expect do
sidekiq_perform_enqueued_jobs do
described_class.call(user.id)
end
end.to change(user.notifications, :count).by(1)
expect(user.notifications.last.notifiable).to eq(download_app_broadcast)
Timecop.return
end
# rubocop:enable RSpec/MultipleExpectations
@ -147,20 +153,25 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
expect(Notification).not_to have_received(:send_welcome_notification).with(user.id, github_connect_broadcast.id)
end
it "does not send duplicate notifications (twitter)" do
user = create(:user, :with_identity, identities: ["github"], created_at: 1.day.ago)
2.times do
sidekiq_perform_enqueued_jobs { described_class.new(user.id).__send__(:send_authentication_notification) }
Authentication::Providers.available.each do |provider_name|
it "does not send duplicate notifications for #{provider_name}" do
user = create(:user, :with_identity, identities: [provider_name], created_at: 1.day.ago)
2.times do
sidekiq_perform_enqueued_jobs { described_class.new(user.id).__send__(:send_authentication_notification) }
end
expect(user.notifications.count).to eq(1)
end
it "generates and sends the appropriate broadcast for a #{provider_name} identity" do
user = create(:user, :with_identity, identities: [provider_name], created_at: 1.day.ago)
sidekiq_perform_enqueued_jobs { described_class.new(user.id).__send__(:send_authentication_notification) }
expect(user.notifications.first.notifiable).not_to eq(public_send("#{provider_name}_connect_broadcast"))
end
expect(user.notifications.count).to eq(1)
end
end
describe "#send_feed_customization_notification" do
let!(:user) do
omniauth_mock_providers_payload
create(:user, :with_identity, identities: %w[twitter github], created_at: 3.days.ago)
end
let!(:user) { create(:user, :with_identity, created_at: 3.days.ago) }
it "does not send a notification to a newly-created user" do
user.update!(created_at: Time.current)
@ -189,7 +200,7 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end
describe "#send_ux_customization_notification" do
let!(:user) { create(:user, :with_identity, identities: %w[twitter github], created_at: 5.days.ago) }
let!(:user) { create(:user, :with_identity, created_at: 5.days.ago) }
it "does not send a notification to a newly-created user" do
user.update!(created_at: Time.zone.now)
@ -212,7 +223,7 @@ RSpec.describe Broadcasts::WelcomeNotification::Generator, type: :service do
end
describe "#send_discuss_and_ask_notification" do
let!(:user) { create(:user, :with_identity, identities: %w[twitter github], created_at: 6.days.ago) }
let!(:user) { create(:user, :with_identity, created_at: 6.days.ago) }
let!(:ask_question_broadcast) { create(:ask_question_broadcast) }
let!(:start_discussion_broadcast) { create(:start_discussion_broadcast) }

File diff suppressed because one or more lines are too long

View file

@ -67,6 +67,46 @@ module OmniauthHelpers
},
).freeze
OMNIAUTH_PAYLOAD_APPLE = OmniAuth::AuthHash::InfoHash.new(
{
provider: "apple",
uid: SecureRandom.hex,
info: {
sub: "001551.6fb185533f63469196d61e2ae126febf.1523",
email: "hynnu385xiabcdef@privaterelay.appleid.com",
first_name: "fname",
last_name: "lname"
},
credentials: {
token: SecureRandom.hex,
refresh_token: SecureRandom.hex,
expires_at: 1_589_475_606,
expires: true
},
extra: {
raw_info: {
iss: "https://appleid.apple.com",
aud: "test",
exp: 1_589_472_606,
iat: 1_589_472_606,
sub: "001551.6fb185533f63469196d61e2ae126febf.1523",
at_hash: SecureRandom.hex,
email: "hynnu385xiabcdef@privaterelay.appleid.com",
email_verified: true,
is_private_email: true,
nonce_supported: true,
name: {
first_name: "fname",
last_name: "lname"
},
id_info: {
auth_time: 1_589_472_002
}
}
}
},
).freeze
def omniauth_setup_invalid_credentials(provider)
OmniAuth.config.mock_auth[provider] = :invalid_credentials
end
@ -122,6 +162,10 @@ module OmniauthHelpers
OmniAuth.config.mock_auth[:facebook] = OMNIAUTH_PAYLOAD_FACEBOOK.dup
end
def omniauth_mock_apple_payload
OmniAuth.config.mock_auth[:apple] = OMNIAUTH_PAYLOAD_APPLE.dup
end
def omniauth_mock_github_payload
info = OMNIAUTH_BASIC_INFO[:info].merge(
image: "https://dummyimage.com/400x400.jpg",

View file

@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe "Beta provider access", type: :system do
let(:providers) { %w[Facebook GitHub Twitter] }
let(:beta_providers) { ["Apple"] }
before do
allow(SiteConfig).to receive(:authentication_providers).and_return(Authentication::Providers.available)
end
context "when a user tries to sign_up" do
it "doesn't render beta providers by default" do
visit sign_up_path
providers.each do |name|
expect(page).to have_button("Continue with #{name}")
end
beta_providers.each do |name|
expect(page).not_to have_button("Continue with #{name}")
end
end
it "renders beta providers when passed in the correct state param" do
Flipper.enable(:apple_auth)
visit sign_up_path
providers.each do |name|
expect(page).to have_button("Continue with #{name}")
end
beta_providers.each do |name|
expect(page).to have_button("Continue with #{name}")
end
end
end
end

View file

@ -6,6 +6,9 @@ RSpec.describe "Omniauth redirect_uri", type: :system do
# Avoid messing with other tests by resetting back SiteConfig.app_domain
after { SiteConfig.app_domain = test_app_domain }
# Apple auth is in Beta so we need to enable the Feature Flag to test it
before { Flipper.enable(:apple_auth) }
def provider_redirect_regex(provider_name)
# URL encoding translates the query params (i.e. colons/slashes/etc)
%r{
@ -20,7 +23,8 @@ RSpec.describe "Omniauth redirect_uri", type: :system do
allow(SiteConfig).to receive(:authentication_providers).and_return(Authentication::Providers.available)
visit sign_up_path
Authentication::Providers.available.each do |provider_name|
provider_auth_url = find("a.crayons-btn--brand-#{provider_name}")["href"]
provider_auth_button = find("button.crayons-btn--brand-#{provider_name}")
provider_auth_url = provider_auth_button.find(:xpath, "..")["action"]
expect(provider_auth_url).to match(provider_redirect_regex(provider_name))
end
@ -29,7 +33,8 @@ RSpec.describe "Omniauth redirect_uri", type: :system do
# After an update the callback_url should now match SiteConfig.app_domain
Authentication::Providers.available.each do |provider_name|
provider_auth_url = find("a.crayons-btn--brand-#{provider_name}")["href"]
provider_auth_button = find("button.crayons-btn--brand-#{provider_name}")
provider_auth_url = provider_auth_button.find(:xpath, "..")["action"]
expect(provider_auth_url).to match(provider_redirect_regex(provider_name))
end
end

View file

@ -23,7 +23,7 @@ RSpec.describe "Redirects authentication using Referer", type: :system do
it "redirects back to the main feed (root path)" do
visit root_path
click_link(login_link, match: :first)
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/", ignore_query: true)
end
@ -31,7 +31,7 @@ RSpec.describe "Redirects authentication using Referer", type: :system do
it "redirects back to an article page" do
visit "/#{article.slug}"
click_link(login_link, match: :first)
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/#{article.slug}", ignore_query: true)
end
@ -41,7 +41,7 @@ RSpec.describe "Redirects authentication using Referer", type: :system do
it "redirects back to the main feed as default" do
Capybara.current_session.driver.header "Referer", ""
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/", ignore_query: true)
end
@ -51,7 +51,7 @@ RSpec.describe "Redirects authentication using Referer", type: :system do
it "redirects back to the main feed as default" do
Capybara.current_session.driver.header "Referer", "https://example.com"
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/", ignore_query: true)
end

View file

@ -0,0 +1,187 @@
require "rails_helper"
RSpec.describe "Authenticating with Apple", vcr: { cassette_name: "fastly_sloan" } do
let(:sign_in_link) { "Continue with Apple" }
before do
omniauth_mock_apple_payload
Flipper.enable(:apple_auth)
allow(SiteConfig).to receive(:authentication_providers).and_return(Authentication::Providers.available)
end
context "when a user is new" do
context "when using valid credentials" do
it "creates a new user" do
expect do
visit sign_up_path
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
end
it "logs in and redirects to the onboarding" do
visit sign_up_path
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/onboarding", ignore_query: true)
expect(page.html).to include("onboarding-container")
end
it "remembers the user" do
visit sign_up_path
click_on(sign_in_link, match: :first)
user = User.last
expect(user.remember_token).to be_present
expect(user.remember_created_at).to be_present
end
end
context "when trying to register with an already existing username" do
it "creates a new user with a temporary username" do
# see Authentication::Providers::Apple#new_user_data
username = OmniAuth.config.mock_auth[:apple].info.first_name.downcase
user = create(:user, username: username)
expect do
visit sign_up_path
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
expect(page).to have_current_path("/onboarding", ignore_query: true)
expect(User.last.username).to include(user.username)
end
end
context "when using invalid credentials" do
let(:params) do
'{"callback_url"=>"http://localhost:3000/users/auth/apple/callback", "state"=>"navbar_basic"}'
end
before do
omniauth_setup_invalid_credentials(:apple)
allow(DatadogStatsClient).to receive(:increment)
end
after do
OmniAuth.config.on_failure = OmniauthHelpers.const_get("OMNIAUTH_DEFAULT_FAILURE_HANDLER")
end
it "does not create a new user" do
expect do
visit sign_up_path
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "notifies Datadog about a callback error" do
error = OmniAuth::Strategies::OAuth2::CallbackError.new(
"Callback error", "Error reason", "https://example.com/error"
)
omniauth_setup_authentication_error(error)
visit sign_up_path
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "apple", "{}")
expect(DatadogStatsClient).to have_received(:increment).with(
"omniauth.failure", *args
)
end
it "notifies Datadog about an OAuth unauthorized error" do
request = double
allow(request).to receive(:code).and_return(401)
allow(request).to receive(:message).and_return("unauthorized")
error = OAuth::Unauthorized.new(request)
omniauth_setup_authentication_error(error)
visit sign_up_path
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "apple", "{}")
expect(DatadogStatsClient).to have_received(:increment).with(
"omniauth.failure", *args
)
end
it "notifies Datadog even with no OmniAuth error present" do
error = nil
omniauth_setup_authentication_error(error)
visit sign_up_path
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "apple", "{}")
expect(DatadogStatsClient).to have_received(:increment).with(
"omniauth.failure", *args
)
end
end
context "when a validation failure occurrs" do
before do
# A User is invalid if their name is more than 100 chars long
OmniAuth.config.mock_auth[:apple].info.first_name = "X" * 101
end
it "does not create a new user" do
expect do
visit sign_up_path
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "redirects to the registration page" do
visit sign_up_path
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_up")
end
it "logs errors" do
allow(Honeybadger).to receive(:notify)
visit sign_up_path
click_on(sign_in_link, match: :first)
expect(Honeybadger).to have_received(:notify).once
end
end
end
context "when a user already exists" do
let!(:auth_payload) { OmniAuth.config.mock_auth[:apple] }
let(:user) { create(:user, :with_identity, identities: [:apple]) }
before do
# Apple sends nil as `first_name` and `last_name` for an existing user
auth_payload.info.first_name = auth_payload.info.last_name = nil
auth_payload.info.email = user.email
end
after do
sign_out user
end
context "when using valid credentials" do
it "logs in" do
visit sign_up_path
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/?signin=true")
end
end
context "when already signed in" do
it "redirects to the feed" do
sign_in user
visit user_apple_omniauth_authorize_path
expect(page).to have_current_path("/?signin=true")
end
end
end
end

View file

@ -13,13 +13,13 @@ RSpec.describe "Authenticating with Facebook" do
it "creates a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
end
it "logs in and redirects to the onboarding" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/onboarding", ignore_query: true)
expect(page.html).to include("onboarding-container")
@ -27,7 +27,7 @@ RSpec.describe "Authenticating with Facebook" do
it "remembers the user" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
user = User.last
@ -45,13 +45,13 @@ RSpec.describe "Authenticating with Facebook" do
it "creates a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
end
it "logs in and redirects to the onboarding" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/onboarding", ignore_query: true)
expect(page.html).to include("onboarding-container")
@ -59,7 +59,7 @@ RSpec.describe "Authenticating with Facebook" do
it "remembers the user" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
user = User.last
@ -76,7 +76,7 @@ RSpec.describe "Authenticating with Facebook" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
expect(page).to have_current_path("/onboarding", ignore_query: true)
@ -102,16 +102,16 @@ RSpec.describe "Authenticating with Facebook" do
it "does not create a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "does not log in" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_in")
expect(page).to have_link(sign_in_link)
expect(page).to have_button(sign_in_link)
end
it "notifies Datadog about a callback error" do
@ -122,7 +122,7 @@ RSpec.describe "Authenticating with Facebook" do
omniauth_setup_authentication_error(error, params)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "facebook", params)
expect(DatadogStatsClient).to have_received(:increment).with(
@ -138,7 +138,7 @@ RSpec.describe "Authenticating with Facebook" do
omniauth_setup_authentication_error(error, params)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "facebook", params)
expect(DatadogStatsClient).to have_received(:increment).with(
@ -151,7 +151,7 @@ RSpec.describe "Authenticating with Facebook" do
omniauth_setup_authentication_error(error, params)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "facebook", params)
expect(DatadogStatsClient).to have_received(:increment).with(
@ -169,13 +169,13 @@ RSpec.describe "Authenticating with Facebook" do
it "does not create a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "redirects to the registration page" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_up")
end
@ -184,7 +184,7 @@ RSpec.describe "Authenticating with Facebook" do
allow(Honeybadger).to receive(:notify)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(Honeybadger).to have_received(:notify).once
end
@ -207,7 +207,7 @@ RSpec.describe "Authenticating with Facebook" do
context "when using valid credentials" do
it "logs in" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/?signin=true")
end

View file

@ -13,13 +13,13 @@ RSpec.describe "Authenticating with GitHub" do
it "creates a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
end
it "logs in and redirects to the onboarding" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/onboarding", ignore_query: true)
expect(page.html).to include("onboarding-container")
@ -27,7 +27,7 @@ RSpec.describe "Authenticating with GitHub" do
it "remembers the user" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
user = User.last
@ -43,7 +43,7 @@ RSpec.describe "Authenticating with GitHub" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
expect(page).to have_current_path("/onboarding", ignore_query: true)
@ -69,16 +69,16 @@ RSpec.describe "Authenticating with GitHub" do
it "does not create a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "does not log in" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_in")
expect(page).to have_link(sign_in_link)
expect(page).to have_button(sign_in_link)
end
it "notifies Datadog about a callback error" do
@ -89,7 +89,7 @@ RSpec.describe "Authenticating with GitHub" do
omniauth_setup_authentication_error(error, params)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "github", params)
expect(DatadogStatsClient).to have_received(:increment).with(
@ -105,7 +105,7 @@ RSpec.describe "Authenticating with GitHub" do
omniauth_setup_authentication_error(error, params)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "github", params)
expect(DatadogStatsClient).to have_received(:increment).with(
@ -118,7 +118,7 @@ RSpec.describe "Authenticating with GitHub" do
omniauth_setup_authentication_error(error, params)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "github", params)
expect(DatadogStatsClient).to have_received(:increment).with(
@ -136,13 +136,13 @@ RSpec.describe "Authenticating with GitHub" do
it "does not create a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "redirects to the registration page" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_up")
end
@ -151,7 +151,7 @@ RSpec.describe "Authenticating with GitHub" do
allow(Honeybadger).to receive(:notify)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(Honeybadger).to have_received(:notify)
end
@ -173,7 +173,7 @@ RSpec.describe "Authenticating with GitHub" do
context "when using valid credentials" do
it "logs in" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/?signin=true")
end

View file

@ -13,13 +13,13 @@ RSpec.describe "Authenticating with Twitter" do
it "creates a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
end
it "logs in and redirects to the onboarding" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/onboarding?referrer=none")
expect(page.html).to include("onboarding-container")
@ -27,7 +27,7 @@ RSpec.describe "Authenticating with Twitter" do
it "remembers the user" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
user = User.last
@ -43,7 +43,7 @@ RSpec.describe "Authenticating with Twitter" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.to change(User, :count).by(1)
expect(page).to have_current_path("/onboarding?referrer=none")
@ -65,16 +65,16 @@ RSpec.describe "Authenticating with Twitter" do
it "does not create a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "does not log in" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_in")
expect(page).to have_link(sign_in_link)
expect(page).to have_button(sign_in_link)
end
it "notifies Datadog about a callback error" do
@ -85,7 +85,7 @@ RSpec.describe "Authenticating with Twitter" do
omniauth_setup_authentication_error(error)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "twitter", "{}")
expect(DatadogStatsClient).to have_received(:increment).with(
@ -101,7 +101,7 @@ RSpec.describe "Authenticating with Twitter" do
omniauth_setup_authentication_error(error)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "twitter", "{}")
expect(DatadogStatsClient).to have_received(:increment).with(
@ -114,7 +114,7 @@ RSpec.describe "Authenticating with Twitter" do
omniauth_setup_authentication_error(error)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
args = omniauth_failure_args(error, "twitter", "{}")
expect(DatadogStatsClient).to have_received(:increment).with(
@ -132,13 +132,13 @@ RSpec.describe "Authenticating with Twitter" do
it "does not create a new user" do
expect do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
end.not_to change(User, :count)
end
it "redirects to the registration page" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/users/sign_up")
end
@ -147,7 +147,7 @@ RSpec.describe "Authenticating with Twitter" do
allow(Honeybadger).to receive(:notify)
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(Honeybadger).to have_received(:notify)
end
@ -165,7 +165,7 @@ RSpec.describe "Authenticating with Twitter" do
context "when using valid credentials" do
it "logs in" do
visit sign_up_path
click_link(sign_in_link, match: :first)
click_on(sign_in_link, match: :first)
expect(page).to have_current_path("/?signin=true")
end

View file

@ -16,6 +16,7 @@ RSpec.describe "Link on tags for post in notifications", type: :system do
it "shows the sign-with page", js: true do
Authentication::Providers.enabled.each do |provider_name|
provider = Authentication::Providers.get!(provider_name)
next if provider.provider_name == :apple && !Flipper.enabled?(:apple_auth)
expect(page).to have_content("Continue with #{provider.official_name}")
end

View file

@ -59,7 +59,7 @@ RSpec.describe "Authenticating with a password" do
user.lock_access!
visit sign_up_path
click_link("Continue with GitHub", match: :first)
click_on("Continue with GitHub", match: :first)
expect(page).to have_current_path("/?signin=true")
expect(page).not_to have_text("Your account is locked.")

BIN
vendor/cache/omniauth-apple-1.0.1.gem vendored Normal file

Binary file not shown.