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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/standard_id/application.css +15 -0
- data/app/controllers/concerns/standard_id/api_authentication.rb +29 -0
- data/app/controllers/concerns/standard_id/web_authentication.rb +51 -0
- data/app/controllers/standard_id/api/authorization_controller.rb +73 -0
- data/app/controllers/standard_id/api/base_controller.rb +61 -0
- data/app/controllers/standard_id/api/oauth/base_controller.rb +22 -0
- data/app/controllers/standard_id/api/oauth/tokens_controller.rb +44 -0
- data/app/controllers/standard_id/api/oidc/logout_controller.rb +50 -0
- data/app/controllers/standard_id/api/passwordless_controller.rb +38 -0
- data/app/controllers/standard_id/api/providers_controller.rb +175 -0
- data/app/controllers/standard_id/api/userinfo_controller.rb +36 -0
- data/app/controllers/standard_id/web/account_controller.rb +32 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +126 -0
- data/app/controllers/standard_id/web/base_controller.rb +14 -0
- data/app/controllers/standard_id/web/login_controller.rb +69 -0
- data/app/controllers/standard_id/web/logout_controller.rb +20 -0
- data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +46 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -0
- data/app/controllers/standard_id/web/sessions_controller.rb +26 -0
- data/app/controllers/standard_id/web/signup_controller.rb +83 -0
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +37 -0
- data/app/forms/standard_id/web/reset_password_start_form.rb +38 -0
- data/app/forms/standard_id/web/signup_form.rb +65 -0
- data/app/helpers/standard_id/application_helper.rb +4 -0
- data/app/jobs/standard_id/application_job.rb +4 -0
- data/app/mailers/standard_id/application_mailer.rb +6 -0
- data/app/models/concerns/standard_id/account_associations.rb +14 -0
- data/app/models/concerns/standard_id/credentiable.rb +12 -0
- data/app/models/standard_id/application_record.rb +5 -0
- data/app/models/standard_id/authorization_code.rb +86 -0
- data/app/models/standard_id/browser_session.rb +27 -0
- data/app/models/standard_id/client_application.rb +143 -0
- data/app/models/standard_id/client_secret_credential.rb +63 -0
- data/app/models/standard_id/credential.rb +16 -0
- data/app/models/standard_id/device_session.rb +38 -0
- data/app/models/standard_id/email_identifier.rb +5 -0
- data/app/models/standard_id/identifier.rb +25 -0
- data/app/models/standard_id/password_credential.rb +24 -0
- data/app/models/standard_id/passwordless_challenge.rb +30 -0
- data/app/models/standard_id/phone_number_identifier.rb +5 -0
- data/app/models/standard_id/service_session.rb +44 -0
- data/app/models/standard_id/session.rb +54 -0
- data/app/models/standard_id/username_identifier.rb +5 -0
- data/app/views/standard_id/web/account/edit.html.erb +26 -0
- data/app/views/standard_id/web/account/show.html.erb +31 -0
- data/app/views/standard_id/web/login/show.html.erb +108 -0
- data/app/views/standard_id/web/reset_password/confirm/show.html.erb +27 -0
- data/app/views/standard_id/web/reset_password/start/show.html.erb +20 -0
- data/app/views/standard_id/web/sessions/index.html.erb +112 -0
- data/app/views/standard_id/web/signup/show.html.erb +96 -0
- data/config/initializers/generators.rb +9 -0
- data/config/initializers/migration_helpers.rb +32 -0
- data/config/routes/api.rb +24 -0
- data/config/routes/web.rb +26 -0
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +56 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +10 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +17 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +10 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +43 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +20 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +46 -0
- data/db/migrate/20250903135906_create_standard_id_passwordless_challenges.rb +22 -0
- data/lib/generators/standard_id/install/install_generator.rb +14 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +11 -0
- data/lib/standard_id/api/authentication_guard.rb +20 -0
- data/lib/standard_id/api/session_manager.rb +39 -0
- data/lib/standard_id/api/token_manager.rb +50 -0
- data/lib/standard_id/api_engine.rb +7 -0
- data/lib/standard_id/config.rb +69 -0
- data/lib/standard_id/engine.rb +5 -0
- data/lib/standard_id/errors.rb +55 -0
- data/lib/standard_id/jwt_service.rb +50 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +47 -0
- data/lib/standard_id/oauth/authorization_code_flow.rb +53 -0
- data/lib/standard_id/oauth/authorization_flow.rb +91 -0
- data/lib/standard_id/oauth/base_request_flow.rb +43 -0
- data/lib/standard_id/oauth/client_credentials_flow.rb +38 -0
- data/lib/standard_id/oauth/implicit_authorization_flow.rb +79 -0
- data/lib/standard_id/oauth/password_flow.rb +70 -0
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +87 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +61 -0
- data/lib/standard_id/oauth/subflows/base.rb +19 -0
- data/lib/standard_id/oauth/subflows/social_login_grant.rb +66 -0
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +52 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +107 -0
- data/lib/standard_id/passwordless/base_strategy.rb +67 -0
- data/lib/standard_id/passwordless/email_strategy.rb +27 -0
- data/lib/standard_id/passwordless/sms_strategy.rb +29 -0
- data/lib/standard_id/version.rb +3 -0
- data/lib/standard_id/web/authentication_guard.rb +23 -0
- data/lib/standard_id/web/session_manager.rb +71 -0
- data/lib/standard_id/web/token_manager.rb +30 -0
- data/lib/standard_id/web_engine.rb +7 -0
- data/lib/standard_id.rb +49 -0
- data/lib/tasks/standard_id_tasks.rake +4 -0
- 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,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,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,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>
|