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,46 @@
1
+ class CreateStandardIdAuthorizationCodes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :standard_id_authorization_codes, id: primary_key_type do |t|
4
+ # Link to account when available (can be nil for pre-auth flows)
5
+ t.references :account, null: true, foreign_key: true, index: true, type: foreign_key_type
6
+
7
+ # Opaque auth code hash (SHA256 of the plaintext code), unique for lookup
8
+ t.string :code_hash, null: false
9
+
10
+ # OAuth client binding and redirect URI binding
11
+ t.string :client_id, null: false
12
+ t.text :redirect_uri, null: false
13
+
14
+ # Optional OAuth/OIDC extras
15
+ t.string :scope
16
+ t.string :audience
17
+ t.string :nonce
18
+
19
+ # PKCE
20
+ t.string :code_challenge
21
+ t.string :code_challenge_method
22
+
23
+ # Lifecycle
24
+ t.datetime :issued_at, null: false
25
+ t.datetime :expires_at, null: false
26
+ t.datetime :consumed_at
27
+
28
+ # Provider and custom metadata if needed
29
+ if connection.adapter_name.downcase.include?("postgres")
30
+ t.jsonb :metadata, default: {}, null: false
31
+ t.index :metadata, using: :gin
32
+ else
33
+ t.json :metadata, default: {}, null: false
34
+ end
35
+
36
+ t.timestamps
37
+
38
+ # Indexes
39
+ t.index :code_hash, unique: true
40
+ t.index [:client_id, :expires_at]
41
+ t.index [:account_id, :expires_at]
42
+ t.index :expires_at
43
+ t.index :consumed_at
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ class CreateStandardIdPasswordlessChallenges < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :standard_id_passwordless_challenges do |t|
4
+ t.string :connection_type, null: false
5
+
6
+ t.string :username, null: false
7
+ t.string :code, null: false
8
+
9
+ t.datetime :expires_at, null: false
10
+ t.datetime :used_at
11
+
12
+ t.string :ip_address
13
+ t.text :user_agent
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :standard_id_passwordless_challenges, [:connection_type, :username, :code], name: "index_passwordless_challenges_on_lookup"
19
+ add_index :standard_id_passwordless_challenges, :expires_at
20
+ add_index :standard_id_passwordless_challenges, :used_at
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ require "rails/generators"
2
+
3
+ module StandardId
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+ desc "Creates a StandardId initializer at config/initializers/standard_id.rb"
8
+
9
+ def create_initializer_file
10
+ template "standard_id.rb", "config/initializers/standard_id.rb"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # StandardId configuration
2
+ # Generated by: rails g standard_id:install
3
+
4
+ StandardId.configure do |c|
5
+ # Set to the String name of your account model (e.g., "User" or "Account")
6
+ c.account_class_name = "User"
7
+
8
+ # Optional: customize cache store and logger used internally by StandardId
9
+ # c.cache_store = Rails.cache
10
+ # c.logger = Rails.logger
11
+ end
@@ -0,0 +1,20 @@
1
+ module StandardId
2
+ module Api
3
+ class AuthenticationGuard
4
+ def require_session!(session_manager)
5
+ api_session = session_manager.current_session
6
+
7
+ if api_session.blank?
8
+ raise StandardId::NotAuthenticatedError, "Invalid or missing access token"
9
+ elsif api_session.respond_to?(:expired?) && api_session.expired?
10
+ raise StandardId::ExpiredSessionError, "Session has expired"
11
+ elsif api_session.respond_to?(:revoked?) && api_session.revoked?
12
+ session_manager.clear_session!
13
+ raise StandardId::RevokedSessionError, "Session has been revoked"
14
+ end
15
+
16
+ api_session
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ module StandardId
2
+ module Api
3
+ class SessionManager
4
+ def initialize(token_manager, request:)
5
+ @token_manager = token_manager
6
+ @request = request
7
+ end
8
+
9
+ def current_session
10
+ @current_session ||= load_current_session
11
+ end
12
+
13
+ def current_account
14
+ return unless current_session
15
+ @current_account ||= StandardId.config.account_class.find_by(id: current_session.account_id)
16
+ end
17
+
18
+ def revoke_current_session!
19
+ clear_session!
20
+ end
21
+
22
+ def clear_session!
23
+ @current_session = nil
24
+ @current_account = nil
25
+ end
26
+
27
+ private
28
+
29
+ def load_current_session
30
+ return @current_session if @current_session.present?
31
+
32
+ jwt_session = @token_manager.verify_jwt_token
33
+ return unless jwt_session&.active?
34
+
35
+ @current_session = jwt_session
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,50 @@
1
+ module StandardId
2
+ module Api
3
+ class TokenManager
4
+ attr_reader :request
5
+
6
+ def initialize(request)
7
+ @request = request
8
+ end
9
+
10
+ def create_device_session(account, device_id: nil, device_agent: nil)
11
+ StandardId::DeviceSession.create!(
12
+ account:,
13
+ ip_address: @request.remote_ip,
14
+ device_id: device_id || SecureRandom.uuid,
15
+ device_agent: device_agent || @request.user_agent,
16
+ expires_at: 30.days.from_now # TODO: make this configurable
17
+ )
18
+ end
19
+
20
+ def create_service_session(account, service_name:, service_version:, owner:, metadata: {})
21
+ StandardId::ServiceSession.create!(
22
+ account:,
23
+ owner:,
24
+ ip_address: @request.remote_ip,
25
+ service_name:,
26
+ service_version:,
27
+ metadata: metadata || {},
28
+ expires_at: StandardId::ServiceSession.default_expiry
29
+ )
30
+ end
31
+
32
+ def bearer_token
33
+ return @bearer_token if @bearer_token.present?
34
+
35
+ auth_header = @request.headers["Authorization"]
36
+ return unless auth_header&.start_with?("Bearer ")
37
+
38
+ @bearer_token = auth_header.split(" ", 2).last
39
+ end
40
+
41
+ def verify_jwt_token(token: bearer_token)
42
+ StandardId::JwtService.decode_session(token)
43
+ end
44
+
45
+ def generate_lookup_hash(token)
46
+ Digest::SHA256.hexdigest("#{token}:#{Rails.application.secret_key_base}")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,7 @@
1
+ module StandardId
2
+ class ApiEngine < ::Rails::Engine
3
+ isolate_namespace StandardId
4
+
5
+ paths["config/routes.rb"] = "config/routes/api.rb"
6
+ end
7
+ end
@@ -0,0 +1,69 @@
1
+ module StandardId
2
+ # Manages configuration for the StandardId engine
3
+ #
4
+ # Usage:
5
+ # StandardId.configure do |config|
6
+ # config.account_class_name = "User"
7
+ # config.cache_store = ActiveSupport::Cache::MemoryStore.new
8
+ # config.logger = Rails.logger
9
+ # config.allowed_post_logout_redirect_uris = ["https://example.com/logout"]
10
+ # end
11
+ class Config
12
+ # The name of the Account model class as a String, e.g. "User" or "Account"
13
+ attr_accessor :account_class_name
14
+
15
+ # Optional cache store and logger, used by StandardId.cache_store and StandardId.logger
16
+ attr_accessor :cache_store, :logger
17
+
18
+ # OAuth issuer identifier for ID tokens
19
+ attr_accessor :issuer
20
+
21
+ # Optional login URL for redirecting unauthenticated browser requests
22
+ # Example: "/login" or a full URL like "https://app.example.com/login"
23
+ # If set, Authorization endpoints can redirect to this path with a redirect_uri param
24
+ attr_accessor :login_url
25
+
26
+ # Social login provider credentials
27
+ attr_accessor :google_client_id, :google_client_secret
28
+ attr_accessor :apple_client_id, :apple_client_secret, :apple_private_key, :apple_key_id, :apple_team_id
29
+
30
+ # Passwordless authentication callbacks
31
+ # These should be callable objects (procs/lambdas) that accept (recipient, code) parameters
32
+ attr_accessor :passwordless_email_sender, :passwordless_sms_sender
33
+
34
+ # Allowed post-logout redirect URIs for OIDC logout endpoint
35
+ # If empty or nil, no redirects are allowed and the endpoint will return a JSON message
36
+ # If provided, the post_logout_redirect_uri must exactly match one of the values in this list
37
+ attr_accessor :allowed_post_logout_redirect_uris
38
+
39
+ # Layout name to use for StandardId Web controllers.
40
+ # If nil, controllers should default to "application" (host app or dummy app).
41
+ # Examples: "application", "standard_id/web/application", "my_custom_layout"
42
+ attr_accessor :web_layout
43
+
44
+ def initialize
45
+ @account_class_name = nil
46
+ @cache_store = nil
47
+ @logger = nil
48
+ @issuer = nil
49
+ @login_url = nil
50
+ @google_client_id = nil
51
+ @google_client_secret = nil
52
+ @apple_client_id = nil
53
+ @apple_client_secret = nil
54
+ @apple_private_key = nil
55
+ @apple_key_id = nil
56
+ @apple_team_id = nil
57
+ @passwordless_email_sender = nil
58
+ @passwordless_sms_sender = nil
59
+ @allowed_post_logout_redirect_uris = []
60
+ @web_layout = nil
61
+ end
62
+
63
+ def account_class
64
+ account_class_name.constantize
65
+ rescue NameError
66
+ raise NameError, "Could not find account class: #{account_class_name}. Please set a valid class name using `StandardId.configure { |c| c.account_class_name = 'YourAccountClass' }`"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ module StandardId
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace StandardId
4
+ end
5
+ end
@@ -0,0 +1,55 @@
1
+ module StandardId
2
+ class NotAuthenticatedError < StandardError; end
3
+
4
+ class InvalidSessionError < StandardError; end
5
+ class ExpiredSessionError < InvalidSessionError; end
6
+ class RevokedSessionError < InvalidSessionError; end
7
+
8
+ class OAuthError < StandardError
9
+ def oauth_error_code
10
+ :invalid_request
11
+ end
12
+
13
+ def http_status
14
+ :bad_request
15
+ end
16
+ end
17
+
18
+ class UnsupportedGrantTypeError < OAuthError
19
+ def oauth_error_code = :unsupported_grant_type
20
+ end
21
+
22
+ class MissingClientSecretCredentialsError < OAuthError
23
+ def oauth_error_code = :invalid_request
24
+ end
25
+
26
+ class InvalidClientSecretCredentialsError < OAuthError
27
+ def oauth_error_code = :invalid_client
28
+ def http_status = :unauthorized
29
+ end
30
+
31
+ class InvalidRequestError < OAuthError
32
+ def oauth_error_code = :invalid_request
33
+ end
34
+
35
+ class InvalidClientError < OAuthError
36
+ def oauth_error_code = :invalid_client
37
+ def http_status = :unauthorized
38
+ end
39
+
40
+ class InvalidGrantError < OAuthError
41
+ def oauth_error_code = :invalid_grant
42
+ end
43
+
44
+ class InvalidScopeError < OAuthError
45
+ def oauth_error_code = :invalid_scope
46
+ end
47
+
48
+ class UnauthorizedClientError < OAuthError
49
+ def oauth_error_code = :unauthorized_client
50
+ end
51
+
52
+ class UnsupportedResponseTypeError < OAuthError
53
+ def oauth_error_code = :unsupported_response_type
54
+ end
55
+ end
@@ -0,0 +1,50 @@
1
+ require "jwt"
2
+
3
+ module StandardId
4
+ class JwtService
5
+ ALGORITHM = "HS256"
6
+ Session = Struct.new(:account_id, :client_id, :scopes, :grant_type, keyword_init: true) do
7
+ def active?
8
+ true
9
+ end
10
+ end
11
+
12
+ def self.encode(payload, expires_in: 1.hour)
13
+ payload[:exp] = expires_in.from_now.to_i
14
+ payload[:iat] = Time.current.to_i
15
+
16
+ JWT.encode(payload, secret_key, ALGORITHM)
17
+ end
18
+
19
+ def self.decode(token)
20
+ decoded = JWT.decode(token, secret_key, true, { algorithm: ALGORITHM })
21
+ decoded.first.with_indifferent_access
22
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError
23
+ nil
24
+ end
25
+
26
+ def self.decode_session(token)
27
+ payload = decode(token)
28
+ return unless payload
29
+
30
+ scopes = if payload[:scope].is_a?(String)
31
+ payload[:scope].split(" ")
32
+ else
33
+ Array(payload[:scope]).compact
34
+ end
35
+
36
+ Session.new(
37
+ account_id: payload[:sub],
38
+ client_id: payload[:client_id],
39
+ scopes: scopes,
40
+ grant_type: payload[:grant_type]
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def self.secret_key
47
+ Rails.application.secret_key_base
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ module StandardId
2
+ module Oauth
3
+ class AuthorizationCodeAuthorizationFlow < AuthorizationFlow
4
+ expect_params :client_id, :audience
5
+ permit_params :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method
6
+
7
+ private
8
+
9
+ def generate_authorization_response
10
+ subflow_for(params).call
11
+ end
12
+
13
+ def subflow_for(flow_params)
14
+ builders = {
15
+ social: -> do
16
+ Subflows::SocialLoginGrant.new(
17
+ **common_subflow_params(flow_params),
18
+ connection: flow_params[:connection],
19
+ base_url: request.base_url
20
+ )
21
+ end,
22
+ traditional: -> do
23
+ Subflows::TraditionalCodeGrant.new(
24
+ **common_subflow_params(flow_params),
25
+ current_account: current_account
26
+ )
27
+ end
28
+ }
29
+
30
+ key = flow_params[:connection].present? ? :social : :traditional
31
+ builders.fetch(key).call
32
+ end
33
+
34
+ def common_subflow_params(flow_params)
35
+ {
36
+ client_id: flow_params[:client_id],
37
+ redirect_uri: redirect_uri,
38
+ scope: scope,
39
+ audience: audience,
40
+ state: state,
41
+ code_challenge: flow_params[:code_challenge],
42
+ code_challenge_method: flow_params[:code_challenge_method]
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,53 @@
1
+ module StandardId
2
+ module Oauth
3
+ class AuthorizationCodeFlow < TokenGrantFlow
4
+ expect_params :client_id, :client_secret, :code
5
+ permit_params :redirect_uri, :code_verifier
6
+
7
+ def authenticate!
8
+ @credential = validate_client_secret!(params[:client_id], params[:client_secret])
9
+
10
+ @authorization_code = find_authorization_code(params[:code])
11
+ unless @authorization_code&.valid_for_client?(params[:client_id])
12
+ raise StandardId::InvalidGrantError, "Invalid or expired authorization code"
13
+ end
14
+
15
+ if params[:redirect_uri].present? && @authorization_code.redirect_uri != params[:redirect_uri]
16
+ raise StandardId::InvalidGrantError, "Redirect URI mismatch"
17
+ end
18
+
19
+ unless @authorization_code.pkce_valid?(params[:code_verifier])
20
+ raise StandardId::InvalidGrantError, "Invalid PKCE code_verifier"
21
+ end
22
+
23
+ @authorization_code.mark_as_used!
24
+ end
25
+
26
+ private
27
+
28
+ def subject_id
29
+ @authorization_code.account_id
30
+ end
31
+
32
+ def client_id
33
+ @credential.client_id
34
+ end
35
+
36
+ def token_scope
37
+ @authorization_code.scope
38
+ end
39
+
40
+ def grant_type
41
+ "authorization_code"
42
+ end
43
+
44
+ def supports_refresh_token?
45
+ true
46
+ end
47
+
48
+ def find_authorization_code(code)
49
+ StandardId::AuthorizationCode.lookup(code)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,91 @@
1
+ module StandardId
2
+ module Oauth
3
+ class AuthorizationFlow < BaseRequestFlow
4
+ attr_reader :params, :request, :current_account
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
+ [:response_type]
15
+ end
16
+ end
17
+
18
+ def execute
19
+ validate_params!
20
+ authenticate_client!
21
+ generate_authorization_response
22
+ end
23
+
24
+ private
25
+
26
+ def validate_params!
27
+ if params[:response_type].blank?
28
+ raise StandardId::InvalidRequestError, "The response_type parameter is required"
29
+ end
30
+
31
+ if params[:client_id].blank?
32
+ raise StandardId::InvalidRequestError, "The client_id parameter is required"
33
+ end
34
+ end
35
+
36
+ def authenticate_client!
37
+ @client = StandardId::ClientApplication.active.find_by(client_id: params[:client_id])
38
+ unless @client
39
+ raise StandardId::InvalidClientError, "Invalid client_id"
40
+ end
41
+
42
+ # TODO: support for secret key rotation
43
+ # Maintain @client_credential for downstream compatibility (select any active secret)
44
+ @client_credential = @client.primary_client_secret
45
+
46
+ if params[:redirect_uri].present? && !@client.valid_redirect_uri?(params[:redirect_uri])
47
+ raise StandardId::InvalidRequestError, "Invalid redirect_uri"
48
+ end
49
+ end
50
+
51
+ def generate_authorization_response
52
+ raise NotImplementedError, "Subclasses must implement generate_authorization_response"
53
+ end
54
+
55
+ def build_redirect_uri(base_uri, params_hash)
56
+ uri = URI.parse(base_uri)
57
+ query_params = URI.decode_www_form(uri.query || "")
58
+
59
+ params_hash.each do |key, value|
60
+ query_params << [key.to_s, value.to_s] if value.present?
61
+ end
62
+
63
+ uri.query = URI.encode_www_form(query_params)
64
+ uri.to_s
65
+ end
66
+
67
+ def build_fragment_uri(base_uri, params_hash)
68
+ uri = URI.parse(base_uri)
69
+ fragment_params = params_hash.compact.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
70
+ uri.fragment = fragment_params
71
+ uri.to_s
72
+ end
73
+
74
+ def redirect_uri
75
+ params[:redirect_uri] || @client&.redirect_uris_array&.first
76
+ end
77
+
78
+ def state
79
+ params[:state]
80
+ end
81
+
82
+ def scope
83
+ params[:scope]
84
+ end
85
+
86
+ def audience
87
+ params[:audience]
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,43 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Shared base for all OAuth flows to handle params DSL and request context
4
+ # Used by both token grant flows and authorization endpoint flows
5
+ class BaseRequestFlow
6
+ attr_reader :params, :request, :current_account
7
+
8
+ def initialize(params, request, current_account: nil)
9
+ @params = params
10
+ @request = request
11
+ @current_account = current_account
12
+ end
13
+
14
+ class << self
15
+ def expect_params(*keys)
16
+ @expected_params ||= []
17
+ @expected_params |= keys.flatten.map! { |k| k.to_sym }
18
+ end
19
+
20
+ def permit_params(*keys)
21
+ @permitted_params ||= []
22
+ @permitted_params |= keys.flatten.map! { |k| k.to_sym }
23
+ end
24
+
25
+ def expected_params
26
+ Array(@expected_params).dup
27
+ end
28
+
29
+ # Subclasses can append additional keys by overriding extra_permitted_keys
30
+ def permitted_params
31
+ exp = expected_params
32
+ perm = Array(@permitted_params)
33
+ configured = (exp + perm + Array(extra_permitted_keys)).uniq
34
+ configured
35
+ end
36
+
37
+ def extra_permitted_keys
38
+ []
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,38 @@
1
+ module StandardId
2
+ module Oauth
3
+ class ClientCredentialsFlow < TokenGrantFlow
4
+ expect_params :client_id, :client_secret, :audience
5
+ permit_params :organization
6
+
7
+ def authenticate!
8
+ @credential = validate_client_secret!(params[:client_id], params[:client_secret])
9
+ end
10
+
11
+ private
12
+
13
+ def subject_id
14
+ @credential.account_id
15
+ end
16
+
17
+ def client_id
18
+ @credential.client_id
19
+ end
20
+
21
+ def token_scope
22
+ @credential.scopes
23
+ end
24
+
25
+ def grant_type
26
+ "client_credentials"
27
+ end
28
+
29
+ def audience
30
+ params[:audience]
31
+ end
32
+
33
+ def token_expiry
34
+ 1.hour
35
+ end
36
+ end
37
+ end
38
+ end