standard_id 0.1.6 → 0.2.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +408 -22
  3. data/app/controllers/concerns/standard_id/set_current_request_details.rb +19 -0
  4. data/app/controllers/concerns/standard_id/social_authentication.rb +86 -37
  5. data/app/controllers/concerns/standard_id/web_authentication.rb +29 -1
  6. data/app/controllers/standard_id/api/base_controller.rb +1 -0
  7. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +7 -18
  8. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +33 -37
  9. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  10. data/app/controllers/standard_id/web/login_controller.rb +6 -19
  11. data/app/controllers/standard_id/web/signup_controller.rb +3 -6
  12. data/app/forms/standard_id/web/signup_form.rb +32 -1
  13. data/app/models/standard_id/browser_session.rb +8 -0
  14. data/app/models/standard_id/client_secret_credential.rb +11 -0
  15. data/app/models/standard_id/device_session.rb +4 -0
  16. data/app/models/standard_id/identifier.rb +28 -0
  17. data/app/models/standard_id/service_session.rb +1 -1
  18. data/app/models/standard_id/session.rb +16 -2
  19. data/app/views/standard_id/web/auth/callback/providers/{apple_mobile.html.erb → mobile_callback.html.erb} +1 -1
  20. data/config/routes/api.rb +1 -2
  21. data/config/routes/web.rb +4 -3
  22. data/lib/generators/standard_id/install/templates/standard_id.rb +11 -8
  23. data/lib/standard_config/config.rb +3 -12
  24. data/lib/standard_config/config_provider.rb +6 -6
  25. data/lib/standard_config/manager.rb +21 -14
  26. data/lib/standard_config/schema.rb +7 -5
  27. data/lib/standard_config.rb +10 -3
  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 +11 -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 +198 -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 +138 -0
  43. data/lib/standard_id/jwt_service.rb +6 -1
  44. data/lib/standard_id/oauth/authorization_code_flow.rb +10 -0
  45. data/lib/standard_id/oauth/client_credentials_flow.rb +31 -0
  46. data/lib/standard_id/oauth/password_flow.rb +36 -4
  47. data/lib/standard_id/oauth/passwordless_otp_flow.rb +38 -2
  48. data/lib/standard_id/oauth/subflows/social_login_grant.rb +11 -22
  49. data/lib/standard_id/oauth/token_grant_flow.rb +22 -1
  50. data/lib/standard_id/passwordless/base_strategy.rb +32 -0
  51. data/lib/standard_id/provider_registry.rb +79 -0
  52. data/lib/standard_id/providers/base.rb +242 -0
  53. data/lib/standard_id/version.rb +1 -1
  54. data/lib/standard_id/web/authentication_guard.rb +29 -0
  55. data/lib/standard_id/web/session_manager.rb +39 -1
  56. data/lib/standard_id/web/token_manager.rb +2 -2
  57. data/lib/standard_id.rb +18 -4
  58. metadata +16 -6
  59. data/lib/standard_id/social_providers/apple.rb +0 -184
  60. data/lib/standard_id/social_providers/google.rb +0 -168
  61. 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: 7a8069c98c5ede27fc1f485806e5a9b150d4f43294d177f59fe89008e11bd289
4
- data.tar.gz: e88dae408bce4db81c42c780985d9e4a833b4864636a2d10b5ff979407dc9562
3
+ metadata.gz: 313792f07d188ad560c11e667abdcb3b7fbfb8d15738d892152beae42b287d65
4
+ data.tar.gz: 4b1811518c974b8e467987fce6abc153ad05f46dd876a1cfe2f40c88c768516c
5
5
  SHA512:
6
- metadata.gz: 19f08dcd3da104952f2313669bc7bceb18ef0ce866ff234a0beb966e64d83294cefc76886c2f5e74080b935271444afeae0057deadcbac7bcfda40f6dd0441b4
7
- data.tar.gz: 2531fba6a76ea0dd4800cebe0444a86cdea8ab516c64888b40b7d60aa681a957af2f3f4fb9622aebc6cf6f25b44a3caf0b3d03f16dcc673ce2f0a30c79599211
6
+ metadata.gz: 2a59a7565ac2a591fef01ff9aa90edc505e8383f3b9b3d2ac5e4ae82a3d777a9902934bd63683d1adff0211fa0dbc0687e0fd3c6c64428b3e0833c512b8bf9c5
7
+ data.tar.gz: 6313c08cd9f375e737563324e2c20a16e9ed3f5f4e25fca793f19c2bd18ad4dff06cba49437b914a5776f164d95d95d33cc880d1b332b74cdaf493e661400e2b
data/README.md CHANGED
@@ -123,9 +123,11 @@ StandardId.configure do |config|
123
123
  # config.use_inertia = true
124
124
  # config.inertia_component_namespace = "auth"
125
125
 
126
- # Passwordless delivery callbacks
127
- # config.passwordless_email_sender = ->(email, code) { UserMailer.send_code(email, code).deliver_now }
128
- # config.passwordless_sms_sender = ->(phone, code) { SmsService.send_code(phone, code) }
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)
129
131
 
130
132
  # Subset configuration
131
133
  # config.password.minimum_length = 12
@@ -186,21 +188,24 @@ StandardId.configure do |config|
186
188
  name: social_info[:name] || social_info[:given_name]
187
189
  }
188
190
  }
189
-
190
- # Optional: run a callback whenever a social login completes
191
- config.social.social_callback = ->(social_info:, provider:, tokens:, account:) {
192
- AuditLog.social_login(
193
- provider: provider,
194
- email: social_info[:email],
195
- tokens: tokens,
196
- account_id: account.id,
197
- )
198
- }
199
191
  end
200
192
  ```
201
193
 
202
194
  `social_info` is an indifferent-access hash containing at least `email`, `name`, and `provider_id`.
203
195
 
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
+
204
209
  ### Inertia.js Integration
205
210
 
206
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.
@@ -355,22 +360,403 @@ end
355
360
 
356
361
  This will redirect unauthenticated users to the login page using `inertia_location` for Inertia requests, ensuring proper SPA navigation.
357
362
 
358
- ### Passwordless Authentication
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:
359
394
 
360
395
  ```ruby
361
396
  StandardId.configure do |config|
362
- # Email delivery
363
- config.passwordless_email_sender = ->(email, code) {
364
- UserMailer.send_code(email, code).deliver_now
365
- }
397
+ config.events.enable_logging = true
398
+ end
399
+ ```
366
400
 
367
- # SMS delivery
368
- config.passwordless_sms_sender = ->(phone, code) {
369
- SmsService.send_code(phone, code)
370
- }
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
+ Every StandardId event automatically carries tracing metadata (`event_id`, `timestamp`, and request-scoped fields like `request_id`, `ip_address`, `user_agent`, `current_account` when available). The table below lists the domain-specific payload fields and when each event fires.
417
+
418
+ | Category | Event | Payload fields | When emitted |
419
+ |----------|-------|----------------|--------------|
420
+ | Authentication | `authentication.attempt.started` | `account_lookup`, `auth_method` | Before credential validation begins |
421
+ | | `authentication.attempt.succeeded` | `account`, `auth_method`, `session_type` | After authentication succeeds |
422
+ | | `authentication.attempt.failed` | `account_lookup`, `auth_method`, `error_code`, `error_message` | After authentication fails |
423
+ | | `authentication.password.failed` | `account_lookup`, `error_code`, `error_message` | After password verification fails |
424
+ | | `authentication.otp.failed` | `identifier`, `channel`, `error_code`, `error_message` | After OTP verification fails |
425
+ | Session | `session.creating` | `account`, `session_type`, `ip_address`, `user_agent` | Before a session record is created |
426
+ | | `session.created` | `session`, `account`, `session_type`, `token_issued`, `ip_address`, `user_agent` | After session persistence completes |
427
+ | | `session.validating` | `session` | Before validating an existing session |
428
+ | | `session.validated` | `session`, `account` | After a session passes validation |
429
+ | | `session.expired` | `session`, `account`, `expired_at` | When validation fails because the session expired |
430
+ | | `session.revoked` | `session`, `account`, `reason` | After a session is explicitly revoked |
431
+ | | `session.refreshed` | `session`, `account`, `old_expires_at`, `new_expires_at` | After a refresh operation extends a session |
432
+ | Account | `account.creating` | `account_params`, `auth_method` | Before an account record is created |
433
+ | | `account.created` | `account`, `auth_method`, `source` (signup/passwordless/social) | After an account record is created |
434
+ | | `account.verified` | `account`, `verified_via` (email/phone) | When an account is marked verified |
435
+ | | `account.status_changed` | `account`, `old_status`, `new_status`, `changed_by` | When account status transitions (Issue #16) |
436
+ | | `account.locked` | `account`, `lock_reason`, `locked_by` | When an account is administratively locked (Issue #17) |
437
+ | | `account.unlocked` | `account`, `unlocked_by` | When an account lock is lifted (Issue #17) |
438
+ | Identifier | `identifier.created` | `identifier`, `account` | After an identifier record is created |
439
+ | | `identifier.verification.started` | `identifier`, `channel` (email/sms), `code_sent` | After a verification code is issued |
440
+ | | `identifier.verification.succeeded` | `identifier`, `account`, `verified_at` | After identifier verification succeeds |
441
+ | | `identifier.verification.failed` | `identifier`, `error_code`, `attempts` | After identifier verification fails |
442
+ | | `identifier.linked` | `identifier`, `account`, `source` (social/manual) | When an identifier is associated to an account |
443
+ | OAuth | `oauth.authorization.requested` | `client_id`, `account`, `scope`, `redirect_uri` | Before issuing an authorization code |
444
+ | | `oauth.authorization.granted` | `authorization_code`, `client_id`, `account`, `scope` | After an authorization code is created |
445
+ | | `oauth.authorization.denied` | `client_id`, `account`, `reason` | When a user denies authorization |
446
+ | | `oauth.token.issuing` | `grant_type`, `client_id`, `account`, `scope` | Before generating access/refresh tokens |
447
+ | | `oauth.token.issued` | `access_token_id`, `grant_type`, `client_id`, `account`, `expires_in` | After tokens are generated |
448
+ | | `oauth.token.refreshed` | `old_token_id`, `new_token_id`, `client_id`, `account` | After a refresh token is redeemed |
449
+ | | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
450
+ | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms) | Before generating an OTP |
451
+ | | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
452
+ | | `passwordless.code.sent` | `identifier`, `channel`, `delivery_status` | After an OTP is delivered |
453
+ | | `passwordless.code.verified` | `code_challenge`, `account`, `channel` | After OTP verification succeeds |
454
+ | | `passwordless.code.failed` | `identifier`, `channel`, `attempts` | After OTP verification fails |
455
+ | | `passwordless.account.created` | `account`, `channel`, `identifier` | When an account is created via passwordless flow |
456
+ | Social | `social.auth.started` | `provider`, `redirect_uri`, `state` | Before redirecting to a social provider |
457
+ | | `social.auth.callback_received` | `provider`, `code`, `state` | After the provider redirects back |
458
+ | | `social.user_info.fetched` | `provider`, `social_info`, `email` | After fetching user info from the provider |
459
+ | | `social.account.created` | `account`, `provider`, `social_info` | When a social login creates a new account |
460
+ | | `social.account.linked` | `account`, `provider`, `identifier` | When a social identity links to an existing account |
461
+ | | `social.auth.completed` | `account`, `provider`, `tokens` | After social login completes |
462
+ | Credential | `credential.password.created` | `credential`, `account` | After a password credential is created |
463
+ | | `credential.password.reset_initiated` | `credential`, `account`, `reset_token_expires_at` | After a password reset is initiated |
464
+ | | `credential.password.reset_completed` | `credential`, `account` | After a password reset is confirmed |
465
+ | | `credential.password.changed` | `credential`, `account`, `changed_by` | After a password is updated |
466
+ | | `credential.client_secret.created` | `credential`, `client_id` | After a client secret is created |
467
+ | | `credential.client_secret.rotated` | `credential`, `client_id`, `old_secret_revoked_at` | After a client secret rotation |
468
+
469
+ ### Subscribing to Events
470
+
471
+ #### Block-based (simple)
472
+
473
+ ```ruby
474
+ # config/initializers/standard_id_events.rb
475
+ StandardId::Events.subscribe(StandardId::Events::AUTHENTICATION_SUCCEEDED) do |event|
476
+ Analytics.track_login(
477
+ account_id: event[:account].id,
478
+ method: event[:auth_method],
479
+ ip: event[:ip_address]
480
+ )
481
+ end
482
+
483
+ # Subscribe to multiple events at once
484
+ StandardId::Events.subscribe(
485
+ StandardId::Events::SESSION_CREATING,
486
+ StandardId::Events::SESSION_VALIDATING,
487
+ StandardId::Events::OAUTH_TOKEN_ISSUING
488
+ ) do |event|
489
+ # Handle all three events with the same block
490
+ check_rate_limit(event[:account], event[:ip_address])
491
+ end
492
+
493
+ # Subscribe to events with pattern matching
494
+ StandardId::Events.subscribe(/social/) do |event|
495
+ Rails.logger.info("Social event: #{event.name}")
496
+ end
497
+ ```
498
+
499
+ #### Class-based (complex logic)
500
+
501
+ ```ruby
502
+ # app/subscribers/audit_subscriber.rb
503
+ class AuditSubscriber < StandardId::Events::Subscribers::Base
504
+ subscribe_to StandardId::Events::SECURITY_EVENTS
505
+
506
+ def call(event)
507
+ AuditLog.create!(
508
+ event_type: event.short_name,
509
+ account_id: event[:account]&.id,
510
+ ip_address: event[:ip_address],
511
+ metadata: event.payload
512
+ )
513
+ end
514
+ end
515
+
516
+ # config/initializers/standard_id_events.rb
517
+ AuditSubscriber.attach
518
+ ```
519
+
520
+ ## Account Status (Activation/Deactivation)
521
+
522
+ 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.
523
+
524
+ ### Setup
525
+
526
+ 1. Add a migration for the status column. For PostgreSQL (recommended), use a native enum type:
527
+
528
+ ```ruby
529
+ # PostgreSQL with native enum (recommended)
530
+ class AddStatusToUsers < ActiveRecord::Migration[8.0]
531
+ def up
532
+ create_enum :account_status, %w[active inactive]
533
+
534
+ add_column :users, :status, :enum, enum_type: :account_status, default: "active", null: false
535
+ add_column :users, :activated_at, :datetime
536
+ add_column :users, :deactivated_at, :datetime
537
+ end
538
+
539
+ def down
540
+ remove_column :users, :status
541
+ remove_column :users, :activated_at
542
+ remove_column :users, :deactivated_at
543
+
544
+ drop_enum :account_status
545
+ end
546
+ end
547
+ ```
548
+
549
+ For other databases (MySQL, SQLite), use a string column:
550
+
551
+ ```ruby
552
+ # String column (MySQL, SQLite)
553
+ class AddStatusToUsers < ActiveRecord::Migration[8.0]
554
+ def change
555
+ add_column :users, :status, :string, default: "active", null: false
556
+ add_column :users, :activated_at, :datetime
557
+ add_column :users, :deactivated_at, :datetime
558
+ add_index :users, :status
559
+ end
560
+ end
561
+ ```
562
+
563
+ 2. Include the concern in your account model:
564
+
565
+ ```ruby
566
+ class User < ApplicationRecord
567
+ include StandardId::AccountStatus
568
+ # ...
569
+ end
570
+ ```
571
+
572
+ The concern works with both PostgreSQL enum and string columns - Rails enum handles both transparently.
573
+
574
+ ### Usage
575
+
576
+ ```ruby
577
+ # Deactivate an account
578
+ user.deactivate!
579
+ # => Emits ACCOUNT_DEACTIVATED event
580
+ # => All active sessions are automatically revoked
581
+
582
+ # Reactivate an account
583
+ user.activate!
584
+ # => Emits ACCOUNT_ACTIVATED event
585
+ # => User can log in again
586
+
587
+ # Check status
588
+ user.active? # => true/false
589
+ user.inactive? # => true/false
590
+
591
+ # Query scopes
592
+ User.active # => Users with status 'active'
593
+ User.inactive # => Users with status 'inactive'
594
+ ```
595
+
596
+ ### Handling AccountDeactivatedError
597
+
598
+ When an inactive account attempts to authenticate, `StandardId::AccountDeactivatedError` is raised. You need to handle this error in your application controller:
599
+
600
+ ```ruby
601
+ # app/controllers/application_controller.rb
602
+ class ApplicationController < ActionController::Base
603
+ include StandardId::WebAuthentication
604
+
605
+ rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
606
+
607
+ private
608
+
609
+ def handle_account_deactivated
610
+ # For web requests, redirect with a message
611
+ redirect_to login_path, alert: "Your account has been deactivated. Please contact support."
612
+ end
613
+ end
614
+ ```
615
+
616
+ For API controllers:
617
+
618
+ ```ruby
619
+ # app/controllers/api/base_controller.rb
620
+ class Api::BaseController < ActionController::API
621
+ include StandardId::ApiAuthentication
622
+
623
+ rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
624
+
625
+ private
626
+
627
+ def handle_account_deactivated
628
+ render json: {
629
+ error: "account_deactivated",
630
+ message: "Your account has been deactivated"
631
+ }, status: :forbidden
632
+ end
371
633
  end
372
634
  ```
373
635
 
636
+ ## Account Locking (Administrative Security)
637
+
638
+ 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.
639
+
640
+ ### Key Differences from Account Deactivation
641
+
642
+ | Feature | Account Status | Account Locking |
643
+ |---------|---------------|-----------------|
644
+ | **Purpose** | Lifecycle management | Security enforcement |
645
+ | **Who Controls** | System/User | Admin/Staff only |
646
+ | **User Reversible** | Yes (future) | No |
647
+ | **Use Cases** | Inactivity, user choice | Policy violation, security incident, fraud |
648
+
649
+ An account can be in any combination:
650
+ - Active + Unlocked ✅ (normal operation)
651
+ - Active + Locked ⚠️ (admin locked for security)
652
+ - Inactive + Unlocked ⚠️ (deactivated but not locked)
653
+ - Inactive + Locked 🚫 (both restrictions apply)
654
+
655
+ ### Setup
656
+
657
+ 1. Add a migration for the locking columns:
658
+
659
+ ```ruby
660
+ class AddLockingToUsers < ActiveRecord::Migration[8.0]
661
+ def change
662
+ add_column :users, :locked, :boolean, default: false, null: false
663
+ add_column :users, :locked_at, :datetime
664
+ add_column :users, :lock_reason, :string
665
+ add_column :users, :locked_by_id, :integer
666
+ add_column :users, :locked_by_type, :string
667
+ add_column :users, :unlocked_at, :datetime
668
+ add_column :users, :unlocked_by_id, :integer
669
+ add_column :users, :unlocked_by_type, :string
670
+
671
+ add_index :users, :locked
672
+ add_index :users, [:locked_by_type, :locked_by_id]
673
+ end
674
+ end
675
+ ```
676
+
677
+ 2. Include the concern in your account model:
678
+
679
+ ```ruby
680
+ class User < ApplicationRecord
681
+ include StandardId::AccountLocking # For admin locking
682
+ include StandardId::AccountStatus # Optional: for activation/deactivation
683
+ # ...
684
+ end
685
+ ```
686
+
687
+ ### Usage
688
+
689
+ ```ruby
690
+ # Lock an account (revokes all active sessions immediately)
691
+ user.lock!(reason: "Suspicious activity detected", locked_by: current_admin)
692
+ # => Emits ACCOUNT_LOCKED event
693
+ # => All active sessions (browser, device, service) are revoked
694
+
695
+ # Unlock an account (user must log in again)
696
+ user.unlock!(unlocked_by: current_admin)
697
+ # => Emits ACCOUNT_UNLOCKED event
698
+ # => User can log in again
699
+
700
+ # Check lock status
701
+ user.locked? # => true/false
702
+ user.unlocked? # => true/false
703
+
704
+ # Query scopes
705
+ User.locked # => Users with locked = true
706
+ User.unlocked # => Users with locked = false
707
+
708
+ # Combine with AccountStatus scopes
709
+ User.unlocked.active # => Users who can log in
710
+ ```
711
+
712
+ ### Handling AccountLockedError
713
+
714
+ When a locked account attempts to authenticate, `StandardId::AccountLockedError` is raised. The error includes metadata about the lock:
715
+
716
+ ```ruby
717
+ # app/controllers/application_controller.rb
718
+ class ApplicationController < ActionController::Base
719
+ include StandardId::WebAuthentication
720
+
721
+ rescue_from StandardId::AccountLockedError, with: :handle_account_locked
722
+
723
+ private
724
+
725
+ def handle_account_locked(error)
726
+ # error.account - The locked account
727
+ # error.lock_reason - Why the account was locked
728
+ # error.locked_at - When the account was locked
729
+ redirect_to login_path, alert: "Your account has been locked. Please contact support."
730
+ end
731
+ end
732
+ ```
733
+
734
+ For API controllers:
735
+
736
+ ```ruby
737
+ # app/controllers/api/base_controller.rb
738
+ class Api::BaseController < ActionController::API
739
+ include StandardId::ApiAuthentication
740
+
741
+ rescue_from StandardId::AccountLockedError, with: :handle_account_locked
742
+
743
+ private
744
+
745
+ def handle_account_locked(error)
746
+ render json: {
747
+ error: "account_locked",
748
+ message: "Your account has been locked. Please contact support.",
749
+ locked_at: error.locked_at&.iso8601
750
+ # Note: Consider not exposing lock_reason to end users for security
751
+ }, status: :forbidden
752
+ end
753
+ end
754
+ ```
755
+
756
+ ### Event Subscriptions
757
+
758
+ 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.
759
+
374
760
  ## Usage Examples
375
761
 
376
762
  ### Web Authentication
@@ -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
@@ -2,64 +2,70 @@ module StandardId
2
2
  module SocialAuthentication
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ included do
6
+ prepend_before_action :prepare_provider
7
+ end
8
+
5
9
  private
6
10
 
7
- def get_user_info_from_provider(connection, redirect_uri: nil, flow: :web)
8
- case connection
9
- when "google"
10
- StandardId::SocialProviders::Google.get_user_info(
11
- code: params[:code],
12
- id_token: params[:id_token],
13
- access_token: params[:access_token],
14
- redirect_uri: redirect_uri
15
- )
16
- when "apple"
17
- StandardId::SocialProviders::Apple.get_user_info(
18
- code: params[:code],
19
- id_token: params[:id_token],
20
- redirect_uri: redirect_uri,
21
- client_id: apple_client_id_for_flow(flow)
22
- )
23
- else
24
- raise StandardId::InvalidRequestError, "Unsupported provider: #{connection}"
25
- end
11
+ attr_reader :provider
12
+
13
+ def prepare_provider
14
+ @provider = StandardId::ProviderRegistry.get(params[:provider])
15
+ rescue StandardId::ProviderRegistry::ProviderNotFoundError => e
16
+ raise StandardId::InvalidRequestError, e.message
26
17
  end
27
18
 
28
- def apple_client_id_for_flow(flow)
29
- flow == :web ? StandardId.config.apple_client_id : StandardId.config.apple_mobile_client_id
19
+ def get_user_info_from_provider(redirect_uri: nil, flow: :web)
20
+ provider_params = {
21
+ code: params[:code],
22
+ id_token: params[:id_token],
23
+ access_token: params[:access_token],
24
+ redirect_uri: redirect_uri
25
+ }
26
+
27
+ resolved_params = provider.resolve_params(provider_params, context: { flow: flow })
28
+ provider.get_user_info(**resolved_params.compact)
30
29
  end
31
30
 
32
- def find_or_create_account_from_social(raw_social_info, provider)
31
+ def find_or_create_account_from_social(raw_social_info)
33
32
  social_info = raw_social_info.to_h.with_indifferent_access
34
33
  email = social_info[:email]
35
- raise StandardId::InvalidRequestError, "No email provided by #{provider}" if email.blank?
34
+ raise StandardId::InvalidRequestError, "No email provided by #{provider.provider_name}" if email.blank?
35
+
36
+ emit_social_user_info_fetched(provider, social_info, email)
36
37
 
37
38
  identifier = StandardId::EmailIdentifier.find_by(value: email)
38
39
 
39
40
  if identifier.present?
41
+ emit_social_account_linked(identifier.account, provider, identifier)
40
42
  identifier.account
41
43
  else
42
- account = build_account_from_social(social_info, provider)
44
+ account = build_account_from_social(social_info)
43
45
  identifier = StandardId::EmailIdentifier.create!(
44
46
  account: account,
45
47
  value: email
46
48
  )
47
49
  identifier.verify! if identifier.respond_to?(:verify!)
50
+ emit_social_account_created(account, provider, social_info)
48
51
  account
49
52
  end
50
53
  end
51
54
 
52
- def build_account_from_social(social_info, provider)
53
- attrs = resolve_account_attributes(social_info, provider)
54
- StandardId.account_class.create!(attrs)
55
+ def build_account_from_social(social_info)
56
+ emit_account_creating_from_social(social_info)
57
+ attrs = resolve_account_attributes(social_info)
58
+ account = StandardId.account_class.create!(attrs)
59
+ emit_account_created_from_social(account)
60
+ account
55
61
  end
56
62
 
57
- def resolve_account_attributes(social_info, provider)
63
+ def resolve_account_attributes(social_info)
58
64
  resolver = StandardId.config.social_account_attributes
59
65
  attrs = if resolver.respond_to?(:call)
60
66
  payload = {
61
67
  social_info: social_info,
62
- provider: provider
68
+ provider: provider.provider_name
63
69
  }
64
70
 
65
71
  filtered_payload = StandardId::Utils::CallableParameterFilter.filter(resolver, payload)
@@ -95,18 +101,61 @@ module StandardId
95
101
  end
96
102
 
97
103
  def run_social_callback(provider:, social_info:, provider_tokens:, account:)
98
- callback = StandardId.config.social_callback
99
- return if callback.blank?
104
+ emit_social_auth_completed(provider, social_info, provider_tokens, account)
105
+ end
100
106
 
101
- payload = {
107
+ def emit_social_user_info_fetched(provider, social_info, email)
108
+ StandardId::Events.publish(
109
+ StandardId::Events::SOCIAL_USER_INFO_FETCHED,
102
110
  provider: provider,
103
111
  social_info: social_info,
104
- tokens: provider_tokens.presence,
105
- account: account
106
- }
112
+ email: email
113
+ )
114
+ end
115
+
116
+ def emit_social_account_created(account, provider, social_info)
117
+ StandardId::Events.publish(
118
+ StandardId::Events::SOCIAL_ACCOUNT_CREATED,
119
+ account: account,
120
+ provider: provider,
121
+ social_info: social_info
122
+ )
123
+ end
124
+
125
+ def emit_social_account_linked(account, provider, identifier)
126
+ StandardId::Events.publish(
127
+ StandardId::Events::SOCIAL_ACCOUNT_LINKED,
128
+ account: account,
129
+ provider: provider,
130
+ identifier: identifier
131
+ )
132
+ end
133
+
134
+ def emit_social_auth_completed(provider, social_info, provider_tokens, account)
135
+ StandardId::Events.publish(
136
+ StandardId::Events::SOCIAL_AUTH_COMPLETED,
137
+ account: account,
138
+ provider: provider,
139
+ social_info: social_info,
140
+ tokens: provider_tokens
141
+ )
142
+ end
143
+
144
+ def emit_account_creating_from_social(social_info)
145
+ StandardId::Events.publish(
146
+ StandardId::Events::ACCOUNT_CREATING,
147
+ account_params: resolve_account_attributes(social_info),
148
+ auth_method: "social:#{provider.provider_name}"
149
+ )
150
+ end
107
151
 
108
- filtered_payload = StandardId::Utils::CallableParameterFilter.filter(callback, payload)
109
- callback.call(**filtered_payload.symbolize_keys)
152
+ def emit_account_created_from_social(account)
153
+ StandardId::Events.publish(
154
+ StandardId::Events::ACCOUNT_CREATED,
155
+ account: account,
156
+ auth_method: "social:#{provider.provider_name}",
157
+ source: "social"
158
+ )
110
159
  end
111
160
  end
112
161
  end