commit: 26f21fd5a03b1c6407cd81c58481288d06958ad3
parent: 9da81a16391edfcbda9c748dcd519fb3ebd765e5
Author: Eugen Rochko <eugen@zeonfederated.com>
Date: Sun, 4 Feb 2018 05:42:13 +0100
CAS + SAML authentication feature (#6425)
* Cas authentication feature
* Config
* Remove class_eval + Omniauth initializer
* Codeclimate review
* Codeclimate review 2
* Codeclimate review 3
* Remove uid/email reconciliation
* SAML authentication
* Clean up code
* Improve login form
* Fix code style issues
* Add locales
Diffstat:
20 files changed, 365 insertions(+), 3 deletions(-)
diff --git a/.env.production.sample b/.env.production.sample
@@ -13,7 +13,7 @@ DB_PORT=5432
# Federation
# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation.
# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com.
-LOCAL_DOMAIN=example.com
+LOCAL_DOMAIN=example.com
# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links)
@@ -58,7 +58,7 @@ VAPID_PUBLIC_KEY=
# E-mail configuration
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
# If you want to use an SMTP server without authentication (e.g local Postfix relay)
-# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and
+# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and
# *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough).
SMTP_SERVER=smtp.mailgun.org
SMTP_PORT=587
@@ -135,3 +135,43 @@ STREAMING_CLUSTER_NUM=1
# If you use Docker, you may want to assign UID/GID manually.
# UID=1000
# GID=1000
+
+# Optional CAS authentication (cf. omniauth-cas) :
+# CAS_ENABLED=true
+# CAS_URL=https://sso.myserver.com/
+# CAS_HOST=sso.myserver.com/
+# CAS_PORT=443
+# CAS_SSL=true
+# CAS_VALIDATE_URL=
+# CAS_CALLBACK_URL=
+# CAS_LOGOUT_URL=
+# CAS_LOGIN_URL=
+# CAS_UID_FIELD='user'
+# CAS_CA_PATH=
+# CAS_DISABLE_SSL_VERIFICATION=false
+# CAS_UID_KEY='user'
+# CAS_NAME_KEY='name'
+# CAS_EMAIL_KEY='email'
+# CAS_NICKNAME_KEY='nickname'
+# CAS_FIRST_NAME_KEY='firstname'
+# CAS_LAST_NAME_KEY='lastname'
+# CAS_LOCATION_KEY='location'
+# CAS_IMAGE_KEY='image'
+# CAS_PHONE_KEY='phone'
+
+# Optional SAML authentication (cf. omniauth-saml)
+# SAML_ENABLED=true
+# SAML_ACS_URL=
+# SAML_ISSUER=http://localhost:3000/auth/auth/saml/metadata
+# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO
+# SAML_IDP_CERT=
+# SAML_IDP_CERT_FINGERPRINT=
+# SAML_NAME_IDENTIFIER_FORMAT=
+# SAML_CERT=
+# SAML_PRIVATE_KEY=
+# SAML_SECURITY_WANT_ASSERTION_SIGNED=true
+# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
+# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
+# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
+# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42"
+# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
diff --git a/Gemfile b/Gemfile
@@ -32,6 +32,9 @@ gem 'devise', '~> 4.4'
gem 'devise-two-factor', '~> 3.0'
gem 'devise_pam_authenticatable2', '~> 8.0'
+gem 'omniauth-cas', '~> 1.1', install_if: -> { ENV['CAS_ENABLED'] == 'true' }
+gem 'omniauth-saml', '~> 1.8', install_if: -> { ENV['SAML_ENABLED'] == 'true' }
+gem 'omniauth', '~> 1.2'
gem 'doorkeeper', '~> 4.2'
gem 'fast_blank', '~> 1.0'
diff --git a/Gemfile.lock b/Gemfile.lock
@@ -201,6 +201,7 @@ GEM
hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (0.3.7)
+ hashie (3.5.7)
highline (1.7.10)
hiredis (0.6.1)
hkdf (0.3.0)
@@ -304,6 +305,16 @@ GEM
sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0)
oj (3.3.10)
+ omniauth (1.8.1)
+ hashie (>= 3.4.6, < 3.6.0)
+ rack (>= 1.6.2, < 3)
+ omniauth-cas (1.1.1)
+ addressable (~> 2.3)
+ nokogiri (~> 1.5)
+ omniauth (~> 1.2)
+ omniauth-saml (1.9.0)
+ omniauth (~> 1.3, >= 1.3.2)
+ ruby-saml (~> 1.4, >= 1.4.3)
orm_adapter (0.5.0)
ostatus2 (2.0.3)
addressable (~> 2.5)
@@ -455,6 +466,8 @@ GEM
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.12.0)
ruby-progressbar (1.9.0)
+ ruby-saml (1.6.1)
+ nokogiri (>= 1.5.10)
rufus-scheduler (3.4.2)
et-orbi (~> 1.0)
safe_yaml (1.0.4)
@@ -606,6 +619,9 @@ DEPENDENCIES
nokogiri (~> 1.8)
nsa (~> 0.2)
oj (~> 3.3)
+ omniauth (~> 1.2)
+ omniauth-cas (~> 1.1)
+ omniauth-saml (~> 1.8)
ostatus2 (~> 2.0)
ox (~> 2.8)
paperclip (~> 5.1)
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
@@ -2,4 +2,28 @@
class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth'
+
+ before_action :set_user, only: [:finish_signup]
+
+ # GET/PATCH /users/:id/finish_signup
+ def finish_signup
+ return unless request.patch? && params[:user]
+ if @user.update(user_params)
+ @user.skip_reconfirmation!
+ sign_in(@user, bypass: true)
+ redirect_to root_path, notice: I18n.t('devise.confirmations.send_instructions')
+ else
+ @show_errors = true
+ end
+ end
+
+ private
+
+ def set_user
+ @user = current_user
+ end
+
+ def user_params
+ params.require(:user).permit(:email)
+ end
end
diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
+ skip_before_action :verify_authenticity_token
+
+ def self.provides_callback_for(provider)
+ provider_id = provider.to_s.chomp '_oauth2'
+
+ define_method provider do
+ @user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
+
+ if @user.persisted?
+ sign_in_and_redirect @user, event: :authentication
+ set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format?
+ else
+ session["devise.#{provider}_data"] = request.env['omniauth.auth']
+ redirect_to new_user_registration_url
+ end
+ end
+ end
+
+ Devise.omniauth_configs.each_key do |provider|
+ provides_callback_for provider
+ end
+
+ def after_sign_in_path_for(resource)
+ if resource.email_verified?
+ root_path
+ else
+ finish_signup_path
+ end
+ end
+end
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
@@ -568,3 +568,21 @@ code {
margin-bottom: 4px;
}
}
+
+.alternative-login {
+ margin-top: 20px;
+ margin-bottom: 20px;
+
+ h4 {
+ font-size: 16px;
+ color: $ui-base-lighter-color;
+ text-align: center;
+ margin-bottom: 20px;
+ border: 0;
+ padding: 0;
+ }
+
+ .button {
+ display: block;
+ }
+}
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Omniauthable
+ extend ActiveSupport::Concern
+
+ TEMP_EMAIL_PREFIX = 'change@me'
+ TEMP_EMAIL_REGEX = /\Achange@me/
+
+ included do
+ def omniauth_providers
+ Devise.omniauth_configs.keys
+ end
+
+ def email_verified?
+ email && email !~ TEMP_EMAIL_REGEX
+ end
+ end
+
+ class_methods do
+ def find_for_oauth(auth, signed_in_resource = nil)
+ # EOLE-SSO Patch
+ auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array
+ identity = Identity.find_for_oauth(auth)
+
+ # If a signed_in_resource is provided it always overrides the existing user
+ # to prevent the identity being locked with accidentally created accounts.
+ # Note that this may leave zombie accounts (with no associated identity) which
+ # can be cleaned up at a later date.
+ user = signed_in_resource ? signed_in_resource : identity.user
+ user = create_for_oauth(auth) if user.nil?
+
+ if identity.user.nil?
+ identity.user = user
+ identity.save!
+ end
+
+ user
+ end
+
+ def create_for_oauth(auth)
+ # Check if the user exists with provided email if the provider gives us a
+ # verified email. If no verified email was provided or the user already
+ # exists, we assign a temporary email and ask the user to verify it on
+ # the next step via Auth::ConfirmationsController.finish_signup
+
+ user = User.new(user_params_from_auth(auth))
+ user.account.avatar_remote_url = auth.info.image if auth.info.image =~ /\A#{URI.regexp(%w(http https))}\z/
+ user.skip_confirmation!
+ user.save!
+ user
+ end
+
+ private
+
+ def user_params_from_auth(auth)
+ email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email)
+ email = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email)
+
+ {
+ email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
+ password: Devise.friendly_token[0, 20],
+ account_attributes: {
+ username: ensure_unique_username(auth.uid),
+ display_name: [auth.info.first_name, auth.info.last_name].join(' '),
+ },
+ }
+ end
+
+ def ensure_unique_username(starting_username)
+ username = starting_username
+ i = 0
+
+ while Account.exists?(username: username)
+ i += 1
+ username = "#{starting_username}_#{i}"
+ end
+
+ username
+ end
+ end
+end
diff --git a/app/models/identity.rb b/app/models/identity.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: identities
+#
+# id :integer not null, primary key
+# user_id :integer
+# provider :string default(""), not null
+# uid :string default(""), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class Identity < ApplicationRecord
+ belongs_to :user, dependent: :destroy
+ validates :uid, presence: true, uniqueness: { scope: :provider }
+ validates :provider, presence: true
+
+ def self.find_for_oauth(auth)
+ find_or_create_by(uid: auth.uid, provider: auth.provider)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
@@ -39,6 +39,7 @@
class User < ApplicationRecord
include Settings::Extend
+ include Omniauthable
ACTIVE_DURATION = 14.days
@@ -52,6 +53,7 @@ class User < ApplicationRecord
:confirmable
devise :pam_authenticatable
+ devise :omniauthable
belongs_to :account, inverse_of: :user
belongs_to :invite, counter_cache: :uses, optional: true
diff --git a/app/views/auth/confirmations/finish_signup.html.haml b/app/views/auth/confirmations/finish_signup.html.haml
@@ -0,0 +1,14 @@
+- content_for :page_title do
+ = t('auth.confirm_email')
+
+= simple_form_for(current_user, as: 'user', url: finish_signup_path, html: { role: 'form'}) do |f|
+ - if @show_errors && current_user.errors.any?
+ #error_explanation
+ - current_user.errors.full_messages.each do |msg|
+ = msg
+ %br
+
+ = f.input :email
+
+ .actions
+ = f.submit t('auth.confirm_email'), class: 'button'
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
@@ -14,4 +14,13 @@
.actions
= f.button :button, t('auth.login'), type: :submit
+- if devise_mapping.omniauthable? and resource_class.omniauth_providers.any?
+ .simple_form.alternative-login
+ %h4= t('auth.or_log_in_with')
+
+ .actions
+ - resource_class.omniauth_providers.each do |provider|
+ = link_to omniauth_authorize_path(resource_name, provider), class: "button button-#{provider}" do
+ = t("auth.providers.#{provider}", default: provider.to_s.chomp("_oauth2").capitalize)
+
.form-footer= render 'auth/shared/links'
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
@@ -46,6 +46,7 @@ ignore_missing:
- 'terms.body_html'
- 'application_mailer.salutation'
- 'errors.500'
+ - 'auth.providers.*'
ignore_unused:
- 'activemodel.errors.*'
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
@@ -0,0 +1,59 @@
+Rails.application.config.middleware.use OmniAuth::Builder do
+ # Vanilla omniauth stategies
+end
+
+Devise.setup do |config|
+ # Devise omniauth strategies
+
+ # CAS strategy
+ if ENV['CAS_ENABLED'] == 'true'
+ cas_options = {}
+ cas_options[:url] = ENV['CAS_URL'] if ENV['CAS_URL']
+ cas_options[:host] = ENV['CAS_HOST'] if ENV['CAS_HOST']
+ cas_options[:port] = ENV['CAS_PORT'] if ENV['CAS_PORT']
+ cas_options[:ssl] = ENV['CAS_SSL'] == 'true' if ENV['CAS_SSL']
+ cas_options[:validate_url] = ENV['CAS_VALIDATE_URL'] if ENV['CAS_VALIDATE_URL']
+ cas_options[:callback_url] = ENV['CAS_CALLBACK_URL'] if ENV['CAS_CALLBACK_URL']
+ cas_options[:logout_url] = ENV['CAS_LOGOUT_URL'] if ENV['CAS_LOGOUT_URL']
+ cas_options[:login_url] = ENV['CAS_LOGIN_URL'] if ENV['CAS_LOGIN_URL']
+ cas_options[:uid_field] = ENV['CAS_UID_FIELD'] || 'user' if ENV['CAS_UID_FIELD']
+ cas_options[:ca_path] = ENV['CAS_CA_PATH'] if ENV['CAS_CA_PATH']
+ cas_options[:disable_ssl_verification] = ENV['CAS_DISABLE_SSL_VERIFICATION'] == 'true' if ENV['CAS_DISABLE_SSL_VERIFICATION']
+ cas_options[:uid_key] = ENV['CAS_UID_KEY'] || 'user'
+ cas_options[:name_key] = ENV['CAS_NAME_KEY'] || 'name'
+ cas_options[:email_key] = ENV['CAS_EMAIL_KEY'] || 'email'
+ cas_options[:nickname_key] = ENV['CAS_NICKNAME_KEY'] || 'nickname'
+ cas_options[:first_name_key] = ENV['CAS_FIRST_NAME_KEY'] || 'firstname'
+ cas_options[:last_name_key] = ENV['CAS_LAST_NAME_KEY'] || 'lastname'
+ cas_options[:location_key] = ENV['CAS_LOCATION_KEY'] || 'location'
+ cas_options[:image_key] = ENV['CAS_IMAGE_KEY'] || 'image'
+ cas_options[:phone_key] = ENV['CAS_PHONE_KEY'] || 'phone'
+ config.omniauth :cas, cas_options
+ end
+
+ # SAML strategy
+ if ENV['SAML_ENABLED'] == 'true'
+ saml_options = {}
+ saml_options[:assertion_consumer_service_url] = ENV['SAML_ACS_URL'] if ENV['SAML_ACS_URL']
+ saml_options[:issuer] = ENV['SAML_ISSUER'] if ENV['SAML_ISSUER']
+ saml_options[:idp_sso_target_url] = ENV['SAML_IDP_SSO_TARGET_URL'] if ENV['SAML_IDP_SSO_TARGET_URL']
+ saml_options[:idp_sso_target_url_runtime_params] = ENV['SAML_IDP_SSO_TARGET_PARAMS'] if ENV['SAML_IDP_SSO_TARGET_PARAMS'] # FIXME: Should be parsable Hash
+ saml_options[:idp_cert] = ENV['SAML_IDP_CERT'] if ENV['SAML_IDP_CERT']
+ saml_options[:idp_cert_fingerprint] = ENV['SAML_IDP_CERT_FINGERPRINT'] if ENV['SAML_IDP_CERT_FINGERPRINT']
+ saml_options[:idp_cert_fingerprint_validator] = ENV['SAML_IDP_CERT_FINGERPRINT_VALIDATOR'] if ENV['SAML_IDP_CERT_FINGERPRINT_VALIDATOR'] # FIXME: Should be Lambda { |fingerprint| }
+ saml_options[:name_identifier_format] = ENV['SAML_NAME_IDENTIFIER_FORMAT'] if ENV['SAML_NAME_IDENTIFIER_FORMAT']
+ saml_options[:request_attributes] = {}
+ saml_options[:certificate] = ENV['SAML_CERT'] if ENV['SAML_CERT']
+ saml_options[:private_key] = ENV['SAML_PRIVATE_KEY'] if ENV['SAML_PRIVATE_KEY']
+ saml_options[:security] = {}
+ saml_options[:security][:want_assertions_signed] = ENV['SAML_SECURITY_WANT_ASSERTION_SIGNED'] == 'true'
+ saml_options[:security][:want_assertions_encrypted] = ENV['SAML_SECURITY_WANT_ASSERTION_ENCRYPTED'] == 'true'
+ saml_options[:attribute_statements] = {}
+ saml_options[:attribute_statements][:uid] = [ENV['SAML_ATTRIBUTES_STATEMENTS_UID']] if ENV['SAML_ATTRIBUTES_STATEMENTS_UID']
+ saml_options[:attribute_statements][:email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']
+ saml_options[:attribute_statements][:full_name] = [ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']] if ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']
+ saml_options[:uid_attribute] = ENV['SAML_UID_ATTRIBUTE'] if ENV['SAML_UID_ATTRIBUTE']
+ config.omniauth :saml, saml_options
+ end
+
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
@@ -355,6 +355,7 @@ en:
auth:
agreement_html: By signing up you agree to follow <a href="%{rules_path}">the rules of the instance</a> and <a href="%{terms_path}">our terms of service</a>.
change_password: Security
+ confirm_email: Confirm email
delete_account: Delete account
delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
didnt_get_confirmation: Didn't receive confirmation instructions?
@@ -364,6 +365,10 @@ en:
logout: Logout
migrate_account: Move to a different account
migrate_account_html: If you wish to redirect this account to a different one, you can <a href="%{path}">configure it here</a>.
+ or_log_in_with: Or log in with
+ providers:
+ cas: CAS
+ saml: SAML
register: Sign up
resend_confirmation: Resend confirmation instructions
reset_password: Reset password
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
@@ -355,6 +355,7 @@ fr:
auth:
agreement_html: En vous inscrivant, vous souscrivez <a href="%{rules_path}">aux règles de l’instance</a> et à <a href="%{terms_path}">nos conditions d’utilisation</a>.
change_password: Sécurité
+ confirm_email: Confirmer mon adresse mail
delete_account: Supprimer le compte
delete_account_html: Si vous désirez supprimer votre compte, vous pouvez <a href="%{path}">cliquer ici</a>. Il vous sera demandé de confirmer cette action.
didnt_get_confirmation: Vous n’avez pas reçu les consignes de confirmation ?
@@ -364,6 +365,7 @@ fr:
logout: Se déconnecter
migrate_account: Déplacer vers un compte différent
migrate_account_html: Si vous voulez rediriger ce compte vers un autre, vous pouvez le <a href="%{path}">configurer ici</a>.
+ or_log_in_with: Ou authentifiez-vous avec
register: S’inscrire
resend_confirmation: Envoyer à nouveau les consignes de confirmation
reset_password: Réinitialiser le mot de passe
diff --git a/config/routes.rb b/config/routes.rb
@@ -24,9 +24,11 @@ Rails.application.routes.draw do
devise_scope :user do
get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite
+ match '/auth/finish_signup' => 'auth/confirmations#finish_signup', via: [:get, :patch], as: :finish_signup
end
devise_for :users, path: 'auth', controllers: {
+ omniauth_callbacks: 'auth/omniauth_callbacks',
sessions: 'auth/sessions',
registrations: 'auth/registrations',
passwords: 'auth/passwords',
diff --git a/db/migrate/20180204034416_create_identities.rb b/db/migrate/20180204034416_create_identities.rb
@@ -0,0 +1,11 @@
+class CreateIdentities < ActiveRecord::Migration[5.0]
+ def change
+ create_table :identities do |t|
+ t.references :user, foreign_key: { on_delete: :cascade }
+ t.string :provider, null: false, default: ''
+ t.string :uid, null: false, default: ''
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20180109143959) do
+ActiveRecord::Schema.define(version: 20180204034416) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -173,6 +173,15 @@ ActiveRecord::Schema.define(version: 20180109143959) do
t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
end
+ create_table "identities", id: :serial, force: :cascade do |t|
+ t.integer "user_id"
+ t.string "provider", default: "", null: false
+ t.string "uid", default: "", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["user_id"], name: "index_identities_on_user_id"
+ end
+
create_table "imports", force: :cascade do |t|
t.integer "type", null: false
t.boolean "approved", default: false, null: false
@@ -526,6 +535,7 @@ ActiveRecord::Schema.define(version: 20180109143959) do
add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
+ add_foreign_key "identities", "users", on_delete: :cascade
add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
add_foreign_key "invites", "users", on_delete: :cascade
add_foreign_key "list_accounts", "accounts", on_delete: :cascade
diff --git a/spec/fabricators/identity_fabricator.rb b/spec/fabricators/identity_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:identity) do
+ user nil
+ provider "MyString"
+ uid "MyString"
+end
diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Identity, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end