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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.actrc +4 -0
  3. data/.cursor/rules/ruby-gem.mdc +49 -0
  4. data/.gemignore +6 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +88 -0
  7. data/.vscode/settings.json +22 -0
  8. data/CHANGELOG.md +127 -0
  9. data/CODE_OF_CONDUCT.md +3 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +838 -0
  12. data/Rakefile +341 -0
  13. data/UPGRADE.md +57 -0
  14. data/app/assets/stylesheets/clavis.css +133 -0
  15. data/app/controllers/clavis/auth_controller.rb +133 -0
  16. data/config/database.yml +16 -0
  17. data/config/routes.rb +49 -0
  18. data/docs/SECURITY.md +340 -0
  19. data/docs/TESTING.md +78 -0
  20. data/docs/integration.md +272 -0
  21. data/error_handling.md +355 -0
  22. data/file_structure.md +221 -0
  23. data/gemfiles/rails_80.gemfile +17 -0
  24. data/gemfiles/rails_80.gemfile.lock +286 -0
  25. data/implementation_plan.md +523 -0
  26. data/lib/clavis/configuration.rb +196 -0
  27. data/lib/clavis/controllers/concerns/authentication.rb +232 -0
  28. data/lib/clavis/controllers/concerns/session_management.rb +117 -0
  29. data/lib/clavis/engine.rb +191 -0
  30. data/lib/clavis/errors.rb +205 -0
  31. data/lib/clavis/logging.rb +116 -0
  32. data/lib/clavis/models/concerns/oauth_authenticatable.rb +169 -0
  33. data/lib/clavis/oauth_identity.rb +174 -0
  34. data/lib/clavis/providers/apple.rb +135 -0
  35. data/lib/clavis/providers/base.rb +432 -0
  36. data/lib/clavis/providers/custom_provider_example.rb +57 -0
  37. data/lib/clavis/providers/facebook.rb +84 -0
  38. data/lib/clavis/providers/generic.rb +63 -0
  39. data/lib/clavis/providers/github.rb +87 -0
  40. data/lib/clavis/providers/google.rb +98 -0
  41. data/lib/clavis/providers/microsoft.rb +57 -0
  42. data/lib/clavis/security/csrf_protection.rb +79 -0
  43. data/lib/clavis/security/https_enforcer.rb +90 -0
  44. data/lib/clavis/security/input_validator.rb +192 -0
  45. data/lib/clavis/security/parameter_filter.rb +64 -0
  46. data/lib/clavis/security/rate_limiter.rb +109 -0
  47. data/lib/clavis/security/redirect_uri_validator.rb +124 -0
  48. data/lib/clavis/security/session_manager.rb +220 -0
  49. data/lib/clavis/security/token_storage.rb +114 -0
  50. data/lib/clavis/user_info_normalizer.rb +74 -0
  51. data/lib/clavis/utils/nonce_store.rb +14 -0
  52. data/lib/clavis/utils/secure_token.rb +17 -0
  53. data/lib/clavis/utils/state_store.rb +18 -0
  54. data/lib/clavis/version.rb +6 -0
  55. data/lib/clavis/view_helpers.rb +260 -0
  56. data/lib/clavis.rb +132 -0
  57. data/lib/generators/clavis/controller/controller_generator.rb +48 -0
  58. data/lib/generators/clavis/controller/templates/controller.rb.tt +137 -0
  59. data/lib/generators/clavis/controller/templates/views/login.html.erb.tt +145 -0
  60. data/lib/generators/clavis/install_generator.rb +182 -0
  61. data/lib/generators/clavis/templates/add_oauth_to_users.rb +28 -0
  62. data/lib/generators/clavis/templates/clavis.css +133 -0
  63. data/lib/generators/clavis/templates/initializer.rb +47 -0
  64. data/lib/generators/clavis/templates/initializer.rb.tt +76 -0
  65. data/lib/generators/clavis/templates/migration.rb +18 -0
  66. data/lib/generators/clavis/templates/migration.rb.tt +16 -0
  67. data/lib/generators/clavis/user_method/user_method_generator.rb +219 -0
  68. data/lib/tasks/provider_verification.rake +77 -0
  69. data/llms.md +487 -0
  70. data/log/development.log +20 -0
  71. data/log/test.log +0 -0
  72. data/sig/clavis.rbs +4 -0
  73. data/testing_plan.md +710 -0
  74. 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