sqrl-rails 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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE +21 -0
  4. data/README.md +196 -0
  5. data/app/controllers/sqrl/rails/application_controller.rb +70 -0
  6. data/app/controllers/sqrl/rails/assets_controller.rb +22 -0
  7. data/app/controllers/sqrl/rails/callback_controller.rb +101 -0
  8. data/app/controllers/sqrl/rails/cli_controller.rb +18 -0
  9. data/app/controllers/sqrl/rails/identities_controller.rb +57 -0
  10. data/app/controllers/sqrl/rails/logins_controller.rb +24 -0
  11. data/app/controllers/sqrl/rails/refresh_controller.rb +22 -0
  12. data/app/controllers/sqrl/rails/status_controller.rb +21 -0
  13. data/app/helpers/sqrl/rails/login_helper.rb +85 -0
  14. data/app/models/sqrl/rails/identity.rb +48 -0
  15. data/app/models/sqrl/rails/nut.rb +13 -0
  16. data/app/models/sqrl/rails/pending_login.rb +15 -0
  17. data/app/views/layouts/sqrl/rails/application.html.erb +18 -0
  18. data/app/views/sqrl/rails/identities/index.html.erb +27 -0
  19. data/app/views/sqrl/rails/logins/new.html.erb +3 -0
  20. data/config/locales/en.yml +11 -0
  21. data/config/routes.rb +12 -0
  22. data/lib/generators/sqrl/install/install_generator.rb +131 -0
  23. data/lib/generators/sqrl/install/templates/create_sqrl_tables.rb.tt +56 -0
  24. data/lib/generators/sqrl/install/templates/initializer.rb.tt +91 -0
  25. data/lib/sqrl/rails/configuration.rb +145 -0
  26. data/lib/sqrl/rails/controller_helpers.rb +37 -0
  27. data/lib/sqrl/rails/engine.rb +42 -0
  28. data/lib/sqrl/rails/password_policy.rb +120 -0
  29. data/lib/sqrl/rails/rate_limited.rb +62 -0
  30. data/lib/sqrl/rails/sign_in.rb +80 -0
  31. data/lib/sqrl/rails/stores/active_record.rb +279 -0
  32. data/lib/sqrl/rails/stores/cache.rb +311 -0
  33. data/lib/sqrl/rails/user_factory.rb +41 -0
  34. data/lib/sqrl/rails.rb +220 -0
  35. data/lib/sqrl-rails.rb +3 -0
  36. data/lib/tasks/sqrl.rake +12 -0
  37. metadata +129 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5e531c844edf1a741273c373f1f310549df1cd2434a371a240ffa1c14eddf885
4
+ data.tar.gz: f01f6e38861b0a286f0ac1138d46750e7fe9336e8c25d067736d2a55420f3901
5
+ SHA512:
6
+ metadata.gz: 38266ffecad16700685b01f50bb189b1c425036392787c8de14867232d24971da0491ef10754cbce43f7699aaa8ed0fabf0980ea5d4c98fe1e074abb721fcf0d
7
+ data.tar.gz: 118424b7a269f1b30c87567475062c63c9cd6c38b3842b42203f311a4f4eddc7a3b77bc34afb8d4458695ddf3ff0a71653092ba6801d60981e2d73777ccecd43
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-20
4
+
5
+ First release.
6
+
7
+ - Isolated engine serving the SQRL endpoint, status poll, CPS/poll callback, login page,
8
+ script, refresh endpoint and identity-management pages.
9
+ - ActiveRecord stores (`sqrl_identities`, `sqrl_nuts`, `sqrl_pending_logins`) with atomic
10
+ single use, plus an `ActiveSupport::Cache` store set.
11
+ - `bin/rails generate sqrl:install`: migration, mount, initializer, user association,
12
+ login block in `sessions/new`, password-policy includes.
13
+ - Sign-in adapter for the Rails 8 `generate authentication` concern, with Warden and plain
14
+ session fallbacks; `:create` / `:link` / `:refuse` policies for unknown identities.
15
+ - `sqrlonly` / `hardlock` enforcement for password login and reset; identity management;
16
+ per-IP rate limiting; `ask` with deny button; `allowed_hosts`, hooks, `prune` task.
17
+ - `insecure_http` testing mode; `poll_requires_ip_match` off by default.
18
+ - Requires Ruby 3.2+ and Rails 7.1, 7.2, 8.0 or 8.1.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel P. Clark
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,196 @@
1
+ # sqrl-rails
2
+
3
+ SQRL login for Rails applications: an isolated engine that serves the SQRL protocol
4
+ endpoint, the status poll, the login callback and the browser script; ActiveRecord
5
+ stores; a view helper that renders the login block; and a sign-in adapter that plugs
6
+ into the authentication stack generated by `bin/rails generate authentication` (Rails 8)
7
+ or into any custom session scheme.
8
+
9
+ Devise applications should use [`devise-sqrl`](../devise-sqrl), which builds on this gem.
10
+
11
+ ## Install
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ gem "sqrl-rails"
16
+ ```
17
+
18
+ ```sh
19
+ bin/rails generate sqrl:install # migration, mount, initializer, User association, login block
20
+ bin/rails db:migrate
21
+ ```
22
+
23
+ The generator:
24
+
25
+ - copies the `create_sqrl_tables` migration (`sqrl_identities`, `sqrl_nuts`,
26
+ `sqrl_pending_logins`);
27
+ - mounts the engine: `mount SQRL::Rails::Engine, at: "/sqrl", as: :sqrl`;
28
+ - writes `config/initializers/sqrl.rb` (every option documented inline);
29
+ - adds `has_many :sqrl_identities` to `app/models/user.rb`;
30
+ - appends `<%= sqrl_login_block %>` to `app/views/sessions/new.html.erb` when that view
31
+ exists (the Rails 8 generated login form).
32
+
33
+ Every step is idempotent. Options: `--host`, `--mount`, `--user-model`,
34
+ `--skip-migration`, `--skip-routes`, `--skip-views`.
35
+
36
+ ## How a login works
37
+
38
+ 1. `sqrl_login_block` issues a `sqrl://host/sqrl/cli?nut=…` URL, remembers the pending
39
+ login in the browser session and renders the link, the QR code and the status text
40
+ that `/sqrl/sqrl.js` drives.
41
+ 2. The SQRL client POSTs its signed `query`/`ident` to `/sqrl/cli`. On success the
42
+ identity is marked authenticated on the pending login and, when the client asked for
43
+ Client Provided Session, a one-time token is handed back in `url=`.
44
+ 3. The browser lands on `/sqrl/callback?t=TOKEN` (CPS) or, after the poller saw
45
+ `{"authenticated": true}` on `/sqrl/status`, on `/sqrl/callback` with its own cookie.
46
+ The callback resolves the user, signs them in and redirects.
47
+
48
+ ## Configuration
49
+
50
+ `config/initializers/sqrl.rb`:
51
+
52
+ ```ruby
53
+ Rails.application.config.sqrl.tap do |sqrl|
54
+ sqrl.host = ENV["SQRL_HOST"].presence # public host for sqrl:// URLs (default: request host)
55
+ sqrl.user_class = "User"
56
+ sqrl.on_unknown_identity = :create # :create | :link | :refuse
57
+ # sqrl.create_user = ->(identity) { User.create!(name: "SQRL user", password: SecureRandom.hex(32)) }
58
+ # sqrl.sign_in = ->(controller, identity, user) { controller.send(:start_new_session_for, user) }
59
+ # sqrl.after_sign_in = ->(controller, completion) { controller.send(:after_authentication_url) }
60
+ # sqrl.failure_path = "/session/new"
61
+ # sqrl.stores = :active_record # or :memory, or an SQRL::Server::Stores
62
+ end
63
+ ```
64
+
65
+ | Option | Default | Meaning |
66
+ | --- | --- | --- |
67
+ | `host` | request host | host placed in the `sqrl://` URLs issued to clients |
68
+ | `insecure_http` | `false` | loopback-only testing mode issuing `qrl://` (plain http) URLs, for trying the browser extension against `bin/rails server`; not part of SQRL, refused for other hosts |
69
+ | `poll_requires_ip_match` | `false` | optional hardening: refuse poll (cookie) completions when the client's IP did not match the browser's |
70
+ | `on_unknown_identity` | `:create` | `:create` a user ({SQRL::Rails::UserFactory}), `:link` to the signed-in user, or `:refuse` |
71
+ | `create_user` | `UserFactory` | `(identity) -> user`; the default fills placeholder columns (random password, `sqrl-…@sqrl.invalid`) |
72
+ | `find_user` | by `user_ref` | `(identity) -> user or nil` |
73
+ | `current_user` | `Current.user`, else `session[:user_id]` | `(controller) -> user` used for `:link` |
74
+ | `sign_in` | `SignIn.default` | `(controller, identity, user)`; default calls `start_new_session_for` when the Rails 8 concern is present, else Warden, else `session[:user_id]` |
75
+ | `after_sign_in` | `after_authentication_url` / root | `(controller, completion) -> url`; a same-site `return_to` wins |
76
+ | `failure_path` | `/sqrl/login` | where a failed callback redirects |
77
+ | `stores` | `:active_record` | storage back end |
78
+ | `nut_ttl`, `cps_token_ttl`, `require_ip_match`, `ask`, `sin`, `path_extension`, `on_authenticated` | see `SQRL::Server::Config` | protocol options |
79
+
80
+ ### Rails 8 `generate authentication`
81
+
82
+ Nothing to configure: when `Authentication` (the generated concern) exists, the
83
+ callback controller includes it and calls `start_new_session_for(user)`, so SQRL logins
84
+ produce the same `Session` rows and cookies as the password form, and
85
+ `after_authentication_url` sends the browser back to the page that required login.
86
+
87
+ ### Custom session schemes
88
+
89
+ Set `sign_in` (and usually `after_sign_in`) to whatever your application does; `identity`
90
+ is an `SQRL::Server::Identity` (`idk_b64`, `user_ref`, `disabled?`, …).
91
+
92
+ ## Helpers
93
+
94
+ In views: `sqrl_login_block(return_to:, cancel_url:, link_user:, script:, **login_block_options)`,
95
+ `sqrl_login_url`, `sqrl_qr_svg`, `sqrl_script_tag`, `sqrl_status_path`, `sqrl_script_path`.
96
+ In controllers: `current_sqrl_identity`, `sqrl_authenticated?`, `sqrl_logout!`
97
+ (call it from your sign-out action), `sqrl_endpoint`.
98
+
99
+ ## Storage
100
+
101
+ `SQRL::Rails::Identity` (`sqrl_identities`): `idk`, `suk`, `vuk` (base64url), `disabled`,
102
+ `sqrlonly`, `hardlock`, `ins`, `logins`, polymorphic `user`, `superseded_by` (tombstone
103
+ rows left by a re-key), `last_login_at`. `SQRL::Rails::Nut` and
104
+ `SQRL::Rails::PendingLogin` are single-use records; `bin/rails sqrl:prune` deletes the
105
+ expired ones (run it from cron or a job).
106
+
107
+ Nut consumption and CPS-token claims are single conditional `UPDATE`s, so they stay
108
+ single-use under concurrent requests.
109
+
110
+ ## Account policies: `sqrlonly` and `hardlock`
111
+
112
+ A SQRL client can ask the site to refuse every non-SQRL way of signing in (`sqrlonly`)
113
+ and of recovering the account (`hardlock`). The flags are stored on the identity;
114
+ `SQRL::Rails.sqrl_only?(user)` and `SQRL::Rails.hard_locked?(user)` answer for a user,
115
+ and `SQRL::Rails::Identity.sqrl_only` / `.hard_locked` are scopes.
116
+
117
+ `bin/rails generate sqrl:install` enforces them in the controllers that
118
+ `generate authentication` produced:
119
+
120
+ ```ruby
121
+ class SessionsController < ApplicationController
122
+ include SQRL::Rails::PasswordPolicy::Sessions # refuses password login for sqrlonly users (create)
123
+ end
124
+
125
+ class PasswordsController < ApplicationController
126
+ include SQRL::Rails::PasswordPolicy::Passwords # refuses password reset for hardlock users (create, edit, update)
127
+ end
128
+ ```
129
+
130
+ Both look the user up by `email_address`/`email` parameter or by reset token
131
+ (`sqrl_policy_user`) and redirect to the sign-in page with a flash alert
132
+ (`sqrl_policy_refused`); override either method for other flows. Messages are the
133
+ `sqrl.password_policy.*` translations.
134
+
135
+ ## Identity management
136
+
137
+ `GET /sqrl/identities` lists the signed-in user's identities (short key, linked date,
138
+ last login, login count, flags) with an Unlink button (`DELETE /sqrl/identities/:id`).
139
+ Unlinking detaches the identity from the user; the site keeps the protocol association,
140
+ so the next login with it is treated as an unknown identity again. The view helper
141
+ `sqrl_identities_path` links to the page. `last_login_at` and `logins` are updated on
142
+ every successful `ident` and `enable`.
143
+
144
+ ## `ask`, `sin` and the deny button
145
+
146
+ ```ruby
147
+ sqrl.ask = SQRL::Ask.new(message: "Allow this login?", buttons: [{ label: "Allow" }, { label: "Deny" }],
148
+ accept: [1]) # only "Allow" (btn=1) lets the ident proceed
149
+ sqrl.ask_deny_button = 2 # a "Deny" answer is also refused at the callback
150
+ sqrl.sin = "0" # secret-index challenge; the client answers with ins=
151
+ ```
152
+
153
+ Both may be callables receiving the request context. The generated initializer turns
154
+ them on with `SQRL_ASK=1` / `SQRL_SIN=1` for testing.
155
+
156
+ ## Rate limiting, hooks and hosts
157
+
158
+ ```ruby
159
+ sqrl.rate_limit = { to: 60, within: 1.minute } # per client IP, counted in Rails.cache; 429 over the limit
160
+ sqrl.before_handle = ->(ctx) { raise SQRL::Server::RateLimited.new(retry_after: 10) if ... }
161
+ sqrl.before_issue = ->(ctx) { ... } # before a login URL is issued
162
+ sqrl.allowed_hosts = ["example.com", /\.example\.com\z/] # when sqrl.host is not set
163
+ sqrl.host_cache_limit = 16 # distinct request hosts kept per process
164
+ sqrl.prune_every = 1000 # opportunistic pruning of expired rows
165
+ ```
166
+
167
+ `rate_limit` applies to the protocol endpoint, the login and refresh pages, the status
168
+ poll, the callback and the identities page (`only:` narrows it; `by:` and `cache:`
169
+ override the key and store). A request whose host is not allowed answers 400.
170
+ `GET /sqrl/refresh` hands the page a fresh login URL when a nut expires (`sqrl.js`
171
+ calls it through `SQRL.refresh`).
172
+
173
+ ## Storage back ends
174
+
175
+ `sqrl.stores` is `:active_record` (default), `:cache` (`SQRL::Rails::Stores::Cache` on
176
+ `Rails.cache` or `sqrl.cache_store`; needs a store that deletes atomically such as Redis
177
+ or Memcached, and a persistent one if identities live there too) or `:memory`. Every
178
+ store set passes the shared store contract spec from `sqrl-core`.
179
+
180
+ ## Linking identities to existing accounts
181
+
182
+ With `on_unknown_identity = :link`, unknown identities are only accepted while a user is
183
+ signed in; render `sqrl_login_block` on an authenticated settings page and the new
184
+ identity is attached to `Current.user` (or whatever `current_user` returns).
185
+
186
+ ## Example and testing
187
+
188
+ [`examples/rails-auth-app`](../../examples/rails-auth-app) is a Rails 8 application built
189
+ with `rails generate authentication` followed by `rails generate sqrl:install`.
190
+ `bundle exec rake spec:sqrl-rails` runs the engine, store, policy and generator specs
191
+ against an in-gem dummy application; the `gemfiles/` directory runs them on Rails 7.1, 7.2
192
+ and 8.0 as well.
193
+
194
+ ## License
195
+
196
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Base controller of the engine. Deliberately not derived from the host's
6
+ # +ApplicationController+, so host filters (authentication, browser checks) do not
7
+ # apply to the protocol endpoints.
8
+ class ApplicationController < ::ActionController::Base
9
+ protect_from_forgery with: :exception
10
+
11
+ # A before_issue / before_handle hook refused the request.
12
+ rescue_from SQRL::Server::RateLimited do |error|
13
+ render_rack(sqrl_endpoint.rate_limited(error, json: request.format.json?))
14
+ end
15
+
16
+ # The request host may not be used to issue SQRL URLs (allowed_hosts, host cache
17
+ # limit).
18
+ rescue_from SQRL::Rack::HostNotAllowed do |error|
19
+ render plain: error.message, status: :bad_request
20
+ end
21
+
22
+ private
23
+
24
+ def sqrl_endpoint
25
+ @sqrl_endpoint ||= SQRL::Rails.endpoint_for(request)
26
+ end
27
+
28
+ def sqrl_config
29
+ SQRL::Rails.config
30
+ end
31
+
32
+ # The host application's URL prefix (its relative URL root), without the engine
33
+ # mount the current request came in through.
34
+ def host_script_name
35
+ name = request.script_name.to_s
36
+ mount = SQRL::Rails.mount_path
37
+ name.end_with?(mount) ? name[0, name.length - mount.length] : name
38
+ end
39
+
40
+ # Public path of an engine route (+:login+, +:status+, ...).
41
+ def engine_path(name)
42
+ SQRL::Rails.path_for(name, script_name: host_script_name)
43
+ end
44
+
45
+ def render_rack(triple)
46
+ status, headers, body = triple
47
+ headers.each { |k, v| response.set_header(k, v) }
48
+ render body: body.join, status: status, content_type: headers["content-type"]
49
+ end
50
+
51
+ # The host's Authentication concern uses the host's route helpers (root_url,
52
+ # new_session_path); resolve them through the main_app proxy.
53
+ def method_missing(name, ...)
54
+ if host_route_helper?(name)
55
+ main_app.public_send(name, ...)
56
+ else
57
+ super
58
+ end
59
+ end
60
+
61
+ def respond_to_missing?(name, include_private = false)
62
+ host_route_helper?(name) || super
63
+ end
64
+
65
+ def host_route_helper?(name)
66
+ name.to_s.match?(/_(url|path)\z/) && respond_to?(:main_app) && main_app.respond_to?(name)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Serves +sqrl.js+ with an ETag.
6
+ class AssetsController < ApplicationController
7
+ # A public static script; Rails' cross-origin JavaScript check does not apply.
8
+ skip_forgery_protection
9
+
10
+ # Serve +sqrl.js+ with an ETag.
11
+ def script
12
+ status, headers, body = sqrl_endpoint.script(request)
13
+ headers.each { |k, v| response.set_header(k, v) }
14
+ if status == 304
15
+ head :not_modified
16
+ else
17
+ render body: body.join, status: status, content_type: "application/javascript"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Completes a login: with +t=+ (the one-time CPS token the SQRL client sent the
6
+ # browser to) or without it (the browser's own pending login, after the poller saw
7
+ # it authenticated). Resolves or creates the user, signs them in through the
8
+ # configured adapter and redirects.
9
+ class CallbackController < ApplicationController
10
+ include RateLimited
11
+
12
+ concern = SQRL::Rails.host_authentication_concern
13
+ if concern
14
+ include concern
15
+
16
+ allow_unauthenticated_access if respond_to?(:allow_unauthenticated_access)
17
+ end
18
+
19
+ # Complete a login (CPS token or the browser's own pending login), resolve the user and sign in.
20
+ def show
21
+ completion = sqrl_endpoint.callback(request)
22
+ return failure("SQRL login failed or expired. Please try again.") unless completion
23
+ return failure(I18n.t("sqrl.callback.declined", default: "You declined the SQRL login."), completion) if declined?(completion)
24
+
25
+ user = resolve_user(completion)
26
+ return failure("This SQRL identity is not linked to an account.", completion) unless user
27
+
28
+ sign_in = SQRL::Rails.config.sign_in || SignIn.method(:default)
29
+ sign_in.call(self, completion.identity, user)
30
+ redirect_to SignIn.after(self, completion), allow_other_host: false
31
+ rescue SQRL::Rails::Error, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved,
32
+ ActionController::Redirecting::UnsafeRedirectError => e
33
+ failure(e.message, completion)
34
+ end
35
+
36
+ private
37
+
38
+ def sqrl_rate_limit_scope
39
+ :callback
40
+ end
41
+
42
+ # The client answered the site's +ask+ with the button configured as "deny".
43
+ def declined?(completion)
44
+ deny = sqrl_config.ask_deny_button
45
+ !deny.nil? && completion.pending_login.btn == deny.to_i
46
+ end
47
+
48
+ # The user behind an identity; for a brand-new identity apply the configured
49
+ # policy.
50
+ def resolve_user(completion)
51
+ identity = completion.identity
52
+ user = find_user(identity)
53
+ return user if user
54
+
55
+ case sqrl_config.on_unknown_identity
56
+ when :refuse
57
+ nil
58
+ when :link
59
+ link_to_current_user(identity)
60
+ else
61
+ create_user(identity)
62
+ end
63
+ end
64
+
65
+ def find_user(identity)
66
+ return sqrl_config.find_user.call(identity) if sqrl_config.find_user
67
+
68
+ SQRL::Rails.user_from_ref(identity.user_ref)
69
+ end
70
+
71
+ def create_user(identity)
72
+ user = sqrl_config.create_user ? sqrl_config.create_user.call(identity) : UserFactory.call(identity)
73
+ attach(identity, user)
74
+ end
75
+
76
+ def link_to_current_user(identity)
77
+ user = SignIn.current_user(self)
78
+ return nil unless user
79
+
80
+ attach(identity, user)
81
+ end
82
+
83
+ def attach(identity, user)
84
+ return nil unless user
85
+
86
+ SQRL::Rails.stores.identities.update(identity.idk, user_ref: SQRL::Rails.user_ref(user))
87
+ user
88
+ end
89
+
90
+ # +completion+ is passed when the pending login was already consumed: the endpoint
91
+ # recorded the identity in the session at that point, so a failed sign-in must clear
92
+ # it again, otherwise the controller helpers would report a refused identity as
93
+ # authenticated.
94
+ def failure(message, completion = nil)
95
+ sqrl_endpoint.logout(request) if completion
96
+ flash[:alert] = message if respond_to?(:flash)
97
+ redirect_to(sqrl_config.failure_path || engine_path(:login), allow_other_host: false)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # The SQRL endpoint: the client's signed POSTs. Authenticated by the protocol's own
6
+ # signatures, so forgery protection does not apply.
7
+ class CliController < ApplicationController
8
+ include RateLimited
9
+
10
+ skip_forgery_protection
11
+
12
+ # The SQRL protocol endpoint: hands the signed POST to the handler and renders its reply.
13
+ def create
14
+ render_rack(sqrl_endpoint.cli(request))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Lets a signed-in user see the SQRL identities attached to their account and unlink
6
+ # one. Unlinking detaches the identity from the user (the site keeps the protocol
7
+ # association, so the SQRL client still "knows" the site); the next login with that
8
+ # identity is treated as an unknown identity again.
9
+ #
10
+ # The signed-in user comes from the host's +Authentication+ concern (Rails 8), from
11
+ # +config.sqrl.current_user+, or from the session fallback of {SignIn}.
12
+ class IdentitiesController < ApplicationController
13
+ include RateLimited
14
+
15
+ concern = SQRL::Rails.host_authentication_concern
16
+ include concern if concern
17
+
18
+ layout "sqrl/rails/application"
19
+
20
+ before_action :require_sqrl_user
21
+
22
+ # List the current user's SQRL identities.
23
+ def index
24
+ @identities = Identity.active.for_user(@sqrl_user).order(:created_at)
25
+ @current_idk = sqrl_endpoint.current_identity(request)&.idk_b64
26
+ end
27
+
28
+ # Unlink one of the current user's SQRL identities.
29
+ def destroy
30
+ identity = Identity.active.for_user(@sqrl_user).find_by(id: params[:id])
31
+ if identity
32
+ current = sqrl_endpoint.current_identity(request)&.idk_b64
33
+ identity.update!(user: nil)
34
+ sqrl_endpoint.logout(request) if current && current == identity.idk
35
+ flash[:notice] = I18n.t("sqrl.identities.unlinked", default: "SQRL identity unlinked.")
36
+ else
37
+ flash[:alert] = I18n.t("sqrl.identities.not_found", default: "That SQRL identity is not linked to your account.")
38
+ end
39
+ redirect_to engine_path(:identities), status: :see_other
40
+ end
41
+
42
+ private
43
+
44
+ def sqrl_rate_limit_scope
45
+ :identities
46
+ end
47
+
48
+ def require_sqrl_user
49
+ @sqrl_user = SignIn.current_user(self)
50
+ return if @sqrl_user
51
+
52
+ flash[:alert] = I18n.t("sqrl.identities.sign_in_required", default: "Sign in to manage your SQRL identities.")
53
+ redirect_to(sqrl_config.failure_path || engine_path(:login), allow_other_host: false)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # A stand-alone login page, for applications that do not embed
6
+ # +sqrl_login_block+ in their own view.
7
+ class LoginsController < ApplicationController
8
+ include RateLimited
9
+
10
+ layout "sqrl/rails/application"
11
+
12
+ # Render the stand-alone login page.
13
+ def new
14
+ @return_to = params[:return_to].presence
15
+ end
16
+
17
+ private
18
+
19
+ def sqrl_rate_limit_scope
20
+ :login
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Hands the login page a fresh login URL (and QR code) for its browser session when
6
+ # the previous nut expired, as JSON; +sqrl.js+ calls it through +SQRL.refresh+.
7
+ class RefreshController < ApplicationController
8
+ include RateLimited
9
+
10
+ # Issue a fresh login URL for the browser's session as JSON (used by +SQRL.refresh+).
11
+ def show
12
+ render_rack(sqrl_endpoint.refresh(request))
13
+ end
14
+
15
+ private
16
+
17
+ def sqrl_rate_limit_scope
18
+ :refresh
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Cookie-bound poll answered with +{"authenticated": bool, "next": url}+.
6
+ class StatusController < ApplicationController
7
+ include RateLimited
8
+
9
+ # The cookie-bound poll: is this browser's pending login authenticated yet?
10
+ def show
11
+ render_rack(sqrl_endpoint.status(request))
12
+ end
13
+
14
+ private
15
+
16
+ def sqrl_rate_limit_scope
17
+ :status
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # View helpers available in the host application and the engine.
6
+ module LoginHelper
7
+ # The complete SQRL login markup (link, QR code, status text and script tag),
8
+ # wired to the engine's endpoints. Each render issues a fresh login URL.
9
+ #
10
+ # @param return_to [String, nil] same-site path to land on after login (defaults to
11
+ # +params[:return_to]+ or Rails 8's +session[:return_to_after_authenticating]+)
12
+ # @param cancel_url [String, nil]
13
+ # @param link_user [Object, nil, false] user to link a new identity to; defaults to
14
+ # the signed-in user when known, +false+ disables linking
15
+ # @param script [Boolean] append the +sqrl.js+ script tag
16
+ # @param options [Hash] passed to {SQRL::Rack::LoginBlock.render}
17
+ # @return [ActiveSupport::SafeBuffer]
18
+ def sqrl_login_block(return_to: nil, cancel_url: nil, link_user: nil, script: true, **options)
19
+ login = sqrl_login_url(return_to: return_to, cancel_url: cancel_url, link_user: link_user)
20
+ SQRL::Rack::LoginBlock.render(login_url: login.url, status_url: sqrl_status_path,
21
+ cancel_url: cancel_url, script_url: (sqrl_script_path if script),
22
+ refresh_url: sqrl_refresh_path,
23
+ **options).html_safe
24
+ end
25
+
26
+ # Issue (once per request) and return the +sqrl://+ login URL.
27
+ #
28
+ # @return [SQRL::Server::LoginURL]
29
+ def sqrl_login_url(return_to: nil, cancel_url: nil, link_user: nil)
30
+ @sqrl_login_url ||= begin
31
+ return_to ||= sqrl_default_return_to
32
+ link_ref = link_user == false ? nil : SQRL::Rails.user_ref(link_user || sqrl_linkable_user)
33
+ SQRL::Rails.endpoint_for(request).issue(request, return_to: return_to, cancel_url: cancel_url,
34
+ link_user_ref: link_ref)
35
+ end
36
+ end
37
+
38
+ # The QR code of the login URL as inline SVG.
39
+ def sqrl_qr_svg(**options)
40
+ SQRL::Rack::QR.svg(sqrl_login_url.url, **options).html_safe
41
+ end
42
+
43
+ # +<script src="/sqrl/sqrl.js" defer>+
44
+ def sqrl_script_tag
45
+ SQRL::Rack::LoginBlock.script_tag(src: sqrl_script_path).html_safe
46
+ end
47
+
48
+ # @return [String] public path of the status poll
49
+ def sqrl_status_path = SQRL::Rails.path_for(:status, script_name: sqrl_host_script_name)
50
+ # The identity-management page of the engine (list and unlink SQRL identities).
51
+ def sqrl_identities_path = SQRL::Rails.path_for(:identities, script_name: sqrl_host_script_name)
52
+ # @return [String] public path of the login callback
53
+ def sqrl_callback_path = SQRL::Rails.path_for(:callback, script_name: sqrl_host_script_name)
54
+ # @return [String] public path of +sqrl.js+
55
+ def sqrl_script_path = SQRL::Rails.path_for(:script, script_name: sqrl_host_script_name)
56
+ # @return [String] public path of the refresh endpoint
57
+ def sqrl_refresh_path = SQRL::Rails.path_for(:refresh, script_name: sqrl_host_script_name)
58
+
59
+ private
60
+
61
+ # The host application's own prefix (its relative URL root), excluding the engine
62
+ # mount when rendering inside the engine.
63
+ def sqrl_host_script_name
64
+ name = request.script_name.to_s
65
+ mount = SQRL::Rails.mount_path
66
+ name.end_with?(mount) ? name[0, name.length - mount.length] : name
67
+ end
68
+
69
+ def sqrl_default_return_to
70
+ candidate = params[:return_to].presence || (session[:return_to_after_authenticating] if respond_to?(:session))
71
+ SignIn.same_site_path?(candidate) ? candidate : nil
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ def sqrl_linkable_user
77
+ return nil unless SQRL::Rails.config.on_unknown_identity == :link
78
+
79
+ SignIn.current_user(controller)
80
+ rescue StandardError
81
+ nil
82
+ end
83
+ end
84
+ end
85
+ end