clavis 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.actrc +4 -0
- data/.cursor/rules/ruby-gem.mdc +49 -0
- data/.gemignore +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +88 -0
- data/.vscode/settings.json +22 -0
- data/CHANGELOG.md +127 -0
- data/CODE_OF_CONDUCT.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +838 -0
- data/Rakefile +341 -0
- data/UPGRADE.md +57 -0
- data/app/assets/stylesheets/clavis.css +133 -0
- data/app/controllers/clavis/auth_controller.rb +133 -0
- data/config/database.yml +16 -0
- data/config/routes.rb +49 -0
- data/docs/SECURITY.md +340 -0
- data/docs/TESTING.md +78 -0
- data/docs/integration.md +272 -0
- data/error_handling.md +355 -0
- data/file_structure.md +221 -0
- data/gemfiles/rails_80.gemfile +17 -0
- data/gemfiles/rails_80.gemfile.lock +286 -0
- data/implementation_plan.md +523 -0
- data/lib/clavis/configuration.rb +196 -0
- data/lib/clavis/controllers/concerns/authentication.rb +232 -0
- data/lib/clavis/controllers/concerns/session_management.rb +117 -0
- data/lib/clavis/engine.rb +191 -0
- data/lib/clavis/errors.rb +205 -0
- data/lib/clavis/logging.rb +116 -0
- data/lib/clavis/models/concerns/oauth_authenticatable.rb +169 -0
- data/lib/clavis/oauth_identity.rb +174 -0
- data/lib/clavis/providers/apple.rb +135 -0
- data/lib/clavis/providers/base.rb +432 -0
- data/lib/clavis/providers/custom_provider_example.rb +57 -0
- data/lib/clavis/providers/facebook.rb +84 -0
- data/lib/clavis/providers/generic.rb +63 -0
- data/lib/clavis/providers/github.rb +87 -0
- data/lib/clavis/providers/google.rb +98 -0
- data/lib/clavis/providers/microsoft.rb +57 -0
- data/lib/clavis/security/csrf_protection.rb +79 -0
- data/lib/clavis/security/https_enforcer.rb +90 -0
- data/lib/clavis/security/input_validator.rb +192 -0
- data/lib/clavis/security/parameter_filter.rb +64 -0
- data/lib/clavis/security/rate_limiter.rb +109 -0
- data/lib/clavis/security/redirect_uri_validator.rb +124 -0
- data/lib/clavis/security/session_manager.rb +220 -0
- data/lib/clavis/security/token_storage.rb +114 -0
- data/lib/clavis/user_info_normalizer.rb +74 -0
- data/lib/clavis/utils/nonce_store.rb +14 -0
- data/lib/clavis/utils/secure_token.rb +17 -0
- data/lib/clavis/utils/state_store.rb +18 -0
- data/lib/clavis/version.rb +6 -0
- data/lib/clavis/view_helpers.rb +260 -0
- data/lib/clavis.rb +132 -0
- data/lib/generators/clavis/controller/controller_generator.rb +48 -0
- data/lib/generators/clavis/controller/templates/controller.rb.tt +137 -0
- data/lib/generators/clavis/controller/templates/views/login.html.erb.tt +145 -0
- data/lib/generators/clavis/install_generator.rb +182 -0
- data/lib/generators/clavis/templates/add_oauth_to_users.rb +28 -0
- data/lib/generators/clavis/templates/clavis.css +133 -0
- data/lib/generators/clavis/templates/initializer.rb +47 -0
- data/lib/generators/clavis/templates/initializer.rb.tt +76 -0
- data/lib/generators/clavis/templates/migration.rb +18 -0
- data/lib/generators/clavis/templates/migration.rb.tt +16 -0
- data/lib/generators/clavis/user_method/user_method_generator.rb +219 -0
- data/lib/tasks/provider_verification.rake +77 -0
- data/llms.md +487 -0
- data/log/development.log +20 -0
- data/log/test.log +0 -0
- data/sig/clavis.rbs +4 -0
- data/testing_plan.md +710 -0
- metadata +258 -0
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Clavis.configure do |config|
|
4
|
+
# Configure your OAuth providers here
|
5
|
+
config.providers = {
|
6
|
+
# Google example:
|
7
|
+
# google: {
|
8
|
+
# client_id: ENV['GOOGLE_CLIENT_ID'],
|
9
|
+
# client_secret: ENV['GOOGLE_CLIENT_SECRET'],
|
10
|
+
# redirect_uri: 'https://your-app.com/auth/google/callback'
|
11
|
+
# },
|
12
|
+
|
13
|
+
# GitHub example:
|
14
|
+
# github: {
|
15
|
+
# client_id: ENV['GITHUB_CLIENT_ID'],
|
16
|
+
# client_secret: ENV['GITHUB_CLIENT_SECRET'],
|
17
|
+
# redirect_uri: 'https://your-app.com/auth/github/callback'
|
18
|
+
# },
|
19
|
+
|
20
|
+
# Apple example:
|
21
|
+
# apple: {
|
22
|
+
# client_id: ENV['APPLE_CLIENT_ID'], # Your Services ID
|
23
|
+
# team_id: ENV['APPLE_TEAM_ID'],
|
24
|
+
# key_id: ENV['APPLE_KEY_ID'],
|
25
|
+
# private_key: ENV['APPLE_PRIVATE_KEY'],
|
26
|
+
# redirect_uri: 'https://your-app.com/auth/apple/callback'
|
27
|
+
# }
|
28
|
+
}
|
29
|
+
|
30
|
+
# Optional: Configure logging
|
31
|
+
# config.logger = Rails.logger
|
32
|
+
# config.verbose_logging = false
|
33
|
+
|
34
|
+
# Security configuration
|
35
|
+
|
36
|
+
# Token encryption (disabled by default)
|
37
|
+
# config.encrypt_tokens = true
|
38
|
+
# config.encryption_key = ENV['CLAVIS_ENCRYPTION_KEY'] # Must be at least 32 bytes
|
39
|
+
# config.use_rails_credentials = true # Use Rails credentials for encryption key and provider config
|
40
|
+
|
41
|
+
# Parameter filtering (enabled by default)
|
42
|
+
# config.parameter_filter_enabled = true
|
43
|
+
|
44
|
+
# Redirect URI validation
|
45
|
+
# config.allowed_redirect_hosts = ['your-app.com'] # Add your app's domain(s)
|
46
|
+
# config.exact_redirect_uri_matching = false # Set to true for exact matching
|
47
|
+
# config.allow_localhost_in_development = true
|
48
|
+
# config.raise_on_invalid_redirect = true
|
49
|
+
|
50
|
+
# HTTPS enforcement (enabled by default)
|
51
|
+
# config.enforce_https = true # Force HTTPS for all OAuth URLs
|
52
|
+
# config.allow_http_localhost = true # Allow HTTP for localhost in development
|
53
|
+
# config.verify_ssl = true # Verify SSL certificates
|
54
|
+
# config.minimum_tls_version = :TLS1_2 # Minimum TLS version
|
55
|
+
|
56
|
+
# Input validation (enabled by default)
|
57
|
+
# config.validate_inputs = true # Validate all inputs
|
58
|
+
# config.sanitize_inputs = true # Sanitize all inputs
|
59
|
+
|
60
|
+
# Session management (enabled by default)
|
61
|
+
# config.rotate_session_after_login = true # Rotate session ID after login
|
62
|
+
# config.session_key_prefix = 'clavis' # Prefix for session keys
|
63
|
+
|
64
|
+
# Rate limiting (enabled by default)
|
65
|
+
# Requires rack-attack gem to be installed in your application
|
66
|
+
# config.rate_limiting_enabled = true # Enable rate limiting for OAuth endpoints
|
67
|
+
|
68
|
+
# Custom throttle rules (optional)
|
69
|
+
# config.custom_throttles = {
|
70
|
+
# "auth_page_views": {
|
71
|
+
# limit: 30,
|
72
|
+
# period: 1.minute,
|
73
|
+
# block: ->(req) { req.path == "/login" ? req.ip : nil }
|
74
|
+
# }
|
75
|
+
# }
|
76
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateClavisOauthIdentities < ActiveRecord::Migration[8.0]
|
4
|
+
def change
|
5
|
+
create_table :clavis_oauth_identities do |t|
|
6
|
+
t.references :authenticatable, polymorphic: true, null: false, index: true
|
7
|
+
t.string :provider, null: false
|
8
|
+
t.string :uid, null: false
|
9
|
+
t.json :auth_data
|
10
|
+
t.string :token
|
11
|
+
t.string :refresh_token
|
12
|
+
t.datetime :expires_at
|
13
|
+
t.timestamps
|
14
|
+
|
15
|
+
t.index [:provider, :uid], unique: true
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class CreateClavisOauthIdentities < ActiveRecord::Migration[<%= migration_version %>]
|
2
|
+
def change
|
3
|
+
create_table :clavis_oauth_identities do |t|
|
4
|
+
t.references :authenticatable, polymorphic: true, null: false, index: true
|
5
|
+
t.string :provider, null: false
|
6
|
+
t.string :uid, null: false
|
7
|
+
t.json :auth_data
|
8
|
+
t.string :token
|
9
|
+
t.string :refresh_token
|
10
|
+
t.datetime :expires_at
|
11
|
+
t.timestamps
|
12
|
+
|
13
|
+
t.index [:provider, :uid], unique: true
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,219 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
|
5
|
+
module Clavis
|
6
|
+
module Generators
|
7
|
+
class UserMethodGenerator < Rails::Generators::Base
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
|
10
|
+
desc "Adds Clavis OAuth user methods to your application"
|
11
|
+
|
12
|
+
def create_concern
|
13
|
+
# Create directory structure if it doesn't exist
|
14
|
+
directory_path = "app/models/concerns"
|
15
|
+
FileUtils.mkdir_p(directory_path) unless File.directory?(directory_path)
|
16
|
+
|
17
|
+
# Create the concern file
|
18
|
+
create_file "app/models/concerns/clavis_user_methods.rb", <<~RUBY
|
19
|
+
# frozen_string_literal: true
|
20
|
+
|
21
|
+
# This concern provides methods for finding or creating users from OAuth data
|
22
|
+
# It is intended to be included in your User model
|
23
|
+
module ClavisUserMethods
|
24
|
+
extend ActiveSupport::Concern
|
25
|
+
#{" "}
|
26
|
+
# Include the OauthAuthenticatable module to get helper methods
|
27
|
+
included do
|
28
|
+
include Clavis::Models::OauthAuthenticatable if defined?(Clavis::Models::OauthAuthenticatable)
|
29
|
+
#{" "}
|
30
|
+
# Add a temporary attribute to track OAuth authentication during user creation
|
31
|
+
# This can be used to conditionally skip password validation for OAuth users
|
32
|
+
attr_accessor :skip_password_validation
|
33
|
+
#{" "}
|
34
|
+
# IMPORTANT: If your User model uses has_secure_password, you need to handle
|
35
|
+
# password validation. Uncomment and modify ONE of these approaches:
|
36
|
+
#
|
37
|
+
# APPROACH 1: Skip password validation for OAuth users (recommended)
|
38
|
+
# validates :password, presence: true, length: { minimum: 8 },
|
39
|
+
# unless: -> { skip_password_validation }, on: :create
|
40
|
+
#
|
41
|
+
# APPROACH 2: Set a random secure password for OAuth users
|
42
|
+
# before_validation :set_random_password, if: -> { skip_password_validation && respond_to?(:password=) }
|
43
|
+
#
|
44
|
+
# APPROACH 3: Use validate: false for OAuth users (less recommended)
|
45
|
+
# See the #find_or_create_from_clavis method below
|
46
|
+
#{" "}
|
47
|
+
# For approach 2, add this method
|
48
|
+
# def set_random_password
|
49
|
+
# self.password = SecureRandom.hex(16)
|
50
|
+
# self.password_confirmation = password if respond_to?(:password_confirmation=)
|
51
|
+
# end
|
52
|
+
end
|
53
|
+
|
54
|
+
class_methods do
|
55
|
+
# Find or create a user from OAuth authentication
|
56
|
+
# This method is called by Clavis when authenticating via OAuth
|
57
|
+
def find_or_create_from_clavis(auth_hash)
|
58
|
+
# First try to find an existing identity
|
59
|
+
# For OpenID Connect providers like Google, we use the sub claim as the identifier
|
60
|
+
# For other providers, we use the uid
|
61
|
+
identity = if auth_hash[:id_token_claims]&.dig(:sub)
|
62
|
+
Clavis::OauthIdentity.find_by(
|
63
|
+
provider: auth_hash[:provider],
|
64
|
+
uid: auth_hash[:id_token_claims][:sub]
|
65
|
+
)
|
66
|
+
else
|
67
|
+
Clavis::OauthIdentity.find_by(
|
68
|
+
provider: auth_hash[:provider],
|
69
|
+
uid: auth_hash[:uid]
|
70
|
+
)
|
71
|
+
end
|
72
|
+
return identity.user if identity&.user
|
73
|
+
|
74
|
+
# Extract email from auth_hash (try various possible locations)
|
75
|
+
email = extract_email_from_auth_hash(auth_hash)
|
76
|
+
#{" "}
|
77
|
+
# Try to find existing user by email if available
|
78
|
+
user = find_by(email: email) if email.present?
|
79
|
+
#{" "}
|
80
|
+
# Create a new user if none found
|
81
|
+
if user.nil?
|
82
|
+
# Convert to HashWithIndifferentAccess for reliable key access
|
83
|
+
info = auth_hash[:info].with_indifferent_access if auth_hash[:info]
|
84
|
+
claims = auth_hash[:id_token_claims].with_indifferent_access if auth_hash[:id_token_claims]
|
85
|
+
#{" "}
|
86
|
+
user = new(
|
87
|
+
email: email
|
88
|
+
# Add other required fields for your User model here, for example:
|
89
|
+
##{" "}
|
90
|
+
# With HashWithIndifferentAccess, access is reliable regardless of key type:
|
91
|
+
# first_name: info&.dig(:given_name) || info&.dig(:first_name),
|
92
|
+
# last_name: info&.dig(:family_name) || info&.dig(:last_name),
|
93
|
+
# name: info&.dig(:name),
|
94
|
+
# username: info&.dig(:nickname),
|
95
|
+
# avatar_url: info&.dig(:picture) || info&.dig(:image),
|
96
|
+
# terms_accepted: true # for required boolean fields
|
97
|
+
)
|
98
|
+
#{" "}
|
99
|
+
# Mark this user as coming from OAuth to skip password validation
|
100
|
+
# This works with the validation conditionals defined above
|
101
|
+
user.skip_password_validation = true
|
102
|
+
#{" "}
|
103
|
+
# APPROACH 1 & 2: Use standard save with conditional validation
|
104
|
+
user.save!
|
105
|
+
#{" "}
|
106
|
+
# APPROACH 3: Bypass validations entirely - use with caution, and only if approaches 1 & 2 don't work
|
107
|
+
# If your User model has complex validations that are incompatible with OAuth users,
|
108
|
+
# you might need to bypass validations. Uncomment this if needed:
|
109
|
+
# user.save(validate: false)
|
110
|
+
end
|
111
|
+
#{" "}
|
112
|
+
# Create or update the OAuth identity
|
113
|
+
identity = Clavis::OauthIdentity.find_or_initialize_by(
|
114
|
+
provider: auth_hash[:provider],
|
115
|
+
uid: auth_hash[:id_token_claims]&.dig(:sub) || auth_hash[:uid]
|
116
|
+
)
|
117
|
+
identity.user = user
|
118
|
+
identity.auth_data = auth_hash[:info]
|
119
|
+
identity.token = auth_hash.dig(:credentials, :token)
|
120
|
+
identity.refresh_token = auth_hash.dig(:credentials, :refresh_token)
|
121
|
+
identity.expires_at = auth_hash.dig(:credentials, :expires_at) ? Time.at(auth_hash.dig(:credentials, :expires_at)) : nil
|
122
|
+
identity.save!
|
123
|
+
#{" "}
|
124
|
+
# Set the oauth_user flag if available
|
125
|
+
user.update(oauth_user: true) if user.respond_to?(:oauth_user=)
|
126
|
+
#{" "}
|
127
|
+
# Optional: Update any fields on the User model that you want to keep in sync
|
128
|
+
# user.update(
|
129
|
+
# avatar_url: auth_hash.dig(:info, :image),
|
130
|
+
# last_oauth_login_at: Time.current,
|
131
|
+
# last_oauth_provider: auth_hash[:provider]
|
132
|
+
# )
|
133
|
+
#{" "}
|
134
|
+
user
|
135
|
+
end
|
136
|
+
#{" "}
|
137
|
+
private
|
138
|
+
#{" "}
|
139
|
+
# Helper method to extract email from various locations in the auth hash
|
140
|
+
def extract_email_from_auth_hash(auth_hash)
|
141
|
+
return nil unless auth_hash
|
142
|
+
#{" "}
|
143
|
+
# Try to get email from various possible locations
|
144
|
+
if auth_hash[:info]&.with_indifferent_access
|
145
|
+
info = auth_hash[:info].with_indifferent_access
|
146
|
+
return info[:email] if info[:email].present?
|
147
|
+
end
|
148
|
+
#{" "}
|
149
|
+
if auth_hash[:id_token_claims]&.with_indifferent_access
|
150
|
+
claims = auth_hash[:id_token_claims].with_indifferent_access
|
151
|
+
return claims[:email] if claims[:email].present?
|
152
|
+
end
|
153
|
+
#{" "}
|
154
|
+
if auth_hash[:extra]&.dig(:raw_info)&.with_indifferent_access
|
155
|
+
raw_info = auth_hash[:extra][:raw_info].with_indifferent_access
|
156
|
+
return raw_info[:email] if raw_info[:email].present?
|
157
|
+
end
|
158
|
+
#{" "}
|
159
|
+
nil
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
RUBY
|
164
|
+
|
165
|
+
say_status :create, "Created ClavisUserMethods concern", :green
|
166
|
+
end
|
167
|
+
|
168
|
+
def update_user_model
|
169
|
+
user_file = "app/models/user.rb"
|
170
|
+
|
171
|
+
if File.exist?(user_file)
|
172
|
+
# Check if the concern is already included
|
173
|
+
user_content = File.read(user_file)
|
174
|
+
if user_content.include?("include ClavisUserMethods")
|
175
|
+
say_status :skip, "ClavisUserMethods already included in User model", :yellow
|
176
|
+
return
|
177
|
+
end
|
178
|
+
|
179
|
+
# Add the concern include to the User model
|
180
|
+
inject_into_file user_file, after: "class User < ApplicationRecord\n" do
|
181
|
+
" include ClavisUserMethods\n"
|
182
|
+
end
|
183
|
+
|
184
|
+
say_status :inject, "Added ClavisUserMethods include to User model", :green
|
185
|
+
else
|
186
|
+
# Create a User model with the concern included
|
187
|
+
create_file user_file, <<~RUBY
|
188
|
+
class User < ApplicationRecord
|
189
|
+
include ClavisUserMethods
|
190
|
+
#{" "}
|
191
|
+
# Add your User model code here
|
192
|
+
end
|
193
|
+
RUBY
|
194
|
+
|
195
|
+
say_status :create, "Created User model with ClavisUserMethods included", :green
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def show_instructions
|
200
|
+
say "\nThe ClavisUserMethods concern has been created and included in your User model."
|
201
|
+
say "This gives your User model the ability to find or create users from OAuth data."
|
202
|
+
|
203
|
+
say "\n⚠️ IMPORTANT: You must customize the user creation code to match your User model!"
|
204
|
+
say "The default implementation only sets the email field, which may not be sufficient."
|
205
|
+
|
206
|
+
say "\n⚠️ IMPORTANT: If your User model uses has_secure_password, you need to handle password validation!"
|
207
|
+
say "Look for the password validation section in app/models/concerns/clavis_user_methods.rb and"
|
208
|
+
say "uncomment one of the approaches described there."
|
209
|
+
|
210
|
+
say "\nTo customize:"
|
211
|
+
say " 1. Edit app/models/concerns/clavis_user_methods.rb"
|
212
|
+
say " 2. Add required fields to the user creation in find_or_create_from_clavis"
|
213
|
+
say " 3. Handle password validation if your model uses has_secure_password"
|
214
|
+
|
215
|
+
say "\nFor more information, see the documentation at https://github.com/clayton/clavis"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :clavis do
|
4
|
+
desc "Verify all providers implement required methods"
|
5
|
+
task verify_providers: :environment do
|
6
|
+
require "clavis"
|
7
|
+
|
8
|
+
# Define the methods all providers must implement
|
9
|
+
required_methods = %i[
|
10
|
+
process_callback
|
11
|
+
authorize_url
|
12
|
+
token_exchange
|
13
|
+
get_user_info
|
14
|
+
refresh_token
|
15
|
+
provider_name
|
16
|
+
authorization_endpoint
|
17
|
+
token_endpoint
|
18
|
+
userinfo_endpoint
|
19
|
+
default_scopes
|
20
|
+
openid_provider?
|
21
|
+
]
|
22
|
+
|
23
|
+
# Get all provider classes
|
24
|
+
providers = [
|
25
|
+
Clavis::Providers::Google,
|
26
|
+
Clavis::Providers::Github,
|
27
|
+
Clavis::Providers::Microsoft,
|
28
|
+
Clavis::Providers::Facebook,
|
29
|
+
Clavis::Providers::Apple,
|
30
|
+
Clavis::Providers::Generic
|
31
|
+
]
|
32
|
+
|
33
|
+
# Initialize a provider with fake credentials
|
34
|
+
args = {
|
35
|
+
client_id: "fake-client-id",
|
36
|
+
client_secret: "fake-client-secret",
|
37
|
+
redirect_uri: "http://localhost:3000/callback"
|
38
|
+
}
|
39
|
+
|
40
|
+
missing_methods = []
|
41
|
+
errors = []
|
42
|
+
|
43
|
+
# Check each provider
|
44
|
+
providers.each do |provider_class|
|
45
|
+
Rails.logger.debug { "Checking #{provider_class.name}..." }
|
46
|
+
|
47
|
+
begin
|
48
|
+
provider = provider_class.new(args)
|
49
|
+
|
50
|
+
required_methods.each do |method|
|
51
|
+
missing_methods << "#{provider_class.name} is missing method: #{method}" unless provider.respond_to?(method)
|
52
|
+
end
|
53
|
+
rescue StandardError => e
|
54
|
+
errors << "Error initializing #{provider_class.name}: #{e.message}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Report results
|
59
|
+
if missing_methods.any? || errors.any?
|
60
|
+
Rails.logger.debug "FAILURES:"
|
61
|
+
|
62
|
+
if missing_methods.any?
|
63
|
+
Rails.logger.debug "\nMissing Methods:"
|
64
|
+
Rails.logger.debug missing_methods.join("\n")
|
65
|
+
end
|
66
|
+
|
67
|
+
if errors.any?
|
68
|
+
Rails.logger.debug "\nInitialization Errors:"
|
69
|
+
Rails.logger.debug errors.join("\n")
|
70
|
+
end
|
71
|
+
|
72
|
+
exit 1
|
73
|
+
else
|
74
|
+
Rails.logger.debug "✅ All providers implement required methods."
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|