standard_id 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 (100) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/stylesheets/standard_id/application.css +15 -0
  6. data/app/controllers/concerns/standard_id/api_authentication.rb +29 -0
  7. data/app/controllers/concerns/standard_id/web_authentication.rb +51 -0
  8. data/app/controllers/standard_id/api/authorization_controller.rb +73 -0
  9. data/app/controllers/standard_id/api/base_controller.rb +61 -0
  10. data/app/controllers/standard_id/api/oauth/base_controller.rb +22 -0
  11. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +44 -0
  12. data/app/controllers/standard_id/api/oidc/logout_controller.rb +50 -0
  13. data/app/controllers/standard_id/api/passwordless_controller.rb +38 -0
  14. data/app/controllers/standard_id/api/providers_controller.rb +175 -0
  15. data/app/controllers/standard_id/api/userinfo_controller.rb +36 -0
  16. data/app/controllers/standard_id/web/account_controller.rb +32 -0
  17. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +126 -0
  18. data/app/controllers/standard_id/web/base_controller.rb +14 -0
  19. data/app/controllers/standard_id/web/login_controller.rb +69 -0
  20. data/app/controllers/standard_id/web/logout_controller.rb +20 -0
  21. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +46 -0
  22. data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -0
  23. data/app/controllers/standard_id/web/sessions_controller.rb +26 -0
  24. data/app/controllers/standard_id/web/signup_controller.rb +83 -0
  25. data/app/forms/standard_id/web/reset_password_confirm_form.rb +37 -0
  26. data/app/forms/standard_id/web/reset_password_start_form.rb +38 -0
  27. data/app/forms/standard_id/web/signup_form.rb +65 -0
  28. data/app/helpers/standard_id/application_helper.rb +4 -0
  29. data/app/jobs/standard_id/application_job.rb +4 -0
  30. data/app/mailers/standard_id/application_mailer.rb +6 -0
  31. data/app/models/concerns/standard_id/account_associations.rb +14 -0
  32. data/app/models/concerns/standard_id/credentiable.rb +12 -0
  33. data/app/models/standard_id/application_record.rb +5 -0
  34. data/app/models/standard_id/authorization_code.rb +86 -0
  35. data/app/models/standard_id/browser_session.rb +27 -0
  36. data/app/models/standard_id/client_application.rb +143 -0
  37. data/app/models/standard_id/client_secret_credential.rb +63 -0
  38. data/app/models/standard_id/credential.rb +16 -0
  39. data/app/models/standard_id/device_session.rb +38 -0
  40. data/app/models/standard_id/email_identifier.rb +5 -0
  41. data/app/models/standard_id/identifier.rb +25 -0
  42. data/app/models/standard_id/password_credential.rb +24 -0
  43. data/app/models/standard_id/passwordless_challenge.rb +30 -0
  44. data/app/models/standard_id/phone_number_identifier.rb +5 -0
  45. data/app/models/standard_id/service_session.rb +44 -0
  46. data/app/models/standard_id/session.rb +54 -0
  47. data/app/models/standard_id/username_identifier.rb +5 -0
  48. data/app/views/standard_id/web/account/edit.html.erb +26 -0
  49. data/app/views/standard_id/web/account/show.html.erb +31 -0
  50. data/app/views/standard_id/web/login/show.html.erb +108 -0
  51. data/app/views/standard_id/web/reset_password/confirm/show.html.erb +27 -0
  52. data/app/views/standard_id/web/reset_password/start/show.html.erb +20 -0
  53. data/app/views/standard_id/web/sessions/index.html.erb +112 -0
  54. data/app/views/standard_id/web/signup/show.html.erb +96 -0
  55. data/config/initializers/generators.rb +9 -0
  56. data/config/initializers/migration_helpers.rb +32 -0
  57. data/config/routes/api.rb +24 -0
  58. data/config/routes/web.rb +26 -0
  59. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +56 -0
  60. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +10 -0
  61. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +17 -0
  62. data/db/migrate/20250831075703_create_standard_id_credentials.rb +10 -0
  63. data/db/migrate/20250831154635_create_standard_id_sessions.rb +43 -0
  64. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +20 -0
  65. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +46 -0
  66. data/db/migrate/20250903135906_create_standard_id_passwordless_challenges.rb +22 -0
  67. data/lib/generators/standard_id/install/install_generator.rb +14 -0
  68. data/lib/generators/standard_id/install/templates/standard_id.rb +11 -0
  69. data/lib/standard_id/api/authentication_guard.rb +20 -0
  70. data/lib/standard_id/api/session_manager.rb +39 -0
  71. data/lib/standard_id/api/token_manager.rb +50 -0
  72. data/lib/standard_id/api_engine.rb +7 -0
  73. data/lib/standard_id/config.rb +69 -0
  74. data/lib/standard_id/engine.rb +5 -0
  75. data/lib/standard_id/errors.rb +55 -0
  76. data/lib/standard_id/jwt_service.rb +50 -0
  77. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +47 -0
  78. data/lib/standard_id/oauth/authorization_code_flow.rb +53 -0
  79. data/lib/standard_id/oauth/authorization_flow.rb +91 -0
  80. data/lib/standard_id/oauth/base_request_flow.rb +43 -0
  81. data/lib/standard_id/oauth/client_credentials_flow.rb +38 -0
  82. data/lib/standard_id/oauth/implicit_authorization_flow.rb +79 -0
  83. data/lib/standard_id/oauth/password_flow.rb +70 -0
  84. data/lib/standard_id/oauth/passwordless_otp_flow.rb +87 -0
  85. data/lib/standard_id/oauth/refresh_token_flow.rb +61 -0
  86. data/lib/standard_id/oauth/subflows/base.rb +19 -0
  87. data/lib/standard_id/oauth/subflows/social_login_grant.rb +66 -0
  88. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +52 -0
  89. data/lib/standard_id/oauth/token_grant_flow.rb +107 -0
  90. data/lib/standard_id/passwordless/base_strategy.rb +67 -0
  91. data/lib/standard_id/passwordless/email_strategy.rb +27 -0
  92. data/lib/standard_id/passwordless/sms_strategy.rb +29 -0
  93. data/lib/standard_id/version.rb +3 -0
  94. data/lib/standard_id/web/authentication_guard.rb +23 -0
  95. data/lib/standard_id/web/session_manager.rb +71 -0
  96. data/lib/standard_id/web/token_manager.rb +30 -0
  97. data/lib/standard_id/web_engine.rb +7 -0
  98. data/lib/standard_id.rb +49 -0
  99. data/lib/tasks/standard_id_tasks.rake +4 -0
  100. metadata +186 -0
@@ -0,0 +1,86 @@
1
+ module StandardId
2
+ class AuthorizationCode < ApplicationRecord
3
+ self.table_name = "standard_id_authorization_codes"
4
+
5
+ belongs_to :account, class_name: StandardId.config.account_class_name, optional: true
6
+
7
+ # Validations
8
+ validates :code_hash, presence: true, uniqueness: true
9
+ validates :client_id, presence: true
10
+ validates :redirect_uri, presence: true
11
+ validates :issued_at, presence: true
12
+ validates :expires_at, presence: true
13
+
14
+ scope :unexpired, -> { where("expires_at > ?", Time.current) }
15
+ scope :unused, -> { where(consumed_at: nil) }
16
+
17
+ before_validation :set_issued_and_expiry, on: :create
18
+
19
+ def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, metadata: {})
20
+ create!(
21
+ account: account,
22
+ code_hash: hash_for(plaintext_code),
23
+ client_id: client_id,
24
+ redirect_uri: redirect_uri,
25
+ scope: scope,
26
+ audience: audience,
27
+ code_challenge: code_challenge,
28
+ code_challenge_method: code_challenge_method,
29
+ issued_at: Time.current,
30
+ expires_at: Time.current + default_ttl,
31
+ metadata: metadata || {}
32
+ )
33
+ end
34
+
35
+ def self.lookup(plaintext_code)
36
+ find_by(code_hash: hash_for(plaintext_code))
37
+ end
38
+
39
+ def self.hash_for(plaintext_code)
40
+ Digest::SHA256.hexdigest("#{plaintext_code}:#{Rails.configuration.secret_key_base}")
41
+ end
42
+
43
+ def self.default_ttl
44
+ 10.minutes
45
+ end
46
+
47
+ def valid_for_client?(client_id)
48
+ self.client_id == client_id && consumed_at.nil? && !expired?
49
+ end
50
+
51
+ def expired?
52
+ expires_at <= Time.current
53
+ end
54
+
55
+ def pkce_valid?(code_verifier)
56
+ return true if code_challenge.blank?
57
+
58
+ return false if code_verifier.blank?
59
+
60
+ case (code_challenge_method || "plain").downcase
61
+ when "s256"
62
+ expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=")
63
+ ActiveSupport::SecurityUtils.secure_compare(expected, code_challenge)
64
+ when "plain"
65
+ ActiveSupport::SecurityUtils.secure_compare(code_verifier, code_challenge)
66
+ else
67
+ false
68
+ end
69
+ end
70
+
71
+ def mark_as_used!
72
+ with_lock do
73
+ raise StandardId::InvalidGrantError, "Authorization code already used" if consumed_at.present?
74
+ raise StandardId::InvalidGrantError, "Authorization code expired" if expired?
75
+ update!(consumed_at: Time.current)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def set_issued_and_expiry
82
+ self.issued_at ||= Time.current
83
+ self.expires_at ||= issued_at + self.class.default_ttl
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,27 @@
1
+ module StandardId
2
+ class BrowserSession < Session
3
+ validates :user_agent, presence: true
4
+
5
+ def browser_info
6
+ return {} if user_agent.blank?
7
+
8
+ # Simple user agent parsing - in production you might want to use a gem like browser
9
+ case user_agent
10
+ when /Edge/i
11
+ { browser: "Edge", type: "browser" }
12
+ when /Chrome/i
13
+ { browser: "Chrome", type: "browser" }
14
+ when /Firefox/i
15
+ { browser: "Firefox", type: "browser" }
16
+ when /Safari/i
17
+ { browser: "Safari", type: "browser" }
18
+ else
19
+ { browser: "Unknown", type: "browser" }
20
+ end
21
+ end
22
+
23
+ def display_name
24
+ "#{browser_info[:browser]} Browser Session"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,143 @@
1
+ module StandardId
2
+ class ClientApplication < ApplicationRecord
3
+ self.table_name = "standard_id_client_applications"
4
+ belongs_to :owner, polymorphic: true
5
+
6
+ has_many :client_secret_credentials, dependent: :destroy
7
+ has_many :authorization_codes, foreign_key: :client_id, primary_key: :client_id, dependent: :destroy
8
+
9
+ accepts_nested_attributes_for :client_secret_credentials, allow_destroy: false
10
+
11
+ # Validations
12
+ validates :name, presence: true, length: { maximum: 255 }
13
+ validates :description, length: { maximum: 1000 }
14
+ validates :redirect_uris, presence: true
15
+ validates :client_type, inclusion: { in: %w[confidential public] }
16
+ validates :grant_types, presence: true
17
+ validates :response_types, presence: true
18
+ validates :scopes, presence: true
19
+ validates :code_challenge_methods, presence: true, if: :require_pkce?
20
+
21
+ # Lifecycle validations
22
+ validates :access_token_lifetime, :refresh_token_lifetime, :authorization_code_lifetime,
23
+ presence: true, numericality: { greater_than: 0 }
24
+
25
+ # Scopes
26
+ scope :active, -> { where(active: true) }
27
+ scope :confidential, -> { where(client_type: "confidential") }
28
+ scope :public_clients, -> { where(client_type: "public") }
29
+ scope :for_owner, ->(owner) { where(owner: owner) }
30
+
31
+ # Callbacks
32
+ before_create :generate_client_id
33
+ before_update :set_deactivated_at, if: :will_save_change_to_active?
34
+
35
+ def deactivate!
36
+ update!(active: false, deactivated_at: Time.current)
37
+ end
38
+
39
+ def activate!
40
+ update!(active: true, deactivated_at: nil)
41
+ end
42
+
43
+ def active?
44
+ active && deactivated_at.nil?
45
+ end
46
+
47
+ # OAuth configuration helpers
48
+ def redirect_uris_array
49
+ redirect_uris.to_s.split(/\s+/).map(&:strip).reject(&:blank?)
50
+ end
51
+
52
+ def scopes_array
53
+ scopes.to_s.split(/\s+/).map(&:strip).reject(&:blank?)
54
+ end
55
+
56
+ def grant_types_array
57
+ grant_types.to_s.split(/\s+/).map(&:strip).reject(&:blank?)
58
+ end
59
+
60
+ def response_types_array
61
+ response_types.to_s.split(/\s+/).map(&:strip).reject(&:blank?)
62
+ end
63
+
64
+ def code_challenge_methods_array
65
+ code_challenge_methods.to_s.split(/\s+/).map(&:strip).reject(&:blank?)
66
+ end
67
+
68
+ def supports_grant_type?(grant_type)
69
+ grant_types_array.include?(grant_type.to_s)
70
+ end
71
+
72
+ def supports_response_type?(response_type)
73
+ response_types_array.include?(response_type.to_s)
74
+ end
75
+
76
+ def supports_pkce_method?(method)
77
+ return false unless require_pkce?
78
+ code_challenge_methods_array.include?(method.to_s)
79
+ end
80
+
81
+ def valid_redirect_uri?(uri)
82
+ redirect_uris_array.include?(uri.to_s)
83
+ end
84
+
85
+ def confidential?
86
+ client_type == "confidential"
87
+ end
88
+
89
+ def public?
90
+ client_type == "public"
91
+ end
92
+
93
+ # Generate a new client secret credential
94
+ def create_client_secret!(name: "Default Secret", **options)
95
+ client_secret_credentials.create!({
96
+ name: name,
97
+ client_id: client_id
98
+ }.merge(options))
99
+ end
100
+
101
+ # Get the primary (first active) client secret
102
+ def primary_client_secret
103
+ client_secret_credentials.active.first
104
+ end
105
+
106
+ # Client secret rotation support
107
+ def rotate_client_secret!(new_secret_name: "Rotated Secret #{Time.current.strftime('%Y%m%d')}", client_secret: SecureRandom.hex(32))
108
+ transaction do
109
+ # Create new secret
110
+ new_secret = create_client_secret!(name: new_secret_name, client_secret: client_secret)
111
+
112
+ # Deactivate old secrets (but don't delete for audit trail)
113
+ client_secret_credentials.where.not(id: new_secret.id).update_all(
114
+ active: false,
115
+ revoked_at: Time.current
116
+ )
117
+
118
+ new_secret
119
+ end
120
+ end
121
+
122
+ # Check if client can authenticate with given secret
123
+ def authenticate_client_secret(secret)
124
+ client_secret_credentials.active.find { |cred| cred.authenticate_client_secret(secret) }
125
+ end
126
+
127
+ private
128
+
129
+ def generate_client_id
130
+ self.client_id ||= SecureRandom.hex(16)
131
+ end
132
+
133
+ def set_deactivated_at
134
+ if will_save_change_to_active?
135
+ if active?
136
+ self.deactivated_at = nil
137
+ else
138
+ self.deactivated_at = Time.current if deactivated_at.nil?
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,63 @@
1
+ require "securerandom"
2
+
3
+ module StandardId
4
+ class ClientSecretCredential < ApplicationRecord
5
+ include StandardId::Credentiable
6
+
7
+ belongs_to :client_application, class_name: "StandardId::ClientApplication"
8
+ has_secure_password :client_secret
9
+
10
+ before_validation :set_client_id_from_client, on: :create
11
+ before_validation :ensure_client_secret, on: :create
12
+
13
+ validates :name, presence: true
14
+ validates :client_id, presence: true
15
+ validates :active, inclusion: { in: [true, false] }
16
+
17
+ scope :active, -> { where(active: true, revoked_at: nil) }
18
+
19
+ def revoke!
20
+ update!(active: false, revoked_at: Time.current)
21
+ end
22
+
23
+ def active?
24
+ active && revoked_at.nil?
25
+ end
26
+
27
+ def scopes_array
28
+ (scopes || "").split(" ").map(&:strip).reject(&:blank?)
29
+ end
30
+
31
+ def default_redirect_uri
32
+ redirect_uris&.split(" ")&.first
33
+ end
34
+
35
+ # Effective configuration with per-secret override fallback
36
+ def effective_scopes_array
37
+ return scopes_array if scopes.present?
38
+ client_application.scopes_array
39
+ end
40
+
41
+ def effective_redirect_uris_array
42
+ return redirect_uris.to_s.split(/\s+/).map(&:strip).reject(&:blank?) if redirect_uris.present?
43
+ client_application.redirect_uris_array
44
+ end
45
+
46
+ def effective_default_redirect_uri
47
+ effective_redirect_uris_array.first
48
+ end
49
+
50
+ private
51
+
52
+ def set_client_id_from_client
53
+ self.client_id = client_application&.client_id if client_id.blank?
54
+ end
55
+
56
+ def ensure_client_secret
57
+ self.client_secret ||= SecureRandom.hex(32)
58
+ end
59
+
60
+ # Note: We intentionally do not enforce subset validation for per-secret overrides here.
61
+ # If needed later, we can introduce a configuration flag to enable enforcement.
62
+ end
63
+ end
@@ -0,0 +1,16 @@
1
+ module StandardId
2
+ class Credential < ApplicationRecord
3
+ belongs_to :identifier, class_name: "StandardId::Identifier"
4
+
5
+ accepts_nested_attributes_for :identifier
6
+
7
+ delegate :account, to: :identifier
8
+
9
+ delegated_type :credentialable, types: %w[PasswordCredential ClientSecretCredential]
10
+
11
+ # Internal alias for future flexibility with multiple subject types
12
+ def subject
13
+ account
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ module StandardId
2
+ class DeviceSession < Session
3
+ validates :device_id, presence: true
4
+ validates :device_agent, presence: true
5
+
6
+ def device_info
7
+ return {} if device_agent.blank?
8
+
9
+ # Simple device agent parsing
10
+ case device_agent
11
+ when /iOS/i
12
+ { platform: "iOS", type: "mobile" }
13
+ when /Android/i
14
+ { platform: "Android", type: "mobile" }
15
+ when /Windows/i
16
+ { platform: "Windows", type: "desktop" }
17
+ when /Mac/i
18
+ { platform: "macOS", type: "desktop" }
19
+ when /Linux/i
20
+ { platform: "Linux", type: "desktop" }
21
+ else
22
+ { platform: "Unknown", type: "unknown" }
23
+ end
24
+ end
25
+
26
+ def display_name
27
+ "#{device_info[:platform]} Device Session"
28
+ end
29
+
30
+ def refresh!
31
+ update!(last_refreshed_at: Time.current)
32
+ end
33
+
34
+ def stale?
35
+ last_refreshed_at.nil? || last_refreshed_at < 1.hour.ago
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ module StandardId
2
+ class EmailIdentifier < Identifier
3
+ validates :value, format: { with: URI::MailTo::EMAIL_REGEXP }
4
+ end
5
+ end
@@ -0,0 +1,25 @@
1
+ module StandardId
2
+ class Identifier < ApplicationRecord
3
+ belongs_to :account, class_name: StandardId.config.account_class_name
4
+
5
+ has_many :credentials, class_name: "StandardId::Credential", dependent: :restrict_with_exception
6
+
7
+ scope :verified, -> { where.not(verified_at: nil) }
8
+ scope :unverified, -> { where(verified_at: nil) }
9
+
10
+ # Shared validations
11
+ validates :value, presence: true, uniqueness: { scope: [:account_id, :type] }
12
+
13
+ def verified?
14
+ verified_at.present?
15
+ end
16
+
17
+ def verify!
18
+ update!(verified_at: Time.current)
19
+ end
20
+
21
+ def unverify!
22
+ update!(verified_at: nil)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ module StandardId
2
+ class PasswordCredential < ApplicationRecord
3
+ include StandardId::Credentiable
4
+
5
+ has_secure_password
6
+
7
+ generates_token_for :remember_me, expires_in: 30.days do
8
+ password_digest
9
+ end
10
+
11
+ generates_token_for :password_reset, expires_in: 20.minutes do
12
+ password_digest
13
+ end
14
+
15
+ validates :login, presence: true, uniqueness: true
16
+ validates :password, length: { minimum: 8 }, confirmation: true, if: :validate_password?
17
+
18
+ private
19
+
20
+ def validate_password?
21
+ password.present? || password_confirmation.present?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ module StandardId
2
+ class PasswordlessChallenge < ApplicationRecord
3
+ self.table_name = "standard_id_passwordless_challenges"
4
+
5
+ validates :connection_type, presence: true, inclusion: { in: %w[email sms] }
6
+ validates :username, presence: true
7
+ validates :code, presence: true, uniqueness: { scope: [:connection_type, :username, :expires_at] }
8
+ validates :expires_at, presence: true
9
+
10
+ scope :active, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
11
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
12
+ scope :used, -> { where.not(used_at: nil) }
13
+
14
+ def expired?
15
+ expires_at <= Time.current
16
+ end
17
+
18
+ def used?
19
+ used_at.present?
20
+ end
21
+
22
+ def active?
23
+ !expired? && !used?
24
+ end
25
+
26
+ def use!
27
+ update!(used_at: Time.current)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ module StandardId
2
+ class PhoneNumberIdentifier < Identifier
3
+ validates :value, format: { with: /\A\+?[1-9]\d{1,14}\z/ }
4
+ end
5
+ end
@@ -0,0 +1,44 @@
1
+ module StandardId
2
+ class ServiceSession < Session
3
+ belongs_to :owner, polymorphic: true, optional: true
4
+
5
+ validates :service_name, presence: true
6
+ validates :service_version, presence: true
7
+ validates :owner, presence: true
8
+
9
+ before_validation :set_default_expiry, on: :create
10
+
11
+ def display_name
12
+ "#{service_name} Service Session (v#{service_version})"
13
+ end
14
+
15
+ def service_info
16
+ {
17
+ name: service_name,
18
+ version: service_version,
19
+ type: "service"
20
+ }
21
+ end
22
+
23
+ def self.default_expiry
24
+ 90.days.from_now # TODO: make this configurable
25
+ end
26
+
27
+ def refresh!
28
+ # No-op for service sessions - they don't get refreshed
29
+ # Services should create new sessions when needed
30
+ end
31
+
32
+ def stale?
33
+ # Service sessions are never considered stale
34
+ # They're valid until they expire or are revoked
35
+ false
36
+ end
37
+
38
+ private
39
+
40
+ def set_default_expiry
41
+ self.expires_at ||= self.class.default_expiry
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,54 @@
1
+ require "bcrypt"
2
+
3
+ module StandardId
4
+ class Session < ApplicationRecord
5
+ self.table_name = "standard_id_sessions"
6
+
7
+ belongs_to :account, class_name: StandardId.config.account_class_name
8
+
9
+ scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
10
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
11
+ scope :revoked, -> { where.not(revoked_at: nil) }
12
+
13
+ scope :api_compatible, -> { where(type: ["StandardId::DeviceSession", "StandardId::ServiceSession"]) }
14
+ scope :by_token, ->(token) {
15
+ lookup_hash = Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
16
+ where(lookup_hash:)
17
+ }
18
+
19
+ attr_reader :token
20
+
21
+ before_validation :generate_token, :generate_token_digest, :generate_lookup_hash, on: :create
22
+
23
+
24
+ def active?
25
+ !revoked? && !expired?
26
+ end
27
+
28
+ def expired?
29
+ expires_at <= Time.current
30
+ end
31
+
32
+ def revoked?
33
+ revoked_at.present?
34
+ end
35
+
36
+ def revoke!
37
+ update!(revoked_at: Time.current)
38
+ end
39
+
40
+ private
41
+
42
+ def generate_token
43
+ @token ||= SecureRandom.urlsafe_base64(32)
44
+ end
45
+
46
+ def generate_token_digest
47
+ self.token_digest = BCrypt::Password.create(token)
48
+ end
49
+
50
+ def generate_lookup_hash
51
+ self.lookup_hash = Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ module StandardId
2
+ class UsernameIdentifier < Identifier
3
+ validates :value, format: { with: /\A[a-zA-Z0-9_]+\z/ }
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ <% content_for :title, "Edit Account" %>
2
+
3
+ <div class="container">
4
+ <h1>Edit Account</h1>
5
+
6
+ <% if @account.errors.any? %>
7
+ <div class="alert alert-error">
8
+ <ul>
9
+ <% @account.errors.full_messages.each do |msg| %>
10
+ <li><%= msg %></li>
11
+ <% end %>
12
+ </ul>
13
+ </div>
14
+ <% end %>
15
+
16
+ <%= form_with model: @account, url: account_path, method: :patch, local: true do |form| %>
17
+ <!-- Add account fields here when available -->
18
+ <p>No editable fields are defined yet.</p>
19
+
20
+ <%= form.submit "Save Changes", class: "btn" %>
21
+ <% end %>
22
+
23
+ <div class="mt-3">
24
+ <%= link_to "Back", account_path, class: "btn btn-secondary" %>
25
+ </div>
26
+ </div>
@@ -0,0 +1,31 @@
1
+ <% content_for :title, "Account" %>
2
+
3
+ <div class="container">
4
+ <h1>My Account</h1>
5
+
6
+ <div class="mb-3">
7
+ <h2>Account Information</h2>
8
+ <p><strong>Account ID:</strong> <%= @account.id %></p>
9
+ <p><strong>Created:</strong> <%= @account.created_at.strftime("%B %d, %Y") %></p>
10
+ </div>
11
+
12
+ <div class="mb-3">
13
+ <%= link_to "Edit Account", edit_account_path, class: "btn btn-secondary" %>
14
+ </div>
15
+
16
+ <div class="mb-3">
17
+ <h2>Active Sessions</h2>
18
+ <% if @sessions.any? %>
19
+ <p>You have <%= pluralize(@sessions.count, 'active session') %>.</p>
20
+ <%= link_to "Manage Sessions", sessions_path, class: "btn btn-secondary" %>
21
+ <% else %>
22
+ <p>No active sessions found.</p>
23
+ <% end %>
24
+ </div>
25
+
26
+ <div class="text-center mt-3">
27
+ <%= form_with url: logout_path, method: :post, local: true do |form| %>
28
+ <%= form.submit "Sign Out", class: "btn btn-secondary" %>
29
+ <% end %>
30
+ </div>
31
+ </div>