standard_id 0.1.5 → 0.1.7

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +529 -20
  3. data/app/controllers/concerns/standard_id/inertia_rendering.rb +49 -0
  4. data/app/controllers/concerns/standard_id/inertia_support.rb +31 -0
  5. data/app/controllers/concerns/standard_id/set_current_request_details.rb +19 -0
  6. data/app/controllers/concerns/standard_id/social_authentication.rb +86 -37
  7. data/app/controllers/concerns/standard_id/web_authentication.rb +50 -1
  8. data/app/controllers/standard_id/api/base_controller.rb +1 -0
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +7 -18
  10. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +33 -37
  11. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  12. data/app/controllers/standard_id/web/login_controller.rb +12 -21
  13. data/app/controllers/standard_id/web/signup_controller.rb +11 -8
  14. data/app/forms/standard_id/web/signup_form.rb +32 -1
  15. data/app/models/standard_id/browser_session.rb +8 -0
  16. data/app/models/standard_id/client_secret_credential.rb +11 -0
  17. data/app/models/standard_id/device_session.rb +4 -0
  18. data/app/models/standard_id/identifier.rb +28 -0
  19. data/app/models/standard_id/service_session.rb +1 -1
  20. data/app/models/standard_id/session.rb +16 -2
  21. data/app/views/standard_id/web/auth/callback/providers/{apple_mobile.html.erb → mobile_callback.html.erb} +1 -1
  22. data/config/routes/api.rb +1 -2
  23. data/config/routes/web.rb +4 -3
  24. data/lib/generators/standard_id/install/templates/standard_id.rb +19 -8
  25. data/lib/standard_config/config.rb +13 -12
  26. data/lib/standard_config/config_provider.rb +6 -6
  27. data/lib/standard_config/schema.rb +2 -2
  28. data/lib/standard_id/account_locking.rb +86 -0
  29. data/lib/standard_id/account_status.rb +45 -0
  30. data/lib/standard_id/api/authentication_guard.rb +40 -1
  31. data/lib/standard_id/api/token_manager.rb +1 -1
  32. data/lib/standard_id/config/schema.rb +13 -9
  33. data/lib/standard_id/current_attributes.rb +9 -0
  34. data/lib/standard_id/engine.rb +9 -0
  35. data/lib/standard_id/errors.rb +12 -0
  36. data/lib/standard_id/events/definitions.rb +157 -0
  37. data/lib/standard_id/events/event.rb +123 -0
  38. data/lib/standard_id/events/subscribers/account_locking_subscriber.rb +17 -0
  39. data/lib/standard_id/events/subscribers/account_status_subscriber.rb +17 -0
  40. data/lib/standard_id/events/subscribers/base.rb +165 -0
  41. data/lib/standard_id/events/subscribers/logging_subscriber.rb +122 -0
  42. data/lib/standard_id/events.rb +137 -0
  43. data/lib/standard_id/oauth/authorization_code_flow.rb +10 -0
  44. data/lib/standard_id/oauth/client_credentials_flow.rb +31 -0
  45. data/lib/standard_id/oauth/password_flow.rb +36 -4
  46. data/lib/standard_id/oauth/passwordless_otp_flow.rb +38 -2
  47. data/lib/standard_id/oauth/subflows/social_login_grant.rb +11 -22
  48. data/lib/standard_id/oauth/token_grant_flow.rb +22 -1
  49. data/lib/standard_id/passwordless/base_strategy.rb +32 -0
  50. data/lib/standard_id/provider_registry.rb +73 -0
  51. data/lib/standard_id/{social_providers → providers}/apple.rb +46 -7
  52. data/lib/standard_id/providers/base.rb +242 -0
  53. data/lib/standard_id/{social_providers → providers}/google.rb +26 -7
  54. data/lib/standard_id/version.rb +1 -1
  55. data/lib/standard_id/web/authentication_guard.rb +29 -0
  56. data/lib/standard_id/web/session_manager.rb +39 -1
  57. data/lib/standard_id/web/token_manager.rb +2 -2
  58. data/lib/standard_id.rb +13 -2
  59. metadata +20 -6
  60. data/lib/standard_id/social_providers/response_builder.rb +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d55cdf40a33a4f541b1de5ecb1c19f827b92123b63bccd56781da4671944ff45
4
- data.tar.gz: 714bc65bd0aa6a6913b51c0f59f659f41a425a5a21489103d89624149e3ff61d
3
+ metadata.gz: 2878f305d6dfe83c5a1c0851cde68602cbd17899b1a676981afd8166f56995e0
4
+ data.tar.gz: 4c1802cc0bb54045165eb42289d75dda85db9a8838d61a6c1b4e7a7ff50e7828
5
5
  SHA512:
6
- metadata.gz: 0625c5efdb00b0e0d8558191c2b8ad7c9512c6ec13a5fe5896393497241f2f8313f8633b3e319358915b00ff33b23a3979b28e32c64fc20664b9a5e513625d5b
7
- data.tar.gz: 0fb8080e7bee90799d27daa892fb9c221e5d4558b0a5c73916249988f6875793ec54ae2afdc6c93d11b526acee37f23e7350e3f2f47522c67b2e6c37c10e8626
6
+ metadata.gz: 8073e2e1f0208261525be8218960ef6481e8a5558f281a56baf2316b4750f9557b37365831b9aa530706e2d52d6c088e3cc3e9c23c4a4ef722d641f2041ea357
7
+ data.tar.gz: a803c19a19f5fbcc3acae0511f629c6d4b1520d67a54cfa16d062fcc60333083d28cd5149a6658bfb16e37ae3a2ff1d7cdf06d657d16130010a08c67b8c851ff
data/README.md CHANGED
@@ -37,6 +37,11 @@ A comprehensive authentication engine for Rails applications, built on the secur
37
37
  - **Remember Me**: Extended session support
38
38
  - **Account Lockout**: Protection against brute force attacks
39
39
 
40
+ ### ⚡ Frontend Framework Support
41
+ - **Inertia.js Integration**: Optional support for React, Vue, or Svelte frontends
42
+ - **Conditional Rendering**: Automatically switches between ERB and Inertia based on configuration
43
+ - **External Redirects**: Proper handling of OAuth redirects in SPA contexts
44
+
40
45
  ## Installation
41
46
 
42
47
  Add this line to your application's Gemfile:
@@ -114,9 +119,15 @@ StandardId.configure do |config|
114
119
  # Custom layout for web views
115
120
  config.web_layout = "application"
116
121
 
117
- # Passwordless delivery callbacks
118
- # config.passwordless_email_sender = ->(email, code) { UserMailer.send_code(email, code).deliver_now }
119
- # config.passwordless_sms_sender = ->(phone, code) { SmsService.send_code(phone, code) }
122
+ # Inertia.js support (see Inertia.js Integration section below)
123
+ # config.use_inertia = true
124
+ # config.inertia_component_namespace = "auth"
125
+
126
+ # Session lifetimes
127
+ # config.session.browser_session_lifetime = 86400 # 24 hours (web sessions)
128
+ # config.session.browser_session_remember_me_lifetime = 2_592_000 # 30 days (remember me cookies)
129
+ # config.session.device_session_lifetime = 2_592_000 # 30 days (API device sessions)
130
+ # config.session.service_session_lifetime = 7_776_000 # 90 days (service-to-service sessions)
120
131
 
121
132
  # Subset configuration
122
133
  # config.password.minimum_length = 12
@@ -177,37 +188,535 @@ StandardId.configure do |config|
177
188
  name: social_info[:name] || social_info[:given_name]
178
189
  }
179
190
  }
180
-
181
- # Optional: run a callback whenever a social login completes
182
- config.social.social_callback = ->(social_info:, provider:, tokens:, account:) {
183
- AuditLog.social_login(
184
- provider: provider,
185
- email: social_info[:email],
186
- tokens: tokens,
187
- account_id: account.id,
188
- )
189
- }
190
191
  end
191
192
  ```
192
193
 
193
194
  `social_info` is an indifferent-access hash containing at least `email`, `name`, and `provider_id`.
194
195
 
195
- ### Passwordless Authentication
196
+ To handle social login completion (e.g., for analytics or audit logging), subscribe to the `SOCIAL_AUTH_COMPLETED` event:
197
+
198
+ ```ruby
199
+ # config/initializers/standard_id_events.rb
200
+ StandardId::Events.subscribe(StandardId::Events::SOCIAL_AUTH_COMPLETED) do |event|
201
+ Analytics.track_social_login(
202
+ provider: event[:provider],
203
+ account_id: event[:account].id,
204
+ tokens: event[:tokens]
205
+ )
206
+ end
207
+ ```
208
+
209
+ ### Inertia.js Integration
210
+
211
+ StandardId supports [Inertia.js](https://inertiajs.com/) for modern React, Vue, or Svelte frontends. When enabled, web controllers render Inertia components instead of ERB views.
212
+
213
+ #### Setup
214
+
215
+ 1. Add the `inertia_rails` gem to your Gemfile:
216
+
217
+ ```ruby
218
+ gem "inertia_rails"
219
+ ```
220
+
221
+ 2. Enable Inertia in your StandardId configuration:
196
222
 
197
223
  ```ruby
198
224
  StandardId.configure do |config|
199
- # Email delivery
200
- config.passwordless_email_sender = ->(email, code) {
201
- UserMailer.send_code(email, code).deliver_now
225
+ config.use_inertia = true
226
+ config.inertia_component_namespace = "auth" # Optional, defaults to "standard_id"
227
+ end
228
+ ```
229
+
230
+ 3. Create the corresponding frontend components. The component path follows the pattern:
231
+ `{namespace}/{ControllerName}/{action}`
232
+
233
+ For example, with `inertia_component_namespace = "auth"`:
234
+ - Login page: `pages/auth/login/show.tsx`
235
+ - Signup page: `pages/auth/signup/show.tsx`
236
+
237
+ #### Example Component (React)
238
+
239
+ ```tsx
240
+ // frontend/pages/auth/login/show.tsx
241
+ import { useForm } from '@inertiajs/react'
242
+
243
+ interface Props {
244
+ redirect_uri: string
245
+ connection: string | null
246
+ flash: { notice?: string; alert?: string }
247
+ social_providers: { google_enabled: boolean; apple_enabled: boolean }
248
+ }
249
+
250
+ export default function LoginShow({ redirect_uri, flash, social_providers }: Props) {
251
+ const { data, setData, post, processing } = useForm({
252
+ 'login[email]': '',
253
+ 'login[password]': '',
254
+ 'login[remember_me]': false,
255
+ redirect_uri,
256
+ })
257
+
258
+ const handleSubmit = (e: React.FormEvent) => {
259
+ e.preventDefault()
260
+ post('/login')
202
261
  }
203
262
 
204
- # SMS delivery
205
- config.passwordless_sms_sender = ->(phone, code) {
206
- SmsService.send_code(phone, code)
263
+ const handleSocialLogin = (connection: string) => {
264
+ post('/login', { data: { connection, redirect_uri } })
207
265
  }
266
+
267
+ return (
268
+ <div className="login-container">
269
+ {flash.alert && <div className="alert alert-error">{flash.alert}</div>}
270
+ {flash.notice && <div className="alert alert-success">{flash.notice}</div>}
271
+
272
+ <form onSubmit={handleSubmit}>
273
+ <div>
274
+ <label htmlFor="email">Email</label>
275
+ <input
276
+ id="email"
277
+ type="email"
278
+ value={data['login[email]']}
279
+ onChange={e => setData('login[email]', e.target.value)}
280
+ required
281
+ />
282
+ </div>
283
+
284
+ <div>
285
+ <label htmlFor="password">Password</label>
286
+ <input
287
+ id="password"
288
+ type="password"
289
+ value={data['login[password]']}
290
+ onChange={e => setData('login[password]', e.target.value)}
291
+ required
292
+ />
293
+ </div>
294
+
295
+ <div>
296
+ <label>
297
+ <input
298
+ type="checkbox"
299
+ checked={data['login[remember_me]'] as boolean}
300
+ onChange={e => setData('login[remember_me]', e.target.checked)}
301
+ />
302
+ Remember me
303
+ </label>
304
+ </div>
305
+
306
+ <button type="submit" disabled={processing}>
307
+ {processing ? 'Signing in...' : 'Sign In'}
308
+ </button>
309
+ </form>
310
+
311
+ {(social_providers.google_enabled || social_providers.apple_enabled) && (
312
+ <div className="social-login">
313
+ <p>Or continue with</p>
314
+ {social_providers.google_enabled && (
315
+ <button type="button" onClick={() => handleSocialLogin('google')}>
316
+ Sign in with Google
317
+ </button>
318
+ )}
319
+ {social_providers.apple_enabled && (
320
+ <button type="button" onClick={() => handleSocialLogin('apple')}>
321
+ Sign in with Apple
322
+ </button>
323
+ )}
324
+ </div>
325
+ )}
326
+ </div>
327
+ )
328
+ }
329
+ ```
330
+
331
+ > **Note:** The `useForm` hook from `@inertiajs/react` automatically handles CSRF tokens. When you call `post()`, `put()`, `patch()`, or `delete()`, Inertia reads the CSRF token from the `<meta name="csrf-token">` tag in your layout and includes it in the request headers.
332
+
333
+ #### Props Passed to Components
334
+
335
+ Authentication pages receive the following props:
336
+
337
+ | Prop | Type | Description |
338
+ |------|------|-------------|
339
+ | `redirect_uri` | `string` | URL to redirect to after authentication |
340
+ | `connection` | `string \| null` | Social provider connection (if any) |
341
+ | `flash` | `{ notice?: string, alert?: string }` | Flash messages |
342
+ | `social_providers` | `{ google_enabled: boolean, apple_enabled: boolean }` | Available social providers |
343
+ | `errors` | `object` | Validation errors (on form submission failures) |
344
+
345
+ #### Using Authentication in Host App Controllers
346
+
347
+ You can use the `authenticate_account!` method in your own controllers to require authentication with Inertia-compatible redirects:
348
+
349
+ ```ruby
350
+ class DashboardController < ApplicationController
351
+ include StandardId::WebAuthentication
352
+
353
+ before_action :authenticate_account!
354
+
355
+ def show
356
+ # Only authenticated users can access this
357
+ end
358
+ end
359
+ ```
360
+
361
+ This will redirect unauthenticated users to the login page using `inertia_location` for Inertia requests, ensuring proper SPA navigation.
362
+
363
+ ### Passwordless Code Delivery
364
+
365
+ Subscribe to the `PASSWORDLESS_CODE_GENERATED` event to deliver OTP codes:
366
+
367
+ ```ruby
368
+ # config/initializers/standard_id_events.rb
369
+ StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
370
+ case event[:channel]
371
+ when "email"
372
+ UserMailer.send_code(event[:identifier], event[:code_challenge].code).deliver_now
373
+ when "sms"
374
+ SmsService.send_code(event[:identifier], event[:code_challenge].code)
375
+ end
376
+ end
377
+ ```
378
+
379
+ Event payload includes:
380
+ - `channel` - `"email"` or `"sms"`
381
+ - `identifier` - The email address or phone number
382
+ - `code_challenge` - The code challenge object with `.code` method
383
+ - `expires_at` - When the code expires
384
+
385
+ > **Note**: If you're using the deprecated `passwordless_email_sender` or `passwordless_sms_sender` callbacks, see the [Migration Guide](docs/MIGRATION_GUIDE.md) for upgrade instructions.
386
+
387
+ ## Event System
388
+
389
+ StandardId emits events throughout the authentication lifecycle using `ActiveSupport::Notifications`. This enables decoupled handling of cross-cutting concerns like logging, analytics, audit trails, and webhooks.
390
+
391
+ ### Enabling Event Logging
392
+
393
+ Enable the built-in structured logging subscriber:
394
+
395
+ ```ruby
396
+ StandardId.configure do |config|
397
+ config.events.enable_logging = true
398
+ end
399
+ ```
400
+
401
+ This outputs JSON-structured logs for all authentication events:
402
+
403
+ ```json
404
+ {
405
+ "subject": "standard_id.authentication.attempt.succeeded",
406
+ "severity": "info",
407
+ "duration": 50.25,
408
+ "account_id": 123,
409
+ "auth_method": "password",
410
+ "ip_address": "192.168.1.1"
411
+ }
412
+ ```
413
+
414
+ ### Available Events
415
+
416
+ | Category | Events |
417
+ |----------|--------|
418
+ | **Authentication** | `authentication.attempt.started`, `authentication.attempt.succeeded`, `authentication.attempt.failed`, `authentication.password.validated`, `authentication.password.failed`, `authentication.otp.validated`, `authentication.otp.failed` |
419
+ | **Session** | `session.creating`, `session.created`, `session.validating`, `session.validated`, `session.expired`, `session.revoked`, `session.refreshed` |
420
+ | **Account** | `account.creating`, `account.created`, `account.verified`, `account.status_changed`, `account.activated`, `account.deactivated`, `account.locked`, `account.unlocked` |
421
+ | **Identifier** | `identifier.created`, `identifier.verification.started`, `identifier.verification.succeeded`, `identifier.verification.failed`, `identifier.linked` |
422
+ | **OAuth** | `oauth.authorization.requested`, `oauth.authorization.granted`, `oauth.authorization.denied`, `oauth.token.issuing`, `oauth.token.issued`, `oauth.token.refreshed`, `oauth.code.consumed` |
423
+ | **Passwordless** | `passwordless.code.requested`, `passwordless.code.generated`, `passwordless.code.sent`, `passwordless.code.verified`, `passwordless.code.failed`, `passwordless.account.created` |
424
+ | **Social** | `social.auth.started`, `social.auth.callback_received`, `social.user_info.fetched`, `social.account.created`, `social.account.linked`, `social.auth.completed` |
425
+ | **Credential** | `credential.password.created`, `credential.password.reset_initiated`, `credential.password.reset_completed`, `credential.password.changed`, `credential.client_secret.created`, `credential.client_secret.rotated` |
426
+
427
+ ### Subscribing to Events
428
+
429
+ #### Block-based (simple)
430
+
431
+ ```ruby
432
+ # config/initializers/standard_id_events.rb
433
+ StandardId::Events.subscribe(StandardId::Events::AUTHENTICATION_SUCCEEDED) do |event|
434
+ Analytics.track_login(
435
+ account_id: event[:account].id,
436
+ method: event[:auth_method],
437
+ ip: event[:ip_address]
438
+ )
439
+ end
440
+
441
+ # Subscribe to multiple events at once
442
+ StandardId::Events.subscribe(
443
+ StandardId::Events::SESSION_CREATING,
444
+ StandardId::Events::SESSION_VALIDATING,
445
+ StandardId::Events::OAUTH_TOKEN_ISSUING
446
+ ) do |event|
447
+ # Handle all three events with the same block
448
+ check_rate_limit(event[:account], event[:ip_address])
449
+ end
450
+
451
+ # Subscribe to events with pattern matching
452
+ StandardId::Events.subscribe(/social/) do |event|
453
+ Rails.logger.info("Social event: #{event.name}")
454
+ end
455
+ ```
456
+
457
+ #### Class-based (complex logic)
458
+
459
+ ```ruby
460
+ # app/subscribers/audit_subscriber.rb
461
+ class AuditSubscriber < StandardId::Events::Subscribers::Base
462
+ subscribe_to StandardId::Events::AUTHENTICATION_SUCCEEDED
463
+ subscribe_to StandardId::Events::AUTHENTICATION_FAILED
464
+ subscribe_to StandardId::Events::SESSION_REVOKED
465
+
466
+ def call(event)
467
+ AuditLog.create!(
468
+ event_type: event.short_name,
469
+ account_id: event[:account]&.id,
470
+ ip_address: event[:ip_address],
471
+ metadata: event.payload
472
+ )
473
+ end
474
+ end
475
+
476
+ # config/initializers/standard_id_events.rb
477
+ AuditSubscriber.attach
478
+ ```
479
+
480
+ ## Account Status (Activation/Deactivation)
481
+
482
+ StandardId provides an optional `AccountStatus` concern for managing account activation and deactivation. This uses Rails enum with the event system to enforce status checks and handle side effects without modifying core authentication logic.
483
+
484
+ ### Setup
485
+
486
+ 1. Add a migration for the status column. For PostgreSQL (recommended), use a native enum type:
487
+
488
+ ```ruby
489
+ # PostgreSQL with native enum (recommended)
490
+ class AddStatusToUsers < ActiveRecord::Migration[8.0]
491
+ def up
492
+ create_enum :account_status, %w[active inactive]
493
+
494
+ add_column :users, :status, :enum, enum_type: :account_status, default: "active", null: false
495
+ add_column :users, :activated_at, :datetime
496
+ add_column :users, :deactivated_at, :datetime
497
+ end
498
+
499
+ def down
500
+ remove_column :users, :status
501
+ remove_column :users, :activated_at
502
+ remove_column :users, :deactivated_at
503
+
504
+ drop_enum :account_status
505
+ end
506
+ end
507
+ ```
508
+
509
+ For other databases (MySQL, SQLite), use a string column:
510
+
511
+ ```ruby
512
+ # String column (MySQL, SQLite)
513
+ class AddStatusToUsers < ActiveRecord::Migration[8.0]
514
+ def change
515
+ add_column :users, :status, :string, default: "active", null: false
516
+ add_column :users, :activated_at, :datetime
517
+ add_column :users, :deactivated_at, :datetime
518
+ add_index :users, :status
519
+ end
520
+ end
521
+ ```
522
+
523
+ 2. Include the concern in your account model:
524
+
525
+ ```ruby
526
+ class User < ApplicationRecord
527
+ include StandardId::AccountStatus
528
+ # ...
529
+ end
530
+ ```
531
+
532
+ The concern works with both PostgreSQL enum and string columns - Rails enum handles both transparently.
533
+
534
+ ### Usage
535
+
536
+ ```ruby
537
+ # Deactivate an account
538
+ user.deactivate!
539
+ # => Emits ACCOUNT_DEACTIVATED event
540
+ # => All active sessions are automatically revoked
541
+
542
+ # Reactivate an account
543
+ user.activate!
544
+ # => Emits ACCOUNT_ACTIVATED event
545
+ # => User can log in again
546
+
547
+ # Check status
548
+ user.active? # => true/false
549
+ user.inactive? # => true/false
550
+
551
+ # Query scopes
552
+ User.active # => Users with status 'active'
553
+ User.inactive # => Users with status 'inactive'
554
+ ```
555
+
556
+ ### Handling AccountDeactivatedError
557
+
558
+ When an inactive account attempts to authenticate, `StandardId::AccountDeactivatedError` is raised. You need to handle this error in your application controller:
559
+
560
+ ```ruby
561
+ # app/controllers/application_controller.rb
562
+ class ApplicationController < ActionController::Base
563
+ include StandardId::WebAuthentication
564
+
565
+ rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
566
+
567
+ private
568
+
569
+ def handle_account_deactivated
570
+ # For web requests, redirect with a message
571
+ redirect_to login_path, alert: "Your account has been deactivated. Please contact support."
572
+ end
573
+ end
574
+ ```
575
+
576
+ For API controllers:
577
+
578
+ ```ruby
579
+ # app/controllers/api/base_controller.rb
580
+ class Api::BaseController < ActionController::API
581
+ include StandardId::ApiAuthentication
582
+
583
+ rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
584
+
585
+ private
586
+
587
+ def handle_account_deactivated
588
+ render json: {
589
+ error: "account_deactivated",
590
+ message: "Your account has been deactivated"
591
+ }, status: :forbidden
592
+ end
593
+ end
594
+ ```
595
+
596
+ ## Account Locking (Administrative Security)
597
+
598
+ StandardId provides an optional `AccountLocking` concern for administrative account locking. This is distinct from account deactivation - locking is for security enforcement by administrators, while deactivation is for lifecycle management.
599
+
600
+ ### Key Differences from Account Deactivation
601
+
602
+ | Feature | Account Status | Account Locking |
603
+ |---------|---------------|-----------------|
604
+ | **Purpose** | Lifecycle management | Security enforcement |
605
+ | **Who Controls** | System/User | Admin/Staff only |
606
+ | **User Reversible** | Yes (future) | No |
607
+ | **Use Cases** | Inactivity, user choice | Policy violation, security incident, fraud |
608
+
609
+ An account can be in any combination:
610
+ - Active + Unlocked ✅ (normal operation)
611
+ - Active + Locked ⚠️ (admin locked for security)
612
+ - Inactive + Unlocked ⚠️ (deactivated but not locked)
613
+ - Inactive + Locked 🚫 (both restrictions apply)
614
+
615
+ ### Setup
616
+
617
+ 1. Add a migration for the locking columns:
618
+
619
+ ```ruby
620
+ class AddLockingToUsers < ActiveRecord::Migration[8.0]
621
+ def change
622
+ add_column :users, :locked, :boolean, default: false, null: false
623
+ add_column :users, :locked_at, :datetime
624
+ add_column :users, :lock_reason, :string
625
+ add_column :users, :locked_by_id, :integer
626
+ add_column :users, :locked_by_type, :string
627
+ add_column :users, :unlocked_at, :datetime
628
+ add_column :users, :unlocked_by_id, :integer
629
+ add_column :users, :unlocked_by_type, :string
630
+
631
+ add_index :users, :locked
632
+ add_index :users, [:locked_by_type, :locked_by_id]
633
+ end
634
+ end
635
+ ```
636
+
637
+ 2. Include the concern in your account model:
638
+
639
+ ```ruby
640
+ class User < ApplicationRecord
641
+ include StandardId::AccountLocking # For admin locking
642
+ include StandardId::AccountStatus # Optional: for activation/deactivation
643
+ # ...
644
+ end
645
+ ```
646
+
647
+ ### Usage
648
+
649
+ ```ruby
650
+ # Lock an account (revokes all active sessions immediately)
651
+ user.lock!(reason: "Suspicious activity detected", locked_by: current_admin)
652
+ # => Emits ACCOUNT_LOCKED event
653
+ # => All active sessions (browser, device, service) are revoked
654
+
655
+ # Unlock an account (user must log in again)
656
+ user.unlock!(unlocked_by: current_admin)
657
+ # => Emits ACCOUNT_UNLOCKED event
658
+ # => User can log in again
659
+
660
+ # Check lock status
661
+ user.locked? # => true/false
662
+ user.unlocked? # => true/false
663
+
664
+ # Query scopes
665
+ User.locked # => Users with locked = true
666
+ User.unlocked # => Users with locked = false
667
+
668
+ # Combine with AccountStatus scopes
669
+ User.unlocked.active # => Users who can log in
670
+ ```
671
+
672
+ ### Handling AccountLockedError
673
+
674
+ When a locked account attempts to authenticate, `StandardId::AccountLockedError` is raised. The error includes metadata about the lock:
675
+
676
+ ```ruby
677
+ # app/controllers/application_controller.rb
678
+ class ApplicationController < ActionController::Base
679
+ include StandardId::WebAuthentication
680
+
681
+ rescue_from StandardId::AccountLockedError, with: :handle_account_locked
682
+
683
+ private
684
+
685
+ def handle_account_locked(error)
686
+ # error.account - The locked account
687
+ # error.lock_reason - Why the account was locked
688
+ # error.locked_at - When the account was locked
689
+ redirect_to login_path, alert: "Your account has been locked. Please contact support."
690
+ end
208
691
  end
209
692
  ```
210
693
 
694
+ For API controllers:
695
+
696
+ ```ruby
697
+ # app/controllers/api/base_controller.rb
698
+ class Api::BaseController < ActionController::API
699
+ include StandardId::ApiAuthentication
700
+
701
+ rescue_from StandardId::AccountLockedError, with: :handle_account_locked
702
+
703
+ private
704
+
705
+ def handle_account_locked(error)
706
+ render json: {
707
+ error: "account_locked",
708
+ message: "Your account has been locked. Please contact support.",
709
+ locked_at: error.locked_at&.iso8601
710
+ # Note: Consider not exposing lock_reason to end users for security
711
+ }, status: :forbidden
712
+ end
713
+ end
714
+ ```
715
+
716
+ ### Event Subscriptions
717
+
718
+ Both `AccountStatus` and `AccountLocking` subscribe to the same events (`OAUTH_TOKEN_ISSUING`, `SESSION_CREATING`, `SESSION_VALIDATING`). The lock check runs alongside the status check - authentication fails if either condition prevents access.
719
+
211
720
  ## Usage Examples
212
721
 
213
722
  ### Web Authentication
@@ -0,0 +1,49 @@
1
+ module StandardId
2
+ module InertiaRendering
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include StandardId::InertiaSupport
7
+ end
8
+
9
+ private
10
+
11
+ # Render with Inertia if enabled, otherwise use standard Rails rendering
12
+ def render_with_inertia(action: nil, props: {}, component: nil, status: :ok, **options)
13
+ if use_inertia?
14
+ component_name = component || inertia_component_name(action)
15
+ render inertia: component_name, props: props, status: status, **options
16
+ else
17
+ render_options = { status: status }
18
+ render_options[:action] = action if action.present?
19
+ render_options.merge!(options.except(:inertia, :props))
20
+ render(**render_options)
21
+ end
22
+ end
23
+
24
+ # Generate the Inertia component name based on controller and action
25
+ def inertia_component_name(action = nil)
26
+ namespace = StandardId.config.inertia_component_namespace.presence || "standard_id"
27
+ controller_name = self.class.name.demodulize.delete_suffix("Controller")
28
+ action_str = (action || self.action_name).to_s
29
+
30
+ "#{namespace}/#{controller_name}/#{action_str}"
31
+ end
32
+
33
+ # Build common props for authentication pages
34
+ def auth_page_props(additional_props = {})
35
+ {
36
+ redirect_uri: @redirect_uri,
37
+ connection: @connection,
38
+ flash: {
39
+ notice: flash[:notice],
40
+ alert: flash[:alert]
41
+ }.compact,
42
+ social_providers: {
43
+ google_enabled: StandardId.config.google_client_id.present?,
44
+ apple_enabled: StandardId.config.apple_client_id.present?
45
+ }
46
+ }.deep_merge(additional_props)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ module StandardId
2
+ module InertiaSupport
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ helper_method :use_inertia?
7
+ end
8
+
9
+ private
10
+
11
+ # Check if Inertia rendering should be used
12
+ def use_inertia?
13
+ StandardId.config.use_inertia && inertia_available?
14
+ end
15
+
16
+ # Check if inertia_rails gem is available in the host application
17
+ def inertia_available?
18
+ defined?(::InertiaRails)
19
+ end
20
+
21
+ # Redirect to an external URL or non-Inertia endpoint
22
+ # Uses inertia_location for Inertia requests, otherwise standard redirect_to
23
+ def redirect_with_inertia(url, **options)
24
+ if use_inertia? && request.inertia?
25
+ inertia_location url
26
+ else
27
+ redirect_to url, **options
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ module StandardId
2
+ module SetCurrentRequestDetails
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :set_current_request_details
7
+ end
8
+
9
+ private
10
+
11
+ def set_current_request_details
12
+ return unless defined?(::Current)
13
+
14
+ ::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
15
+ ::Current.ip_address = request.remote_ip if ::Current.respond_to?(:ip_address=)
16
+ ::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
17
+ end
18
+ end
19
+ end