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,79 @@
1
+ module StandardId
2
+ module Oauth
3
+ class ImplicitAuthorizationFlow < AuthorizationFlow
4
+ expect_params :client_id
5
+ permit_params :audience, :scope, :state, :redirect_uri, :nonce, :connection, :prompt, :organization, :invitation
6
+
7
+ private
8
+
9
+ def generate_authorization_response
10
+ access_token = generate_access_token
11
+
12
+ id_token = generate_id_token if include_id_token?
13
+
14
+ fragment_params = {
15
+ access_token: access_token,
16
+ token_type: "Bearer",
17
+ expires_in: token_expiry.to_i,
18
+ scope: scope,
19
+ state: state
20
+ }
21
+
22
+ fragment_params[:id_token] = id_token if id_token
23
+
24
+ {
25
+ redirect_to: build_fragment_uri(redirect_uri, fragment_params),
26
+ status: :found
27
+ }
28
+ end
29
+
30
+ def generate_access_token
31
+ expires_in = token_expiry
32
+ payload = build_access_token_payload(expires_in)
33
+ StandardId::JwtService.encode(payload, expires_in: expires_in)
34
+ end
35
+
36
+ def generate_id_token
37
+ return nil unless include_id_token?
38
+
39
+ expires_in = token_expiry
40
+ payload = build_id_token_payload(expires_in)
41
+ StandardId::JwtService.encode(payload, expires_in: expires_in)
42
+ end
43
+
44
+ def include_id_token?
45
+ params[:response_type]&.include?("id_token")
46
+ end
47
+
48
+ def build_access_token_payload(expires_in)
49
+ {
50
+ sub: subject_id,
51
+ client_id: params[:client_id],
52
+ scope: scope,
53
+ aud: audience,
54
+ iat: Time.current.to_i,
55
+ exp: (Time.current + expires_in).to_i
56
+ }.compact
57
+ end
58
+
59
+ def build_id_token_payload(expires_in)
60
+ {
61
+ sub: subject_id,
62
+ aud: params[:client_id],
63
+ iss: StandardId.config.issuer,
64
+ iat: Time.current.to_i,
65
+ exp: (Time.current + expires_in).to_i,
66
+ nonce: params[:nonce]
67
+ }.compact
68
+ end
69
+
70
+ def token_expiry
71
+ 1.hour
72
+ end
73
+
74
+ def subject_id
75
+ current_account.id
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,70 @@
1
+ module StandardId
2
+ module Oauth
3
+ class PasswordFlow < TokenGrantFlow
4
+ expect_params :username, :password, :client_id
5
+ permit_params :client_secret, :audience, :scope, :realm
6
+
7
+ def authenticate!
8
+ validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
9
+
10
+ @account = authenticate_account(params[:username], params[:password])
11
+ raise StandardId::InvalidGrantError, "Invalid username or password" if @account.blank?
12
+
13
+ validate_requested_scope!
14
+ end
15
+
16
+ private
17
+
18
+ def subject_id
19
+ @account.id
20
+ end
21
+
22
+ def client_id
23
+ params[:client_id]
24
+ end
25
+
26
+ def token_scope
27
+ params[:scope] || default_scope
28
+ end
29
+
30
+ def grant_type
31
+ "password"
32
+ end
33
+
34
+ def audience
35
+ params[:audience]
36
+ end
37
+
38
+ def supports_refresh_token?
39
+ true
40
+ end
41
+
42
+ def token_expiry
43
+ 8.hours # Longer expiry for user sessions
44
+ end
45
+
46
+ def authenticate_account(username, password)
47
+ StandardId::PasswordCredential
48
+ .includes(credential: :account)
49
+ .find_by(login: username)
50
+ &.authenticate(password)
51
+ &.account
52
+ end
53
+
54
+ def validate_requested_scope!
55
+ return unless params[:scope].present?
56
+
57
+ scope_tokens = params[:scope].split(/\s+/)
58
+ invalid_tokens = scope_tokens.reject { |token| token.match?(/\A[a-zA-Z0-9_:-]+\z/) }
59
+
60
+ if invalid_tokens.any?
61
+ raise StandardId::InvalidScopeError, "Invalid scope tokens: #{invalid_tokens.join(', ')}"
62
+ end
63
+ end
64
+
65
+ def default_scope
66
+ "read"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,87 @@
1
+ module StandardId
2
+ module Oauth
3
+ class PasswordlessOtpFlow < TokenGrantFlow
4
+ expect_params :username, :otp, :connection, :client_id
5
+ permit_params :client_secret, :audience, :scope
6
+
7
+ def authenticate!
8
+ validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
9
+
10
+ raise StandardId::InvalidGrantError, "Invalid or expired verification code" if passwordless_challenge.blank?
11
+ raise StandardId::InvalidGrantError, "Unable to authenticate user" if account.blank?
12
+
13
+ validate_requested_scope!
14
+
15
+ passwordless_challenge.use!
16
+ end
17
+
18
+ private
19
+
20
+ def subject_id
21
+ account.id
22
+ end
23
+
24
+ def client_id
25
+ params[:client_id]
26
+ end
27
+
28
+ def token_scope
29
+ params[:scope] || default_scope
30
+ end
31
+
32
+ def grant_type
33
+ "passwordless_otp"
34
+ end
35
+
36
+ def audience
37
+ params[:audience]
38
+ end
39
+
40
+ def supports_refresh_token?
41
+ true
42
+ end
43
+
44
+ def token_expiry
45
+ 1.hour
46
+ end
47
+
48
+ def passwordless_challenge
49
+ @passwordless_challenge ||= StandardId::PasswordlessChallenge.active.find_by(
50
+ connection_type: params[:connection],
51
+ username: params[:username],
52
+ code: params[:otp]
53
+ )
54
+ end
55
+
56
+ def account
57
+ @account ||= strategy_for(params[:connection]).find_or_create_account(params[:username])
58
+ end
59
+
60
+ def strategy_for(connection)
61
+ case connection
62
+ when "email"
63
+ StandardId::Passwordless::EmailStrategy.new(request)
64
+ when "sms"
65
+ StandardId::Passwordless::SmsStrategy.new(request)
66
+ else
67
+ raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}"
68
+ end
69
+ end
70
+
71
+ def validate_requested_scope!
72
+ return unless params[:scope].present?
73
+
74
+ scope_tokens = params[:scope].split(/\s+/)
75
+ invalid_tokens = scope_tokens.reject { |token| token.match?(/\A[a-zA-Z0-9_:-]+\z/) }
76
+
77
+ if invalid_tokens.any?
78
+ raise StandardId::InvalidScopeError, "Invalid scope tokens: #{invalid_tokens.join(", ")}"
79
+ end
80
+ end
81
+
82
+ def default_scope
83
+ "read"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,61 @@
1
+ module StandardId
2
+ module Oauth
3
+ class RefreshTokenFlow < TokenGrantFlow
4
+ expect_params :refresh_token, :client_id
5
+ permit_params :client_secret, :scope, :audience
6
+
7
+ def authenticate!
8
+ validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
9
+
10
+ @refresh_payload = StandardId::JwtService.decode(params[:refresh_token])
11
+ raise StandardId::InvalidGrantError, "Invalid or expired refresh_token" if @refresh_payload.blank?
12
+
13
+ if @refresh_payload[:client_id] != params[:client_id]
14
+ raise StandardId::InvalidGrantError, "Refresh token was not issued to this client"
15
+ end
16
+
17
+ validate_scope_narrowing!
18
+ end
19
+
20
+ private
21
+
22
+ def subject_id
23
+ @refresh_payload[:sub]
24
+ end
25
+
26
+ def client_id
27
+ @refresh_payload[:client_id]
28
+ end
29
+
30
+ def token_scope
31
+ requested = params[:scope].presence
32
+ return requested if requested.present?
33
+ @refresh_payload[:scope]
34
+ end
35
+
36
+ def grant_type
37
+ "refresh_token"
38
+ end
39
+
40
+ def supports_refresh_token?
41
+ true
42
+ end
43
+
44
+ def validate_scope_narrowing!
45
+ return unless params[:scope].present?
46
+
47
+ original_scopes = Array(@refresh_payload[:scope].to_s.split(/\s+/)).reject(&:blank?)
48
+ requested_scopes = Array(params[:scope].to_s.split(/\s+/)).reject(&:blank?)
49
+
50
+ unless (requested_scopes - original_scopes).empty?
51
+ raise StandardId::InvalidScopeError, "Requested scope exceeds originally granted scope"
52
+ end
53
+
54
+ invalid_tokens = requested_scopes.reject { |t| t.match?(/\A[a-zA-Z0-9_:-]+\z/) }
55
+ if invalid_tokens.any?
56
+ raise StandardId::InvalidScopeError, "Invalid scope tokens: #{invalid_tokens.join(', ')}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,19 @@
1
+ module StandardId
2
+ module Oauth
3
+ module Subflows
4
+ class Base
5
+ def initialize(**params)
6
+ @params = params
7
+ end
8
+
9
+ def call
10
+ raise NotImplementedError, "Subclasses must implement #call"
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :params
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,66 @@
1
+ module StandardId
2
+ module Oauth
3
+ module Subflows
4
+ class SocialLoginGrant < Base
5
+ def call
6
+ { redirect_to: social_provider_url, status: :found }
7
+ end
8
+
9
+ private
10
+
11
+ def social_provider_url
12
+ @social_provider_url ||= case params[:connection]
13
+ when "google-oauth2"
14
+ build_google_oauth_url
15
+ when "apple"
16
+ build_apple_oauth_url
17
+ else
18
+ raise StandardId::InvalidRequestError, "Unsupported connection: #{params[:connection]}"
19
+ end
20
+ end
21
+
22
+ def build_google_oauth_url
23
+ google_params = {
24
+ client_id: StandardId.config.google_client_id,
25
+ redirect_uri: "#{params[:base_url]}/api/oauth/callback/google",
26
+ response_type: "code",
27
+ scope: "openid email profile",
28
+ state: encode_state_with_original_params
29
+ }
30
+
31
+ "https://accounts.google.com/o/oauth2/v2/auth?" + URI.encode_www_form(google_params)
32
+ end
33
+
34
+ def build_apple_oauth_url
35
+ apple_params = {
36
+ client_id: StandardId.config.apple_client_id,
37
+ redirect_uri: "#{params[:base_url]}/api/oauth/callback/apple",
38
+ response_type: "code",
39
+ scope: "name email",
40
+ response_mode: "form_post",
41
+ state: encode_state_with_original_params
42
+ }
43
+
44
+ "https://appleid.apple.com/auth/authorize?" + URI.encode_www_form(apple_params)
45
+ end
46
+
47
+ def encode_state_with_original_params
48
+ original_params = {
49
+ client_id: params[:client_id],
50
+ redirect_uri: params[:redirect_uri],
51
+ scope: params[:scope],
52
+ audience: params[:audience],
53
+ state: params[:state],
54
+ code_challenge: params[:code_challenge],
55
+ code_challenge_method: params[:code_challenge_method]
56
+ }.compact
57
+
58
+ # Remove code_challenge_method if code_challenge is not present
59
+ original_params.delete(:code_challenge_method) if original_params[:code_challenge].blank?
60
+
61
+ Base64.urlsafe_encode64(JSON.generate(original_params))
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,52 @@
1
+ module StandardId
2
+ module Oauth
3
+ module Subflows
4
+ class TraditionalCodeGrant < Base
5
+ def call
6
+ store_authorization_code
7
+
8
+ redirect_params = {
9
+ code: authorization_code,
10
+ state: params[:state]
11
+ }.compact
12
+
13
+ redirect_url = build_redirect_uri(params[:redirect_uri], redirect_params)
14
+
15
+ { redirect_to: redirect_url, status: :found }
16
+ end
17
+
18
+ private
19
+
20
+ def authorization_code
21
+ @authorization_code ||= SecureRandom.urlsafe_base64(32)
22
+ end
23
+
24
+ def store_authorization_code
25
+ StandardId::AuthorizationCode.issue!(
26
+ plaintext_code: authorization_code,
27
+ client_id: params[:client_id],
28
+ redirect_uri: params[:redirect_uri],
29
+ scope: params[:scope],
30
+ audience: params[:audience],
31
+ account: params[:current_account],
32
+ code_challenge: params[:code_challenge],
33
+ code_challenge_method: params[:code_challenge_method],
34
+ metadata: { state: params[:state] }.compact
35
+ )
36
+ end
37
+
38
+ def build_redirect_uri(base_uri, params_hash)
39
+ uri = URI.parse(base_uri)
40
+ query_params = URI.decode_www_form(uri.query || "")
41
+
42
+ params_hash.each do |key, value|
43
+ query_params << [key.to_s, value.to_s] if value.present?
44
+ end
45
+
46
+ uri.query = URI.encode_www_form(query_params)
47
+ uri.to_s
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,107 @@
1
+ module StandardId
2
+ module Oauth
3
+ class TokenGrantFlow < BaseRequestFlow
4
+ attr_reader :params, :request
5
+
6
+ def initialize(params, request, current_account: nil)
7
+ @params = params
8
+ @request = request
9
+ @current_account = current_account
10
+ end
11
+
12
+ class << self
13
+ def extra_permitted_keys
14
+ [:grant_type]
15
+ end
16
+ end
17
+
18
+ def execute
19
+ authenticate!
20
+ generate_token_response
21
+ end
22
+
23
+ private
24
+
25
+ def authenticate!
26
+ raise NotImplementedError, "Subclasses must implement authenticate!"
27
+ end
28
+
29
+ def validate_client_secret!(client_id, client_secret)
30
+ client_secret_credential = StandardId::ClientSecretCredential.active.find_by(client_id: client_id)
31
+ unless client_secret_credential&.authenticate_client_secret(client_secret)
32
+ raise StandardId::InvalidClientError, "Client authentication failed"
33
+ end
34
+ client_secret_credential
35
+ end
36
+
37
+ def generate_token_response
38
+ expires_in = token_expiry
39
+ payload = build_jwt_payload(expires_in)
40
+ access_token = StandardId::JwtService.encode(payload, expires_in: expires_in)
41
+
42
+ response = {
43
+ access_token: access_token,
44
+ token_type: "Bearer",
45
+ expires_in: expires_in.to_i
46
+ }
47
+
48
+ response[:scope] = token_scope if token_scope.present?
49
+ response[:refresh_token] = generate_refresh_token if supports_refresh_token?
50
+
51
+ response.compact
52
+ end
53
+
54
+ def build_jwt_payload(expires_in)
55
+ {
56
+ sub: subject_id,
57
+ client_id: client_id,
58
+ scope: token_scope,
59
+ grant_type: grant_type,
60
+ aud: audience
61
+ }.compact
62
+ end
63
+
64
+ def token_expiry
65
+ 1.hour
66
+ end
67
+
68
+ def supports_refresh_token?
69
+ false
70
+ end
71
+
72
+ def generate_refresh_token
73
+ payload = {
74
+ sub: subject_id,
75
+ client_id: client_id,
76
+ scope: token_scope,
77
+ grant_type: "refresh_token"
78
+ }
79
+ StandardId::JwtService.encode(payload, expires_in: refresh_token_expiry)
80
+ end
81
+
82
+ def refresh_token_expiry
83
+ 30.days
84
+ end
85
+
86
+ def subject_id
87
+ raise NotImplementedError
88
+ end
89
+
90
+ def client_id
91
+ raise NotImplementedError
92
+ end
93
+
94
+ def token_scope
95
+ raise NotImplementedError
96
+ end
97
+
98
+ def grant_type
99
+ raise NotImplementedError
100
+ end
101
+
102
+ def audience
103
+ params[:audience]
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,67 @@
1
+ module StandardId
2
+ module Passwordless
3
+ class BaseStrategy
4
+ attr_reader :request
5
+
6
+ def initialize(request)
7
+ @request = request
8
+ end
9
+
10
+ def connection_type
11
+ raise NotImplementedError
12
+ end
13
+
14
+ # Start flow: validate recipient, create challenge, and trigger sender
15
+ # attrs: { connection:, username: }
16
+ def start!(attrs)
17
+ username = attrs[:username]
18
+ validate_username!(username)
19
+ challenge = create_challenge!(username)
20
+ sender_callback&.call(username, challenge.code)
21
+ challenge
22
+ end
23
+
24
+ protected
25
+
26
+ def create_challenge!(username)
27
+ StandardId::PasswordlessChallenge.create!(
28
+ connection_type: connection_type,
29
+ username: username,
30
+ code: generate_otp_code,
31
+ expires_at: 10.minutes.from_now,
32
+ ip_address: request.remote_ip,
33
+ user_agent: request.user_agent
34
+ )
35
+ end
36
+
37
+ def generate_otp_code
38
+ (SecureRandom.random_number(900_000) + 100_000).to_s
39
+ end
40
+
41
+ def validate_username!(_username)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def find_or_create_account!(_username)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ public
50
+
51
+ # Public wrapper to reuse account lookup/creation outside OTP verification
52
+ def find_or_create_account(username)
53
+ validate_username!(username)
54
+ find_or_create_account!(username)
55
+ end
56
+
57
+ def identifier_class
58
+ raise NotImplementedError
59
+ end
60
+
61
+ def sender_callback
62
+ # Implement in subclasses
63
+ nil
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,27 @@
1
+ module StandardId
2
+ module Passwordless
3
+ class EmailStrategy < BaseStrategy
4
+ def connection_type
5
+ "email"
6
+ end
7
+
8
+ private
9
+
10
+ def validate_username!(email)
11
+ raise StandardId::InvalidRequestError, "Invalid email format" unless email.to_s.match?(/\A[^@\s]+@[^@\s]+\z/)
12
+ end
13
+
14
+ def find_or_create_account!(email)
15
+ identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
16
+ return identifier.account if identifier.present?
17
+
18
+ identifiers_attributes = [{ type: "StandardId::EmailIdentifier", value: email, verified_at: Time.current }]
19
+ Account.create!(identifiers_attributes:)
20
+ end
21
+
22
+ def sender_callback
23
+ StandardId.config.passwordless_email_sender
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module StandardId
2
+ module Passwordless
3
+ class SmsStrategy < BaseStrategy
4
+ def connection_type
5
+ "sms"
6
+ end
7
+
8
+ private
9
+
10
+ def validate_username!(phone_number)
11
+ unless phone_number.to_s.match?(/\A\+?[1-9]\d{1,14}\z/)
12
+ raise StandardId::InvalidRequestError, "Invalid phone number format"
13
+ end
14
+ end
15
+
16
+ def find_or_create_account!(phone_number)
17
+ identifier = StandardId::PhoneNumberIdentifier.includes(:account).find_by(value: phone_number)
18
+ return identifier.account if identifier.present?
19
+
20
+ identifiers_attributes = [{ type: "StandardId::PhoneNumberIdentifier", value: phone_number, verified_at: Time.current }]
21
+ Account.create!(identifiers_attributes:)
22
+ end
23
+
24
+ def sender_callback
25
+ StandardId.config.passwordless_sms_sender
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ module StandardId
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,23 @@
1
+ module StandardId
2
+ module Web
3
+ class AuthenticationGuard
4
+ def require_session!(session_manager, session:, request:)
5
+ session[:return_to_after_authenticating] = request.url
6
+
7
+ browser_session = session_manager.current_session
8
+
9
+ if browser_session.blank?
10
+ raise StandardId::NotAuthenticatedError
11
+ elsif browser_session.expired?
12
+ session_manager.clear_session!
13
+ raise StandardId::ExpiredSessionError
14
+ elsif browser_session.revoked?
15
+ session_manager.clear_session!
16
+ raise StandardId::RevokedSessionError
17
+ end
18
+
19
+ browser_session
20
+ end
21
+ end
22
+ end
23
+ end