dlnk_auth 0.1.0

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +197 -0
  4. data/Rakefile +15 -0
  5. data/app/controllers/concerns/dlnk_auth/authentication/via_magic_link.rb +67 -0
  6. data/app/controllers/concerns/dlnk_auth/authentication.rb +92 -0
  7. data/app/controllers/concerns/dlnk_auth/impersonation.rb +47 -0
  8. data/app/controllers/dlnk_auth/application_controller.rb +7 -0
  9. data/app/controllers/dlnk_auth/impersonations_controller.rb +25 -0
  10. data/app/controllers/dlnk_auth/magic_links_controller.rb +63 -0
  11. data/app/controllers/dlnk_auth/sessions_controller.rb +41 -0
  12. data/app/mailers/dlnk_auth/application_mailer.rb +8 -0
  13. data/app/mailers/dlnk_auth/magic_link_mailer.rb +15 -0
  14. data/app/models/concerns/dlnk_auth/authenticatable.rb +25 -0
  15. data/app/models/dlnk_auth/application_record.rb +7 -0
  16. data/app/models/dlnk_auth/current.rb +7 -0
  17. data/app/models/dlnk_auth/magic_link.rb +55 -0
  18. data/app/models/dlnk_auth/session.rb +16 -0
  19. data/app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.html.erb +7 -0
  20. data/app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.text.erb +7 -0
  21. data/app/views/dlnk_auth/magic_links/show.html.erb +20 -0
  22. data/app/views/dlnk_auth/sessions/new.html.erb +10 -0
  23. data/app/views/layouts/dlnk_auth/application.html.erb +22 -0
  24. data/app/views/layouts/mailer.html.erb +12 -0
  25. data/config/locales/en.yml +15 -0
  26. data/config/routes.rb +7 -0
  27. data/db/migrate/20260215000001_create_dlnk_auth_sessions.rb +10 -0
  28. data/db/migrate/20260215000002_create_dlnk_auth_magic_links.rb +13 -0
  29. data/db/migrate/20260215000005_add_expires_at_index_to_dlnk_auth_magic_links.rb +5 -0
  30. data/lib/dlnk_auth/code.rb +24 -0
  31. data/lib/dlnk_auth/configuration.rb +75 -0
  32. data/lib/dlnk_auth/engine.rb +23 -0
  33. data/lib/dlnk_auth/middleware/tenant_resolver.rb +65 -0
  34. data/lib/dlnk_auth/version.rb +5 -0
  35. data/lib/dlnk_auth.rb +23 -0
  36. data/lib/generators/dlnk_auth/install/install_generator.rb +33 -0
  37. data/lib/generators/dlnk_auth/install/templates/initializer.rb.tt +26 -0
  38. data/lib/generators/dlnk_auth/views/views_generator.rb +20 -0
  39. metadata +92 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c0d19743950394185b34f027606b621b9ed289ec80d37ff3086ad761c8869f3c
4
+ data.tar.gz: d3913fffd2763cc6aa0b79ce9a324d3433214a14541503317c99eb01052002ad
5
+ SHA512:
6
+ metadata.gz: 11bdc33693539a8b731ee7d1969178003e0ca63caf6cf04e6c837e1ff50a6b68e56bbf9fe6cd2c89f255ab605dd4439ab073975a6845a4209da567a5f3802a1c
7
+ data.tar.gz: 7659d49342261e67ca7074dfbbc19fc5344fe3f24874c0a86bcbcd36bf42517c91c2da3368e08aabefe0a457538dfac70dd0af663198ec78bde0686c58b706bc
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DALiNK
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # DlnkAuth
2
+
3
+ Passwordless magic link authentication engine for Rails.
4
+
5
+ DlnkAuth provides a complete sign-in flow using one-time codes and magic links, with opt-in modules for impersonation and multi-tenancy. It is designed as a Rails Engine that mounts into your application.
6
+
7
+ ## Installation
8
+
9
+ Add the gem to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "dlnk_auth"
13
+ ```
14
+
15
+ Run the install generator:
16
+
17
+ ```bash
18
+ bundle install
19
+ rails generate dlnk_auth:install
20
+ rails db:migrate
21
+ ```
22
+
23
+ The generator will:
24
+
25
+ 1. Copy an initializer to `config/initializers/dlnk_auth.rb`
26
+ 2. Copy migrations for sessions and magic links
27
+ 3. Add the engine route to `config/routes.rb`
28
+
29
+ ## Setup
30
+
31
+ ### User model
32
+
33
+ Include the `Authenticatable` concern in your User model:
34
+
35
+ ```ruby
36
+ class User < ApplicationRecord
37
+ include DlnkAuth::Authenticatable
38
+ end
39
+ ```
40
+
41
+ This adds:
42
+
43
+ - `has_many :dlnk_auth_sessions` and `has_many :dlnk_auth_magic_links`
44
+ - Email normalization (strip + downcase)
45
+ - Email presence and format validation
46
+ - `#send_magic_link` instance method
47
+
48
+ ### Application controller
49
+
50
+ Include the `Authentication` concern in your ApplicationController:
51
+
52
+ ```ruby
53
+ class ApplicationController < ActionController::Base
54
+ include DlnkAuth::Authentication
55
+ end
56
+ ```
57
+
58
+ This provides:
59
+
60
+ - `before_action :require_authentication` (applied by default)
61
+ - `authenticated?` and `current_user` helper methods
62
+ - `allow_unauthenticated_access` and `require_unauthenticated_access` class methods
63
+ - Session management via signed cookies
64
+
65
+ For controllers that should be accessible without sign-in:
66
+
67
+ ```ruby
68
+ class PublicController < ApplicationController
69
+ allow_unauthenticated_access
70
+ end
71
+ ```
72
+
73
+ ## Configuration
74
+
75
+ Edit `config/initializers/dlnk_auth.rb`:
76
+
77
+ ```ruby
78
+ DlnkAuth.configure do |c|
79
+ # The user model class name
80
+ c.user_class = "User"
81
+
82
+ # Attribute used for email lookup
83
+ c.email_attribute = :email
84
+
85
+ # Length of one-time codes (Base32 Crockford)
86
+ c.code_length = 6
87
+
88
+ # How long magic links remain valid
89
+ c.magic_link_ttl = 15.minutes
90
+
91
+ # How long sessions last
92
+ c.session_ttl = 2.weeks
93
+
94
+ # Redirect paths after sign in / sign out
95
+ c.after_sign_in_path = :root_path
96
+ c.after_sign_out_path = :root_path
97
+
98
+ # From address for magic link emails
99
+ c.mailer_sender = "noreply@example.com"
100
+
101
+ # Rate limiting for session creation attempts
102
+ c.rate_limit_sessions = { to: 10, within: 3.minutes }
103
+
104
+ # Rate limiting for code verification attempts
105
+ c.rate_limit_codes = { to: 10, within: 15.minutes }
106
+ end
107
+ ```
108
+
109
+ ## Routes
110
+
111
+ The engine mounts at `/auth` by default and provides:
112
+
113
+ | Method | Path | Description |
114
+ |----------|-------------------|--------------------------------|
115
+ | `GET` | `/auth/session/new` | Sign-in form (enter email) |
116
+ | `POST` | `/auth/session` | Create session (submit email) |
117
+ | `DELETE` | `/auth/session` | Sign out |
118
+ | `GET` | `/auth/magic_link` | Verify magic link token |
119
+ | `POST` | `/auth/magic_link` | Verify one-time code |
120
+
121
+ ## Impersonation (opt-in)
122
+
123
+ Enable impersonation in the initializer:
124
+
125
+ ```ruby
126
+ DlnkAuth.configure do |c|
127
+ c.impersonation.enabled = true
128
+ end
129
+ ```
130
+
131
+ When enabled, the `DlnkAuth::Impersonation` concern is automatically included in `ActionController::Base`. It provides:
132
+
133
+ - `impersonate_user(user)` -- start impersonating
134
+ - `stop_impersonating` -- return to your real account
135
+ - `true_user` -- the originally signed-in user
136
+ - `impersonating?` -- whether impersonation is active
137
+
138
+ The engine exposes two impersonation routes:
139
+
140
+ | Method | Path | Description |
141
+ |----------|-------------------------------|----------------------|
142
+ | `POST` | `/auth/impersonation/:user_id` | Start impersonating |
143
+ | `DELETE` | `/auth/impersonation` | Stop impersonating |
144
+
145
+ The impersonation controller requires the current user to respond to `superadmin?` returning `true`. Add this method to your User model:
146
+
147
+ ```ruby
148
+ class User < ApplicationRecord
149
+ include DlnkAuth::Authenticatable
150
+
151
+ def superadmin?
152
+ # your logic here
153
+ role == "superadmin"
154
+ end
155
+ end
156
+ ```
157
+
158
+ ## Multi-tenancy (opt-in)
159
+
160
+ Enable multi-tenancy in the initializer:
161
+
162
+ ```ruby
163
+ DlnkAuth.configure do |c|
164
+ c.tenanting.enabled = true
165
+ c.tenanting.tenant_class = "Tenant"
166
+ c.tenanting.slug_attribute = :slug
167
+ c.tenanting.skip_paths = %w[/auth /assets /rails /up /cable]
168
+ end
169
+ ```
170
+
171
+ When enabled, the `DlnkAuth::Middleware::TenantResolver` Rack middleware is inserted automatically. It resolves the tenant from the first URL segment (e.g., `/acme/dashboard` resolves tenant with slug `acme`) and sets `DlnkAuth::Current.tenant`.
172
+
173
+ Paths listed in `skip_paths` bypass tenant resolution.
174
+
175
+ You can also provide a custom resolver:
176
+
177
+ ```ruby
178
+ c.tenanting.tenant_resolver = ->(request) {
179
+ if request.path_info =~ %r{\A/([^/]+)}
180
+ Tenant.find_by(subdomain: $1)
181
+ end
182
+ }
183
+ ```
184
+
185
+ ## View Customization
186
+
187
+ Copy the engine views into your application for customization:
188
+
189
+ ```bash
190
+ rails generate dlnk_auth:views
191
+ ```
192
+
193
+ This copies all views to `app/views/dlnk_auth/` where you can modify them freely.
194
+
195
+ ## License
196
+
197
+ Released under the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+ require "rake/testtask"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+ load "rails/tasks/statistics.rake"
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test"
11
+ t.pattern = "test/**/*_test.rb"
12
+ t.verbose = false
13
+ end
14
+
15
+ task default: :test
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Authentication
5
+ module ViaMagicLink
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :email_address_pending_authentication
10
+ after_action :ensure_development_magic_link_not_leaked
11
+ end
12
+
13
+ private
14
+ def ensure_development_magic_link_not_leaked
15
+ unless Rails.env.development? || Rails.env.test?
16
+ raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
17
+ end
18
+ end
19
+
20
+ def redirect_to_magic_link_verification(magic_link)
21
+ serve_development_magic_link(magic_link)
22
+ set_pending_authentication_token(magic_link)
23
+ redirect_to dlnk_auth.magic_link_path, notice: I18n.t("dlnk_auth.magic_links.code_sent")
24
+ end
25
+
26
+ def redirect_to_fake_magic_link_verification(email)
27
+ fake = DlnkAuth::MagicLink.new(
28
+ user: user_class.new(DlnkAuth.configuration.email_attribute => email),
29
+ code: DlnkAuth::Code.generate(DlnkAuth.configuration.code_length),
30
+ expires_at: DlnkAuth.configuration.magic_link_ttl.from_now
31
+ )
32
+ redirect_to_magic_link_verification(fake)
33
+ end
34
+
35
+ def serve_development_magic_link(magic_link)
36
+ if Rails.env.development? && magic_link.present?
37
+ flash[:magic_link_code] = magic_link.code
38
+ end
39
+ end
40
+
41
+ def set_pending_authentication_token(magic_link)
42
+ email = magic_link.user.public_send(DlnkAuth.configuration.email_attribute)
43
+ cookies[:pending_authentication_token] = {
44
+ value: pending_authentication_token_verifier.generate(email, expires_at: magic_link.expires_at),
45
+ httponly: true, same_site: :lax, expires: magic_link.expires_at
46
+ }
47
+ end
48
+
49
+ def email_address_pending_authentication
50
+ return @_email_pending_auth if defined?(@_email_pending_auth)
51
+ @_email_pending_auth = pending_authentication_token_verifier.verified(pending_authentication_token)
52
+ end
53
+
54
+ def pending_authentication_token_verifier
55
+ Rails.application.message_verifier(:pending_authentication)
56
+ end
57
+
58
+ def pending_authentication_token
59
+ cookies[:pending_authentication_token]
60
+ end
61
+
62
+ def clear_pending_authentication_token
63
+ cookies.delete(:pending_authentication_token)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Authentication
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :require_authentication
9
+ helper_method :authenticated?, :current_user, :current_tenant
10
+ include DlnkAuth::Authentication::ViaMagicLink
11
+ end
12
+
13
+ class_methods do
14
+ def require_unauthenticated_access(**options)
15
+ allow_unauthenticated_access(**options)
16
+ before_action :redirect_authenticated_user, **options
17
+ end
18
+
19
+ def allow_unauthenticated_access(**options)
20
+ skip_before_action :require_authentication, **options
21
+ before_action :resume_session, **options
22
+ end
23
+ end
24
+
25
+ private
26
+ def authenticated?
27
+ DlnkAuth::Current.session.present?
28
+ end
29
+
30
+ def current_user
31
+ DlnkAuth::Current.user
32
+ end
33
+
34
+ def current_tenant
35
+ DlnkAuth::Current.tenant
36
+ end
37
+
38
+ def require_authentication
39
+ resume_session || request_authentication
40
+ end
41
+
42
+ def resume_session
43
+ if (session_record = find_session_by_cookie)
44
+ set_current_session(session_record)
45
+ end
46
+ end
47
+
48
+ def find_session_by_cookie
49
+ DlnkAuth::Session.find_signed(cookies[:session_token])
50
+ end
51
+
52
+ def request_authentication
53
+ session[:return_to_after_authenticating] = request.fullpath
54
+ redirect_to dlnk_auth.new_session_path
55
+ end
56
+
57
+ def after_authentication_url
58
+ url = session.delete(:return_to_after_authenticating)
59
+ if url.present? && url.start_with?("/") && !url.start_with?("//")
60
+ url
61
+ else
62
+ main_app.send(DlnkAuth.configuration.after_sign_in_path)
63
+ end
64
+ end
65
+
66
+ def redirect_authenticated_user
67
+ redirect_to after_authentication_url if authenticated?
68
+ end
69
+
70
+ def start_new_session_for(user)
71
+ user.dlnk_auth_sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session_record|
72
+ set_current_session(session_record)
73
+ end
74
+ end
75
+
76
+ def set_current_session(session_record)
77
+ DlnkAuth::Current.session = session_record
78
+ DlnkAuth::Current.user = session_record.user
79
+ ttl = DlnkAuth.configuration.session_ttl
80
+ cookies[:session_token] = { value: session_record.signed_id(expires_in: ttl), httponly: true, same_site: :lax, expires: ttl&.from_now }
81
+ end
82
+
83
+ def terminate_session
84
+ DlnkAuth::Current.session&.destroy
85
+ cookies.delete(:session_token)
86
+ end
87
+
88
+ def user_class
89
+ DlnkAuth.configuration.user_class.constantize
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Impersonation
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ helper_method :true_user, :impersonating?
9
+ end
10
+
11
+ private
12
+ def impersonate_user(user)
13
+ session[:dlnk_auth_impersonated_user_id] = user.id
14
+ DlnkAuth::Current.user = user
15
+ end
16
+
17
+ def stop_impersonating
18
+ session.delete(:dlnk_auth_impersonated_user_id)
19
+ DlnkAuth::Current.user = true_user
20
+ end
21
+
22
+ def true_user
23
+ if session[:dlnk_auth_impersonated_user_id].present?
24
+ @_true_user ||= DlnkAuth::Current.session&.user
25
+ else
26
+ current_user
27
+ end
28
+ end
29
+
30
+ def impersonating?
31
+ session[:dlnk_auth_impersonated_user_id].present? && true_user != current_user
32
+ end
33
+
34
+ def current_user
35
+ if session[:dlnk_auth_impersonated_user_id].present?
36
+ @_impersonated_user ||= user_class.find_by(id: session[:dlnk_auth_impersonated_user_id])
37
+ else
38
+ DlnkAuth::Current.user
39
+ end
40
+ end
41
+
42
+ def require_superadmin
43
+ authorizer = DlnkAuth.configuration.impersonation.authorize_with
44
+ head :forbidden unless true_user && authorizer.call(true_user)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class ApplicationController < ActionController::Base
5
+ include DlnkAuth::Authentication
6
+ end
7
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class ImpersonationsController < ApplicationController
5
+ include DlnkAuth::Impersonation
6
+ before_action :require_impersonation_enabled
7
+ before_action :require_superadmin, only: :create
8
+
9
+ def create
10
+ user = user_class.find(params[:user_id])
11
+ impersonate_user(user)
12
+ redirect_back fallback_location: main_app.root_path
13
+ end
14
+
15
+ def destroy
16
+ stop_impersonating
17
+ redirect_back fallback_location: main_app.root_path
18
+ end
19
+
20
+ private
21
+ def require_impersonation_enabled
22
+ head :not_found unless DlnkAuth.configuration.impersonation.enabled
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class MagicLinksController < ApplicationController
5
+ require_unauthenticated_access
6
+
7
+ # Rate limit per IP (default) — prevents single-source brute force
8
+ rate_limit to: DlnkAuth.configuration.rate_limit_codes[:to],
9
+ within: DlnkAuth.configuration.rate_limit_codes[:within],
10
+ only: :create,
11
+ name: "code-verification-per-ip",
12
+ with: :rate_limit_exceeded
13
+
14
+ # Rate limit per email — prevents distributed brute force across multiple IPs
15
+ rate_limit to: DlnkAuth.configuration.rate_limit_codes[:to],
16
+ within: DlnkAuth.configuration.rate_limit_codes[:within],
17
+ by: -> { email_address_pending_authentication || request.remote_ip },
18
+ only: :create,
19
+ name: "code-verification-per-email",
20
+ with: :rate_limit_exceeded
21
+
22
+ before_action :ensure_pending_authentication
23
+
24
+ def show
25
+ end
26
+
27
+ def create
28
+ pending_email = email_address_pending_authentication
29
+ if pending_email.present? && (magic_link = DlnkAuth::MagicLink.consume(code, email: pending_email))
30
+ authenticate(magic_link)
31
+ else
32
+ redirect_to magic_link_path, alert: I18n.t("dlnk_auth.magic_links.invalid_code")
33
+ end
34
+ end
35
+
36
+ private
37
+ def ensure_pending_authentication
38
+ unless email_address_pending_authentication.present?
39
+ redirect_to new_session_path, alert: I18n.t("dlnk_auth.magic_links.email_first")
40
+ end
41
+ end
42
+
43
+ def code
44
+ params[:code]
45
+ end
46
+
47
+ def authenticate(magic_link)
48
+ email_attr = DlnkAuth.configuration.email_attribute
49
+ if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.user.public_send(email_attr))
50
+ clear_pending_authentication_token
51
+ start_new_session_for(magic_link.user)
52
+ redirect_to after_authentication_url, notice: I18n.t("dlnk_auth.magic_links.signed_in")
53
+ else
54
+ clear_pending_authentication_token
55
+ redirect_to new_session_path, alert: I18n.t("dlnk_auth.magic_links.email_mismatch")
56
+ end
57
+ end
58
+
59
+ def rate_limit_exceeded
60
+ redirect_to magic_link_path, alert: I18n.t("dlnk_auth.magic_links.rate_limited")
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class SessionsController < ApplicationController
5
+ require_unauthenticated_access except: :destroy
6
+ rate_limit to: DlnkAuth.configuration.rate_limit_sessions[:to],
7
+ within: DlnkAuth.configuration.rate_limit_sessions[:within],
8
+ only: :create,
9
+ with: :rate_limit_exceeded
10
+
11
+ def new
12
+ end
13
+
14
+ def create
15
+ if (user = user_class.find_by(email_attribute => email_address))
16
+ magic_link = user.send_magic_link
17
+ redirect_to_magic_link_verification(magic_link)
18
+ else
19
+ redirect_to_fake_magic_link_verification(email_address)
20
+ end
21
+ end
22
+
23
+ def destroy
24
+ terminate_session
25
+ redirect_to main_app.send(DlnkAuth.configuration.after_sign_out_path), notice: I18n.t("dlnk_auth.sessions.signed_out")
26
+ end
27
+
28
+ private
29
+ def email_address
30
+ params[:email_address]
31
+ end
32
+
33
+ def email_attribute
34
+ DlnkAuth.configuration.email_attribute
35
+ end
36
+
37
+ def rate_limit_exceeded
38
+ redirect_to new_session_path, alert: I18n.t("dlnk_auth.sessions.rate_limited")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: -> { DlnkAuth.configuration.mailer_sender }
6
+ layout "mailer"
7
+ end
8
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class MagicLinkMailer < ApplicationMailer
5
+ def sign_in_instructions(user, email, code)
6
+ @user = user
7
+ @code = code
8
+
9
+ mail(
10
+ to: email,
11
+ subject: I18n.t("dlnk_auth.mailer.sign_in_instructions.subject")
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Authenticatable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :dlnk_auth_sessions, class_name: "DlnkAuth::Session", as: :user, dependent: :destroy
9
+ has_many :dlnk_auth_magic_links, class_name: "DlnkAuth::MagicLink", as: :user, dependent: :destroy
10
+
11
+ normalizes DlnkAuth.configuration.email_attribute, with: ->(email) { email.strip.downcase }
12
+
13
+ validates DlnkAuth.configuration.email_attribute,
14
+ presence: true,
15
+ format: { with: URI::MailTo::EMAIL_REGEXP }
16
+ end
17
+
18
+ def send_magic_link
19
+ email_value = public_send(DlnkAuth.configuration.email_attribute)
20
+ magic_link = dlnk_auth_magic_links.create!(email: email_value)
21
+ DlnkAuth::MagicLinkMailer.sign_in_instructions(self, email_value, magic_link.code).deliver_later
22
+ magic_link
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :session, :user, :tenant
6
+ end
7
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module DlnkAuth
6
+ class MagicLink < ApplicationRecord
7
+ attr_accessor :code
8
+
9
+ belongs_to :user, polymorphic: true
10
+
11
+ scope :active, -> { where(expires_at: Time.current...) }
12
+ scope :stale, -> { where(expires_at: ..Time.current) }
13
+
14
+ before_validation :generate_code_digest, on: :create
15
+ before_validation :set_expiration, on: :create
16
+
17
+ validates :code_digest, uniqueness: true, presence: true
18
+ validates :email, presence: true
19
+
20
+ class << self
21
+ def consume(code, email:)
22
+ sanitized = DlnkAuth::Code.sanitize(code)
23
+ return nil unless sanitized.present?
24
+
25
+ digest = Digest::SHA256.hexdigest(sanitized)
26
+ transaction do
27
+ active.where(email: email).lock.includes(:user).find_by(code_digest: digest)&.consume
28
+ end
29
+ end
30
+
31
+ def cleanup
32
+ stale.delete_all
33
+ end
34
+ end
35
+
36
+ def consume
37
+ destroy
38
+ self
39
+ end
40
+
41
+ private
42
+ def generate_code_digest
43
+ self.code ||= loop do
44
+ candidate = DlnkAuth::Code.generate(DlnkAuth.configuration.code_length)
45
+ digest = Digest::SHA256.hexdigest(candidate)
46
+ break candidate unless self.class.exists?(code_digest: digest)
47
+ end
48
+ self.code_digest = Digest::SHA256.hexdigest(self.code)
49
+ end
50
+
51
+ def set_expiration
52
+ self.expires_at ||= DlnkAuth.configuration.magic_link_ttl.from_now
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class Session < ApplicationRecord
5
+ belongs_to :user, polymorphic: true
6
+
7
+ scope :stale, -> {
8
+ ttl = DlnkAuth.configuration.session_ttl
9
+ ttl ? where(created_at: ..ttl.ago) : none
10
+ }
11
+
12
+ def self.cleanup
13
+ stale.delete_all
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ <h2>Your sign in code</h2>
2
+ <p>Your code is:</p>
3
+ <p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; text-align: center; padding: 16px; background: #f3f4f6; border-radius: 8px; font-family: monospace;">
4
+ <%= @code %>
5
+ </p>
6
+ <p>This code expires in <%= distance_of_time_in_words(DlnkAuth.configuration.magic_link_ttl) %>.</p>
7
+ <p>If you didn't request this, please ignore this email.</p>
@@ -0,0 +1,7 @@
1
+ Your sign in code
2
+
3
+ Your code is: <%= @code %>
4
+
5
+ This code expires in <%= distance_of_time_in_words(DlnkAuth.configuration.magic_link_ttl) %>.
6
+
7
+ If you didn't request this, please ignore this email.
@@ -0,0 +1,20 @@
1
+ <div style="max-width: 400px; margin: 100px auto; padding: 20px;">
2
+ <h1>Enter your code</h1>
3
+ <p>We sent a code to <strong><%= email_address_pending_authentication %></strong></p>
4
+ <% if flash[:magic_link_code] %>
5
+ <div style="margin: 16px 0; padding: 12px; background: #f0f0f0; border-radius: 8px; text-align: center;">
6
+ <small>Dev code:</small><br>
7
+ <strong style="font-size: 24px; letter-spacing: 8px; font-family: monospace;"><%= flash[:magic_link_code] %></strong>
8
+ </div>
9
+ <% end %>
10
+ <%= form_with url: dlnk_auth.magic_link_path, method: :post do |f| %>
11
+ <div style="margin-bottom: 16px;">
12
+ <%= f.label :code, "Code" %><br>
13
+ <%= f.text_field :code, required: true, autofocus: true, autocomplete: "one-time-code", maxlength: DlnkAuth.configuration.code_length, style: "width: 100%; padding: 8px; text-align: center; font-size: 20px; letter-spacing: 8px; font-family: monospace; text-transform: uppercase;" %>
14
+ </div>
15
+ <%= f.submit "Verify", style: "width: 100%; padding: 10px; cursor: pointer;" %>
16
+ <% end %>
17
+ <p style="margin-top: 16px; text-align: center;">
18
+ <%= link_to "Back to email", dlnk_auth.new_session_path %>
19
+ </p>
20
+ </div>
@@ -0,0 +1,10 @@
1
+ <div style="max-width: 400px; margin: 100px auto; padding: 20px;">
2
+ <h1>Sign in</h1>
3
+ <%= form_with url: dlnk_auth.session_path, method: :post do |f| %>
4
+ <div style="margin-bottom: 16px;">
5
+ <%= f.label :email_address, "Email address" %><br>
6
+ <%= f.email_field :email_address, required: true, autofocus: true, autocomplete: "email", style: "width: 100%; padding: 8px;" %>
7
+ </div>
8
+ <%= f.submit "Send sign in code", style: "width: 100%; padding: 10px; cursor: pointer;" %>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sign in</title>
7
+ <%= csrf_meta_tags %>
8
+ </head>
9
+ <body>
10
+ <% if flash[:alert] %>
11
+ <div style="max-width: 400px; margin: 16px auto; padding: 12px; background: #fee; border: 1px solid #fcc; border-radius: 4px; color: #c00;">
12
+ <%= flash[:alert] %>
13
+ </div>
14
+ <% end %>
15
+ <% if flash[:notice] %>
16
+ <div style="max-width: 400px; margin: 16px auto; padding: 12px; background: #efe; border: 1px solid #cfc; border-radius: 4px; color: #0a0;">
17
+ <%= flash[:notice] %>
18
+ </div>
19
+ <% end %>
20
+ <%= yield %>
21
+ </body>
22
+ </html>
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
5
+ <style>
6
+ /* Email styles */
7
+ </style>
8
+ </head>
9
+ <body>
10
+ <%= yield %>
11
+ </body>
12
+ </html>
@@ -0,0 +1,15 @@
1
+ en:
2
+ dlnk_auth:
3
+ sessions:
4
+ rate_limited: "Too many requests. Try again later."
5
+ signed_out: "You have been signed out."
6
+ magic_links:
7
+ rate_limited: "Too many attempts. Try again later."
8
+ invalid_code: "Invalid or expired code. Please try again."
9
+ email_first: "Please enter your email first."
10
+ email_mismatch: "Email mismatch. Please try again."
11
+ signed_in: "Signed in successfully."
12
+ code_sent: "If this email address is registered, you will receive a sign-in code shortly."
13
+ mailer:
14
+ sign_in_instructions:
15
+ subject: "Your sign in code"
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ DlnkAuth::Engine.routes.draw do
2
+ resource :session, only: [ :new, :create, :destroy ]
3
+ resource :magic_link, only: [ :show, :create ]
4
+
5
+ post "impersonation/:user_id", to: "impersonations#create", as: :impersonation
6
+ delete "impersonation", to: "impersonations#destroy"
7
+ end
@@ -0,0 +1,10 @@
1
+ class CreateDlnkAuthSessions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dlnk_auth_sessions do |t|
4
+ t.references :user, null: false, polymorphic: true
5
+ t.string :ip_address
6
+ t.string :user_agent
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ class CreateDlnkAuthMagicLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dlnk_auth_magic_links do |t|
4
+ t.references :user, null: false, polymorphic: true
5
+ t.string :email, null: false
6
+ t.string :code_digest, null: false
7
+ t.datetime :expires_at, null: false
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :dlnk_auth_magic_links, :code_digest, unique: true
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ class AddExpiresAtIndexToDlnkAuthMagicLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_index :dlnk_auth_magic_links, :expires_at
4
+ end
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module DlnkAuth
6
+ module Code
7
+ BASE32_ALPHABET = %w[0 1 2 3 4 5 6 7 8 9 A B C D E F G H J K M N P Q R S T V W X Y Z].freeze
8
+ SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
9
+
10
+ class << self
11
+ def generate(length)
12
+ Array.new(length) { BASE32_ALPHABET[SecureRandom.random_number(BASE32_ALPHABET.size)] }.join
13
+ end
14
+
15
+ def sanitize(code)
16
+ if code.present?
17
+ code.to_s.upcase
18
+ .then { |c| SUBSTITUTIONS.reduce(c) { |result, (from, to)| result.gsub(from, to) } }
19
+ .then { |c| c.gsub(/[^#{BASE32_ALPHABET.join}]/, "") }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class Configuration
5
+ attr_accessor :user_class, :email_attribute, :code_length,
6
+ :magic_link_ttl, :session_ttl,
7
+ :after_sign_in_path, :after_sign_out_path,
8
+ :mailer_sender,
9
+ :rate_limit_sessions, :rate_limit_codes
10
+
11
+ attr_reader :impersonation, :tenanting
12
+
13
+ def initialize
14
+ @user_class = "User"
15
+ @email_attribute = :email
16
+ @code_length = 6
17
+ @magic_link_ttl = 15.minutes
18
+ @session_ttl = 2.weeks
19
+ @after_sign_in_path = :root_path
20
+ @after_sign_out_path = :root_path
21
+ @mailer_sender = "noreply@example.com"
22
+ @rate_limit_sessions = { to: 10, within: 3.minutes }
23
+ @rate_limit_codes = { to: 10, within: 15.minutes }
24
+ @impersonation = ImpersonationConfig.new
25
+ @tenanting = TenantingConfig.new
26
+ end
27
+
28
+ def initialize_copy(original)
29
+ super
30
+ @impersonation = original.impersonation.dup
31
+ @tenanting = original.tenanting.dup
32
+ end
33
+
34
+ def validate!
35
+ raise ArgumentError, "user_class must be a String" unless user_class.is_a?(String)
36
+ raise ArgumentError, "code_length must be a positive Integer" unless code_length.is_a?(Integer) && code_length > 0
37
+ raise ArgumentError, "email_attribute must be a Symbol" unless email_attribute.is_a?(Symbol)
38
+ raise ArgumentError, "magic_link_ttl must respond to :from_now" unless magic_link_ttl.respond_to?(:from_now)
39
+ raise ArgumentError, "session_ttl must respond to :from_now or be nil" unless session_ttl.nil? || session_ttl.respond_to?(:from_now)
40
+ raise ArgumentError, "mailer_sender must be present" if mailer_sender.blank?
41
+ validate_rate_limit_hash!(:rate_limit_sessions, rate_limit_sessions)
42
+ validate_rate_limit_hash!(:rate_limit_codes, rate_limit_codes)
43
+ raise ArgumentError, "impersonation.authorize_with must respond to :call" unless impersonation.authorize_with.respond_to?(:call)
44
+ end
45
+
46
+ class ImpersonationConfig
47
+ attr_accessor :enabled, :authorize_with
48
+
49
+ def initialize
50
+ @enabled = false
51
+ @authorize_with = ->(user) { user.superadmin? }
52
+ end
53
+ end
54
+
55
+ class TenantingConfig
56
+ attr_accessor :enabled, :tenant_class, :slug_attribute,
57
+ :skip_paths, :tenant_resolver
58
+
59
+ def initialize
60
+ @enabled = false
61
+ @tenant_class = "Tenant"
62
+ @slug_attribute = :slug
63
+ @skip_paths = %w[/auth /assets /rails /up /cable]
64
+ @tenant_resolver = nil
65
+ end
66
+ end
67
+
68
+ private
69
+ def validate_rate_limit_hash!(name, value)
70
+ unless value.is_a?(Hash) && value.key?(:to) && value.key?(:within)
71
+ raise ArgumentError, "#{name} must be a Hash with :to and :within keys"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace DlnkAuth
6
+
7
+ initializer "dlnk_auth.middleware" do |app|
8
+ if DlnkAuth.configuration.tenanting.enabled
9
+ app.middleware.insert_after Rack::TempfileReaper, DlnkAuth::Middleware::TenantResolver
10
+ end
11
+ end
12
+
13
+ initializer "dlnk_auth.configuration_validation", after: :load_config_initializers do
14
+ DlnkAuth.configuration.validate!
15
+ end
16
+
17
+ initializer "dlnk_auth.i18n" do
18
+ DlnkAuth::Engine.root.glob("config/locales/**/*.yml").each do |locale|
19
+ I18n.load_path << locale unless I18n.load_path.include?(locale)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Middleware
5
+ class TenantResolver
6
+ SLUG_PATTERN = %r{\A/([a-z0-9][a-z0-9\-]*[a-z0-9]|[a-z0-9])}
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ config = DlnkAuth.configuration.tenanting
14
+ return @app.call(env) unless config.enabled
15
+
16
+ request = ActionDispatch::Request.new(env)
17
+ path = request.path_info
18
+
19
+ return @app.call(env) if skip_path?(path, config)
20
+
21
+ tenant = resolve_tenant(request, config)
22
+
23
+ if tenant
24
+ rewrite_path(request, path)
25
+ DlnkAuth::Current.tenant = tenant
26
+ end
27
+
28
+ @app.call(env)
29
+ ensure
30
+ DlnkAuth::Current.reset if config&.enabled
31
+ end
32
+
33
+ private
34
+ def skip_path?(path, config)
35
+ return true if path == "/"
36
+ config.skip_paths.any? { |prefix| path.start_with?(prefix) }
37
+ end
38
+
39
+ def resolve_tenant(request, config)
40
+ if config.tenant_resolver
41
+ config.tenant_resolver.call(request)
42
+ else
43
+ resolve_by_slug(request.path_info, config)
44
+ end
45
+ end
46
+
47
+ def resolve_by_slug(path, config)
48
+ if (match = path.match(SLUG_PATTERN))
49
+ slug = match[1].downcase
50
+ tenant_class = config.tenant_class.constantize
51
+ tenant_class.find_by(config.slug_attribute => slug)
52
+ end
53
+ end
54
+
55
+ def rewrite_path(request, path)
56
+ if (match = path.match(SLUG_PATTERN))
57
+ matched = match[0]
58
+ request.env["SCRIPT_NAME"] = request.script_name + matched
59
+ remaining = path.delete_prefix(matched)
60
+ request.env["PATH_INFO"] = remaining.empty? ? "/" : remaining
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ VERSION = "0.1.0"
5
+ end
data/lib/dlnk_auth.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dlnk_auth/version"
4
+ require "dlnk_auth/configuration"
5
+ require "dlnk_auth/code"
6
+ require "dlnk_auth/middleware/tenant_resolver"
7
+ require "dlnk_auth/engine"
8
+
9
+ module DlnkAuth
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+
19
+ def reset_configuration!
20
+ @configuration = Configuration.new
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def copy_initializer
9
+ template "initializer.rb.tt", "config/initializers/dlnk_auth.rb"
10
+ end
11
+
12
+ def copy_migrations
13
+ rails_command "dlnk_auth:install:migrations"
14
+ end
15
+
16
+ def add_route
17
+ route 'mount DlnkAuth::Engine => "/auth"'
18
+ end
19
+
20
+ def show_post_install
21
+ say ""
22
+ say "DlnkAuth installed successfully!", :green
23
+ say ""
24
+ say "Next steps:"
25
+ say " 1. Edit config/initializers/dlnk_auth.rb"
26
+ say " 2. Add `include DlnkAuth::Authenticatable` to your User model"
27
+ say " 3. Add `include DlnkAuth::Authentication` to your ApplicationController"
28
+ say " 4. Run `rails db:migrate`"
29
+ say ""
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ DlnkAuth.configure do |c|
4
+ # Core auth
5
+ c.user_class = "User"
6
+ c.email_attribute = :email
7
+ c.code_length = 6
8
+ c.magic_link_ttl = 15.minutes
9
+ c.session_ttl = 2.weeks
10
+ c.after_sign_in_path = :root_path
11
+ c.after_sign_out_path = :root_path
12
+ c.mailer_sender = "noreply@example.com"
13
+
14
+ # Rate limiting
15
+ c.rate_limit_sessions = { to: 10, within: 3.minutes }
16
+ c.rate_limit_codes = { to: 10, within: 15.minutes }
17
+
18
+ # Impersonation (opt-in)
19
+ # c.impersonation.enabled = true
20
+
21
+ # Multi-tenancy (opt-in)
22
+ # c.tenanting.enabled = true
23
+ # c.tenanting.tenant_class = "Tenant"
24
+ # c.tenanting.slug_attribute = :slug
25
+ # c.tenanting.skip_paths = %w[/auth /assets /rails /up /cable]
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DlnkAuth
4
+ module Generators
5
+ class ViewsGenerator < Rails::Generators::Base
6
+ source_root DlnkAuth::Engine.root.join("app/views/dlnk_auth")
7
+
8
+ def copy_views
9
+ directory ".", "app/views/dlnk_auth"
10
+ end
11
+
12
+ def show_info
13
+ say ""
14
+ say "DlnkAuth views copied to app/views/dlnk_auth/", :green
15
+ say "You can now customize the sign-in pages."
16
+ say ""
17
+ end
18
+ end
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dlnk_auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DALiNK
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: A Rails Engine providing passwordless magic link authentication with
27
+ opt-in impersonation and multi-tenancy modules.
28
+ email:
29
+ - contact@dalink.fr
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - MIT-LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - app/controllers/concerns/dlnk_auth/authentication.rb
38
+ - app/controllers/concerns/dlnk_auth/authentication/via_magic_link.rb
39
+ - app/controllers/concerns/dlnk_auth/impersonation.rb
40
+ - app/controllers/dlnk_auth/application_controller.rb
41
+ - app/controllers/dlnk_auth/impersonations_controller.rb
42
+ - app/controllers/dlnk_auth/magic_links_controller.rb
43
+ - app/controllers/dlnk_auth/sessions_controller.rb
44
+ - app/mailers/dlnk_auth/application_mailer.rb
45
+ - app/mailers/dlnk_auth/magic_link_mailer.rb
46
+ - app/models/concerns/dlnk_auth/authenticatable.rb
47
+ - app/models/dlnk_auth/application_record.rb
48
+ - app/models/dlnk_auth/current.rb
49
+ - app/models/dlnk_auth/magic_link.rb
50
+ - app/models/dlnk_auth/session.rb
51
+ - app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.html.erb
52
+ - app/views/dlnk_auth/magic_link_mailer/sign_in_instructions.text.erb
53
+ - app/views/dlnk_auth/magic_links/show.html.erb
54
+ - app/views/dlnk_auth/sessions/new.html.erb
55
+ - app/views/layouts/dlnk_auth/application.html.erb
56
+ - app/views/layouts/mailer.html.erb
57
+ - config/locales/en.yml
58
+ - config/routes.rb
59
+ - db/migrate/20260215000001_create_dlnk_auth_sessions.rb
60
+ - db/migrate/20260215000002_create_dlnk_auth_magic_links.rb
61
+ - db/migrate/20260215000005_add_expires_at_index_to_dlnk_auth_magic_links.rb
62
+ - lib/dlnk_auth.rb
63
+ - lib/dlnk_auth/code.rb
64
+ - lib/dlnk_auth/configuration.rb
65
+ - lib/dlnk_auth/engine.rb
66
+ - lib/dlnk_auth/middleware/tenant_resolver.rb
67
+ - lib/dlnk_auth/version.rb
68
+ - lib/generators/dlnk_auth/install/install_generator.rb
69
+ - lib/generators/dlnk_auth/install/templates/initializer.rb.tt
70
+ - lib/generators/dlnk_auth/views/views_generator.rb
71
+ homepage: https://github.com/dalink/dlnk_auth
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.3.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.1
90
+ specification_version: 4
91
+ summary: Passwordless magic link authentication engine for Rails
92
+ test_files: []