standard_id 0.20.0 → 0.21.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/{MIT-LICENSE → LICENSE} +3 -1
  3. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -0
  4. data/app/controllers/standard_id/api/authorization_controller.rb +82 -0
  5. data/app/controllers/standard_id/api/well_known/oauth_authorization_server_controller.rb +35 -0
  6. data/app/controllers/standard_id/api/well_known/openid_configuration_controller.rb +1 -21
  7. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +23 -3
  8. data/app/controllers/standard_id/web/base_controller.rb +45 -0
  9. data/app/controllers/standard_id/web/consent_controller.rb +144 -0
  10. data/app/controllers/standard_id/web/login_controller.rb +15 -4
  11. data/app/controllers/standard_id/web/login_verify_controller.rb +14 -2
  12. data/app/controllers/standard_id/web/logout_controller.rb +7 -2
  13. data/app/controllers/standard_id/web/signup_controller.rb +8 -5
  14. data/app/models/standard_id/client_grant.rb +44 -0
  15. data/app/views/standard_id/web/consent/error.html.erb +6 -0
  16. data/app/views/standard_id/web/consent/show.html.erb +35 -0
  17. data/app/views/standard_id/web/login/_social_buttons.html.erb +23 -0
  18. data/app/views/standard_id/web/login/show.html.erb +143 -88
  19. data/config/routes/api.rb +1 -0
  20. data/config/routes/web.rb +5 -0
  21. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +23 -0
  22. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +7 -2
  23. data/lib/standard_id/oauth/consent_payload.rb +36 -0
  24. data/lib/standard_id/oauth/discovery_document.rb +53 -0
  25. data/lib/standard_id/version.rb +1 -1
  26. data/lib/standard_id.rb +2 -0
  27. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: faa6cfb238d38d71af682021ba3b6407cf06657c1b12e7ff29474dc97c753277
4
- data.tar.gz: 365d86af12a4fef90b669f52114ae449d07facd5389089b438ae67174f75059e
3
+ metadata.gz: 4999da4a28bb0450d14da9b7383099cbf67860c9c739c7785cc84f8ba2cc84f0
4
+ data.tar.gz: f6d6574ddb0c3e331dd955bd3fd1c65a3b30c523f7d721b7274762d558f017ca
5
5
  SHA512:
6
- metadata.gz: 4ffc829b81cb6315954c5f2b3632889a7afd3c7457c4971297698f04ec9f43682b7eb715f711e2b28ea3c40d33f51519fc7339cd8d0f4996a7adad413677e880
7
- data.tar.gz: 6247cc6fd0deeee072d90711ae1443d01d42bb585a58330358d9596fae8e665e14af4c274a478f7bc43b4e47adc2aca05d4f2c6ab7a7dc1c4caa846f751ab917
6
+ metadata.gz: 20d8e4560eacf2a272e2cadafcb5647a82781813a5164f510a91bf8f80fc6d3bb17a6287fee15b41704907b323a3039c279213603cfdf619639298b8ca952f80
7
+ data.tar.gz: 60b2c0fa613bcf40eae4197e73d122d957769f4faae6f85dafdec25e463ef375c336fa07308459e0c0c1d1c49ce7ef03ff0fe5d50f1839a0f1213323be74f3cc
@@ -1,4 +1,6 @@
1
- Copyright (c) Jaryl Sim
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Rarebit One
2
4
 
3
5
  Permission is hereby granted, free of charge, to any person obtaining
4
6
  a copy of this software and associated documentation files (the
@@ -100,6 +100,12 @@ module StandardId
100
100
  # - :profile_type [String, nil] first configured profile type for the scope (back-compat)
101
101
  # - :profile_types [Array<String>, nil] all configured profile types for the scope
102
102
  # - :after_sign_in_path [String, nil] default redirect path for the scope
103
+ # - :redirect_uri [String, nil] caller-supplied destination (from the form
104
+ # param for password/signup, from the OAuth state cookie for social).
105
+ # Hooks that always return a default path should return nil when this is
106
+ # present so the originator's URL wins — otherwise upstream OAuth/SSO
107
+ # flows that send users to /login?redirect_uri=... will land on the
108
+ # hook's default page instead of completing the handshake.
103
109
  # @return [String, nil] redirect path override, or nil for default
104
110
  # @raise [StandardId::AuthenticationDenied] to reject the sign-in
105
111
  def invoke_after_sign_in(account, context)
@@ -118,6 +124,12 @@ module StandardId
118
124
  return result if result.present?
119
125
  end
120
126
 
127
+ # When the caller supplied a :redirect_uri and the hook returned nil, treat that
128
+ # as the documented "defer to originator" signal — do NOT shadow it with the
129
+ # scope's after_sign_in_path (which would silently break OAuth/SSO handshakes
130
+ # for any host that configures a scope default).
131
+ return nil if context[:redirect_uri].present?
132
+
121
133
  # When no hook override, use the scope's after_sign_in_path if present
122
134
  scope_config&.after_sign_in_path
123
135
  end
@@ -16,6 +16,9 @@ module StandardId
16
16
  }.freeze
17
17
 
18
18
  def show
19
+ reject_invalid_redirect_uri!
20
+ return redirect_to_consent if consent_required?
21
+
19
22
  response_data = flow_strategy_class.new(flow_strategy_params, request, current_account: current_account).execute
20
23
 
21
24
  if response_data[:redirect_to]
@@ -27,6 +30,85 @@ module StandardId
27
30
 
28
31
  private
29
32
 
33
+ # Validate redirect_uri against the resolved client BEFORE any consent
34
+ # hand-off. The authorization flow validates it during #execute, but the
35
+ # consent gate (redirect_to_consent) runs first — so without this an
36
+ # unvalidated redirect_uri would be signed into the consent payload and the
37
+ # Deny path would redirect straight to it (open redirect). Per OAuth, an
38
+ # invalid redirect_uri is surfaced as an error, never redirected to. On the
39
+ # non-consent path the flow re-validates (harmless, same error).
40
+ def reject_invalid_redirect_uri!
41
+ return if params[:redirect_uri].blank?
42
+
43
+ client = consent_client
44
+ return if client.nil? # unknown/inactive client is rejected by the flow
45
+
46
+ return if client.valid_redirect_uri?(params[:redirect_uri])
47
+
48
+ raise StandardId::InvalidRequestError, "Invalid redirect_uri"
49
+ end
50
+
51
+ # An interactive authorization-code request needs a consent screen when:
52
+ # * the user is authenticated,
53
+ # * the request is an interactive (HTML) request — not JSON, not a
54
+ # social-login redirect (which bounces to the provider),
55
+ # * the resolved client has require_consent enabled, and
56
+ # * the account has not already granted consent covering the scope.
57
+ # Implicit flows and JSON callers are unaffected. The consent screen is
58
+ # authenticated HTML, so it is rendered by the WebEngine (full ERB /
59
+ # Inertia stack); the API endpoint hands off via a signed payload and
60
+ # resumes here once a grant exists.
61
+ def consent_required?
62
+ return false unless response_type == "code"
63
+ return false unless request.format.html?
64
+ return false if social_login?
65
+ return false if current_account.blank?
66
+
67
+ client = consent_client
68
+ return false unless client&.require_consent?
69
+
70
+ !StandardId::ClientGrant.granted?(
71
+ account: current_account,
72
+ client_id: client.client_id,
73
+ requested_scope: params[:scope]
74
+ )
75
+ end
76
+
77
+ def consent_client
78
+ @consent_client ||= StandardId::ClientApplication.active.find_by(client_id: params[:client_id])
79
+ end
80
+
81
+ def redirect_to_consent
82
+ payload = StandardId::Oauth::ConsentPayload.encode(authorize_params_for_resume)
83
+ base = StandardId.config.login_url.present? ? consent_base_from_login_url : "/consent"
84
+ redirect_to "#{base}?consent_request=#{CGI.escape(payload)}", allow_other_host: true, status: :found
85
+ end
86
+
87
+ # Derive the WebEngine consent path from the configured login_url so the
88
+ # consent screen lands on the same host/mount as login (login and consent
89
+ # are both WebEngine, authenticated-HTML routes).
90
+ def consent_base_from_login_url
91
+ login = StandardId.config.login_url.to_s
92
+ login.sub(%r{/login/?\z}, "/consent").then { |u| u == login ? "/consent" : u }
93
+ end
94
+
95
+ # The exact params required to resume authorization-code issuance after
96
+ # approval. Carried through a signed payload so they cannot be tampered
97
+ # with; redirect_uri + PKCE are revalidated when the flow re-runs.
98
+ def authorize_params_for_resume
99
+ {
100
+ response_type: response_type,
101
+ client_id: params[:client_id],
102
+ redirect_uri: params[:redirect_uri],
103
+ scope: params[:scope],
104
+ audience: params[:audience],
105
+ state: params[:state],
106
+ code_challenge: params[:code_challenge],
107
+ code_challenge_method: params[:code_challenge_method],
108
+ nonce: params[:nonce]
109
+ }.compact
110
+ end
111
+
30
112
  def response_type
31
113
  @response_type ||= params[:response_type]
32
114
  end
@@ -0,0 +1,35 @@
1
+ module StandardId
2
+ module Api
3
+ module WellKnown
4
+ # RFC 8414 OAuth 2.0 Authorization Server Metadata.
5
+ #
6
+ # Mirrors OpenidConfigurationController: a public endpoint, guarded on a
7
+ # configured issuer, with a one-hour public cache. Both render the shared
8
+ # StandardId::Oauth::DiscoveryDocument so the OIDC and OAuth metadata
9
+ # documents cannot drift.
10
+ #
11
+ # MOUNT CAVEAT (RFC 8414): the ApiEngine is consumer-mounted at a sub-path
12
+ # (e.g. `/auth/api`), so the gem can only serve this document at
13
+ # `/auth/api/.well-known/oauth-authorization-server`. A strict RFC 8414
14
+ # client that derives a root-anchored URL from a path-carrying issuer
15
+ # (`<host>/.well-known/oauth-authorization-server/auth/api`) lands outside
16
+ # any engine mount; hosts needing that form must add their own root route.
17
+ class OauthAuthorizationServerController < ActionController::API
18
+ include StandardId::ControllerPolicy
19
+ public_controller
20
+
21
+ def show
22
+ issuer = StandardId.config.issuer
23
+
24
+ unless issuer.present?
25
+ render json: { error: "Issuer not configured" }, status: :not_found
26
+ return
27
+ end
28
+
29
+ response.headers["Cache-Control"] = "public, max-age=3600"
30
+ render json: StandardId::Oauth::DiscoveryDocument.build(issuer)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -14,27 +14,7 @@ module StandardId
14
14
  end
15
15
 
16
16
  response.headers["Cache-Control"] = "public, max-age=3600"
17
- render json: discovery_document(issuer)
18
- end
19
-
20
- private
21
-
22
- def discovery_document(issuer)
23
- base = issuer.chomp("/")
24
-
25
- {
26
- issuer: issuer,
27
- authorization_endpoint: "#{base}/authorize",
28
- token_endpoint: "#{base}/oauth/token",
29
- revocation_endpoint: "#{base}/oauth/revoke",
30
- userinfo_endpoint: "#{base}/userinfo",
31
- jwks_uri: "#{base}/.well-known/jwks.json",
32
- response_types_supported: %w[code],
33
- grant_types_supported: %w[authorization_code refresh_token client_credentials],
34
- subject_types_supported: %w[public],
35
- id_token_signing_alg_values_supported: [StandardId.config.oauth.signing_algorithm.to_s.upcase],
36
- token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post]
37
- }
17
+ render json: StandardId::Oauth::DiscoveryDocument.build(issuer)
38
18
  end
39
19
  end
40
20
  end
@@ -26,6 +26,7 @@ module StandardId
26
26
 
27
27
  begin
28
28
  extract_state_and_nonce => { state_data:, nonce: }
29
+ caller_redirect_uri = state_data&.dig("redirect_uri").presence
29
30
  redirect_uri = callback_url_for
30
31
  provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
31
32
  social_info = provider_response[:user_info]
@@ -52,10 +53,19 @@ module StandardId
52
53
  original_request_params: state_data
53
54
  )
54
55
 
55
- context = { mechanism: "social", provider: provider_name }
56
+ context = {
57
+ mechanism: "social",
58
+ provider: provider_name,
59
+ redirect_uri: caller_redirect_uri
60
+ }
56
61
  redirect_override = invoke_after_sign_in(account, context)
57
62
 
58
- destination = redirect_override || state_data["redirect_uri"]
63
+ # When the hook defers (returns nil), the originator-supplied URL becomes the
64
+ # destination. Validate it before redirect_to — without this, an attacker who
65
+ # tricks a victim into clicking /login?connection=google&redirect_uri=<evil>
66
+ # can steer the post-auth landing page. redirect_override is host-internal so
67
+ # we trust it; only the fallthrough needs validation.
68
+ destination = redirect_override || (safe_destination?(caller_redirect_uri) ? caller_redirect_uri : safe_post_signin_default)
59
69
  redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
60
70
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
61
71
  redirect_to destination, redirect_options
@@ -124,7 +134,17 @@ module StandardId
124
134
  "Authentication failed"
125
135
  end
126
136
 
127
- redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: error_message
137
+ # Preserve redirect_uri across the bounce-back-to-/login so the user can retry
138
+ # the OAuth handshake and complete it back to the originator. Symmetric with
139
+ # the SocialLinkError / OAuthError rescue paths above.
140
+ redirect_uri = begin
141
+ extract_state_and_nonce => { state_data: }
142
+ state_data&.dig("redirect_uri").presence
143
+ rescue StandardId::InvalidRequestError
144
+ nil
145
+ end
146
+
147
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: redirect_uri), alert: error_message
128
148
  end
129
149
 
130
150
  def mobile_relay_params
@@ -14,6 +14,51 @@ module StandardId
14
14
 
15
15
  before_action -> { Current.scope = :web if defined?(::Current) }
16
16
  before_action :require_browser_session!
17
+
18
+ private
19
+
20
+ # Read a top-level query/form param expected to be a scalar String, returning
21
+ # nil for absent/blank values OR if Rails parsed it as an Array/Hash (e.g. from
22
+ # `?redirect_uri[]=a&redirect_uri[]=b`). Without this guard, `redirect_to` is
23
+ # called with a non-String and raises ArgumentError → 500 for any caller that
24
+ # sends a malformed redirect_uri.
25
+ def string_param(key)
26
+ value = params[key]
27
+ value.is_a?(String) ? value.presence : nil
28
+ end
29
+
30
+ # Whether `destination` is safe to redirect a signed-in user to.
31
+ # - Same-origin paths ("/foo") pass; protocol-relative ("//evil") does not.
32
+ # - Same-origin absolute URLs ("https://this-host/...") pass — `store_location_for_redirect`
33
+ # stashes `request.url` in session, so callers wrapping `after_authentication_url`
34
+ # need same-origin URLs accepted.
35
+ # - Cross-host URLs pass only when the host has explicitly allow-listed the prefix
36
+ # via `StandardId.config.allowed_redirect_url_prefixes`.
37
+ # - Anything else (blank, absolute URL not in the allow-list, protocol-relative,
38
+ # opaque scheme) is rejected; callers should fall back to `safe_post_signin_default`.
39
+ def safe_destination?(destination)
40
+ return false if destination.blank?
41
+ return true if destination.start_with?("/") && !destination.start_with?("//")
42
+ return true if same_origin_url?(destination)
43
+
44
+ Array(StandardId.config.allowed_redirect_url_prefixes).any? do |entry|
45
+ case entry
46
+ when Regexp then entry.match?(destination)
47
+ else destination.start_with?(entry.to_s)
48
+ end
49
+ end
50
+ end
51
+
52
+ def same_origin_url?(destination)
53
+ return false unless destination.start_with?("http://", "https://")
54
+ URI.parse(destination).origin == URI.parse(request.base_url).origin
55
+ rescue URI::Error, ArgumentError
56
+ false
57
+ end
58
+
59
+ def safe_post_signin_default
60
+ "/"
61
+ end
17
62
  end
18
63
  end
19
64
  end
@@ -0,0 +1,144 @@
1
+ module StandardId
2
+ module Web
3
+ # Renders the OAuth consent screen and records the user's decision.
4
+ #
5
+ # The consent screen is authenticated HTML, so it lives on the WebEngine
6
+ # (full ERB / Inertia stack with `layout "public"`), alongside login. The
7
+ # API authorize endpoint (ActionController::API, JSON/redirect only) hands
8
+ # off here with a signed payload of the original /authorize params when a
9
+ # client has require_consent enabled and no prior grant exists.
10
+ #
11
+ # Flow:
12
+ # GET /consent?consent_request=<signed> -> show the screen
13
+ # POST /consent (decision=approve|deny) -> record + resume, or deny
14
+ #
15
+ # On approve we persist a ClientGrant and resume issuing the authorization
16
+ # code by running the same AuthorizationCodeAuthorizationFlow the API
17
+ # endpoint would have run — so redirect_uri and PKCE are revalidated here,
18
+ # not duplicated. On deny we redirect to redirect_uri with
19
+ # error=access_denied (+ state), per RFC 6749 §4.1.2.1.
20
+ class ConsentController < BaseController
21
+ public_controller
22
+
23
+ include StandardId::InertiaRendering
24
+
25
+ layout "public"
26
+
27
+ skip_before_action :require_browser_session!, only: [:show, :create]
28
+
29
+ # A bad/expired consent payload or an unknown client must not 500. The
30
+ # WebEngine doesn't render OAuth errors as JSON (that's the API layer),
31
+ # so surface a 400 HTML page instead.
32
+ rescue_from StandardId::OAuthError, with: :handle_consent_error
33
+
34
+ before_action :require_authenticated!
35
+ before_action :load_consent_request
36
+
37
+ def show
38
+ @client = consent_client
39
+ raise StandardId::InvalidClientError, "Invalid client_id" unless @client
40
+
41
+ @scopes = scope_list
42
+ render_with_inertia props: consent_props
43
+ end
44
+
45
+ def create
46
+ @client = consent_client
47
+ raise StandardId::InvalidClientError, "Invalid client_id" unless @client
48
+
49
+ if params[:decision].to_s == "approve"
50
+ approve!
51
+ else
52
+ deny!
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def require_authenticated!
59
+ return if current_account.present?
60
+
61
+ base_login_url = StandardId.config.login_url.presence || "/login"
62
+ separator = base_login_url.include?("?") ? "&" : "?"
63
+ redirect_to "#{base_login_url}#{separator}redirect_uri=#{CGI.escape(request.url)}",
64
+ allow_other_host: true, status: :found
65
+ end
66
+
67
+ def load_consent_request
68
+ @consent_request = StandardId::Oauth::ConsentPayload.decode(params[:consent_request])
69
+ raise StandardId::InvalidRequestError, "Invalid or expired consent request" if @consent_request.blank?
70
+ end
71
+
72
+ def consent_client
73
+ @consent_client ||= StandardId::ClientApplication.active.find_by(client_id: @consent_request[:client_id])
74
+ end
75
+
76
+ def approve!
77
+ StandardId::ClientGrant.record!(
78
+ account: current_account,
79
+ client_id: @client.client_id,
80
+ scope: @consent_request[:scope]
81
+ )
82
+
83
+ result = StandardId::Oauth::AuthorizationCodeAuthorizationFlow
84
+ .new(@consent_request, request, current_account: current_account)
85
+ .execute
86
+
87
+ redirect_to result[:redirect_to], status: result[:status] || :found, allow_other_host: true
88
+ end
89
+
90
+ def deny!
91
+ redirect_to denied_redirect_uri, status: :found, allow_other_host: true
92
+ end
93
+
94
+ def denied_redirect_uri
95
+ base = @consent_request[:redirect_uri].presence || @client.redirect_uris_array.first
96
+ # Defense-in-depth: only ever redirect to a URI registered on the client.
97
+ # The authorize endpoint already validates redirect_uri before the consent
98
+ # hand-off, but re-check here so a deny can never become an open redirect
99
+ # (and guard the nil case where a client has no registered URIs).
100
+ unless base.present? && @client.valid_redirect_uri?(base)
101
+ raise StandardId::InvalidRequestError, "Invalid redirect_uri"
102
+ end
103
+
104
+ params_hash = { error: "access_denied", state: @consent_request[:state] }.compact
105
+ build_error_redirect(base, params_hash)
106
+ end
107
+
108
+ def build_error_redirect(base_uri, params_hash)
109
+ uri = URI.parse(base_uri)
110
+ query = URI.decode_www_form(uri.query || "")
111
+ params_hash.each { |k, v| query << [k.to_s, v.to_s] if v.present? }
112
+ uri.query = URI.encode_www_form(query)
113
+ uri.to_s
114
+ end
115
+
116
+ def consent_props
117
+ {
118
+ client: {
119
+ client_id: @client.client_id,
120
+ name: @client.name,
121
+ description: @client.description
122
+ },
123
+ scopes: @scopes,
124
+ consent_request: params[:consent_request],
125
+ flash: { notice: flash[:notice], alert: flash[:alert] }.compact
126
+ }
127
+ end
128
+
129
+ def scope_list
130
+ @consent_request[:scope].to_s.split(/\s+/).map(&:strip).reject(&:blank?)
131
+ end
132
+
133
+ def handle_consent_error(error)
134
+ @consent_error = error.message
135
+ if use_inertia?
136
+ render inertia: inertia_component_name(:error),
137
+ props: { error: @consent_error }, status: :bad_request
138
+ else
139
+ render :error, status: :bad_request
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -31,8 +31,12 @@ module StandardId
31
31
  before_action :redirect_if_social_login, only: [:create]
32
32
 
33
33
  def show
34
- @redirect_uri = params[:redirect_uri] || after_authentication_url
34
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
35
35
  @connection = params[:connection]
36
+ # Drive the ERB view's form selection with the same passwordless-first
37
+ # precedence the #create action uses (passwordless wins when both are on).
38
+ @passwordless_enabled = passwordless_enabled?
39
+ @password_enabled = StandardId.config.web.password_login
36
40
 
37
41
  render_with_inertia props: auth_page_props(passwordless_enabled: passwordless_enabled?)
38
42
  end
@@ -59,9 +63,12 @@ module StandardId
59
63
  }
60
64
 
61
65
  if result
62
- context = { mechanism: "password", provider: nil }
66
+ redirect_uri = string_param(:redirect_uri)
67
+ context = { mechanism: "password", provider: nil, redirect_uri: redirect_uri }
63
68
  redirect_override = invoke_after_sign_in(current_account, context)
64
- destination = redirect_override || params[:redirect_uri] || after_authentication_url
69
+ fallback = after_authentication_url
70
+ fallback = safe_post_signin_default unless safe_destination?(fallback)
71
+ destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
65
72
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
66
73
  else
67
74
  flash.now[:alert] = "Invalid email or password"
@@ -95,7 +102,11 @@ module StandardId
95
102
  expires_in: code_ttl.seconds
96
103
  )
97
104
  session[:standard_id_otp_payload] = signed_payload
98
- session[:return_to_after_authenticating] = params[:redirect_uri] if params[:redirect_uri].present?
105
+ # Use string_param to reject Array/Hash-shaped values — login_verify_controller
106
+ # consumes this via after_authentication_url and passes it to redirect_to;
107
+ # storing a non-String here would crash that flow with ArgumentError.
108
+ redirect_uri = string_param(:redirect_uri)
109
+ session[:return_to_after_authenticating] = redirect_uri if redirect_uri
99
110
 
100
111
  redirect_to login_verify_path, status: :see_other
101
112
  end
@@ -60,12 +60,24 @@ module StandardId
60
60
  invoke_after_account_created(account, { mechanism: "passwordless", provider: nil })
61
61
  end
62
62
 
63
- context = { mechanism: "passwordless", provider: nil }
63
+ # Peek (don't pop) session[:return_to_after_authenticating] after_authentication_url
64
+ # consumes it below when redirect_override is nil, so deleting it here would lose the
65
+ # destination for hosts whose after_sign_in hook defers to the originator's redirect_uri.
66
+ context = {
67
+ mechanism: "passwordless",
68
+ provider: nil,
69
+ redirect_uri: session[:return_to_after_authenticating]
70
+ }
64
71
  redirect_override = invoke_after_sign_in(account, context)
65
72
 
66
73
  session.delete(:standard_id_otp_payload)
67
74
 
68
- destination = redirect_override || after_authentication_url
75
+ # after_authentication_url returns whatever was stashed in
76
+ # session[:return_to_after_authenticating] — which could be an attacker-controlled
77
+ # URL set by handle_passwordless_login from params[:redirect_uri]. string_param
78
+ # blocks Array/Hash but not "https://evil.com/phish". Validate before redirect.
79
+ fallback = after_authentication_url
80
+ destination = redirect_override || (safe_destination?(fallback) ? fallback : safe_post_signin_default)
69
81
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
70
82
  rescue StandardId::AuthenticationDenied => e
71
83
  session.delete(:standard_id_otp_payload)
@@ -14,13 +14,18 @@ module StandardId
14
14
 
15
15
  def create
16
16
  revoke_current_session!
17
- redirect_to params[:redirect_uri] || root_path, notice: "Successfully signed out"
17
+ redirect_to logout_destination, notice: "Successfully signed out"
18
18
  end
19
19
 
20
20
  private
21
21
 
22
22
  def redirect_if_not_authenticated
23
- redirect_to params[:redirect_uri] || root_path unless authenticated?
23
+ redirect_to logout_destination unless authenticated?
24
+ end
25
+
26
+ def logout_destination
27
+ redirect_uri = string_param(:redirect_uri)
28
+ safe_destination?(redirect_uri) ? redirect_uri : root_path
24
29
  end
25
30
  end
26
31
  end
@@ -15,7 +15,7 @@ module StandardId
15
15
  before_action :redirect_if_social_login, only: [:create]
16
16
 
17
17
  def show
18
- @redirect_uri = params[:redirect_uri] || after_authentication_url
18
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
19
19
  @connection = params[:connection] # For social login detection
20
20
 
21
21
  render_with_inertia props: auth_page_props
@@ -43,12 +43,15 @@ module StandardId
43
43
  session_manager.sign_in_account(form.account, scope_name: request.path_parameters[:scope])
44
44
  invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
45
45
 
46
- context = { mechanism: "password", provider: nil }
46
+ redirect_uri = string_param(:redirect_uri)
47
+ context = { mechanism: "password", provider: nil, redirect_uri: redirect_uri }
47
48
  redirect_override = invoke_after_sign_in(form.account, context)
48
- destination = redirect_override || params[:redirect_uri] || after_authentication_url
49
+ fallback = after_authentication_url
50
+ fallback = safe_post_signin_default unless safe_destination?(fallback)
51
+ destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
49
52
  redirect_to destination, notice: "Account created successfully"
50
53
  else
51
- @redirect_uri = params[:redirect_uri] || after_authentication_url
54
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
52
55
  @connection = params[:connection]
53
56
  flash.now[:alert] = form.errors.full_messages.join(", ")
54
57
  render_with_inertia action: :show, props: auth_page_props(errors: form.errors.to_hash), status: :unprocessable_content
@@ -79,7 +82,7 @@ module StandardId
79
82
 
80
83
  def encode_state
81
84
  Base64.urlsafe_encode64({
82
- redirect_uri: params[:redirect_uri] || after_authentication_url,
85
+ redirect_uri: string_param(:redirect_uri) || after_authentication_url,
83
86
  timestamp: Time.current.to_i
84
87
  }.to_json)
85
88
  end
@@ -0,0 +1,44 @@
1
+ module StandardId
2
+ # Records a user's prior consent to an OAuth client, so repeat authorizations
3
+ # for the same (account, client) skip the consent screen. One row per
4
+ # (account, client); re-approval updates the stored scope.
5
+ class ClientGrant < ApplicationRecord
6
+ self.table_name = "standard_id_client_grants"
7
+
8
+ belongs_to :account, class_name: StandardId.config.account_class_name
9
+
10
+ validates :client_id, presence: true
11
+ validates :account_id, uniqueness: { scope: :client_id }
12
+
13
+ # Whether `account` has already consented to `client_id` covering every
14
+ # scope token in `requested_scope`. A grant with a nil/blank stored scope
15
+ # is treated as covering nothing new only when the request also asks for
16
+ # nothing (blank request) — otherwise the requested tokens must all be a
17
+ # subset of the previously granted set.
18
+ def self.granted?(account:, client_id:, requested_scope: nil)
19
+ return false if account.nil? || client_id.blank?
20
+
21
+ grant = find_by(account_id: account.id, client_id: client_id)
22
+ return false unless grant
23
+
24
+ requested = scope_tokens(requested_scope)
25
+ return true if requested.empty?
26
+
27
+ granted = scope_tokens(grant.scope)
28
+ (requested - granted).empty?
29
+ end
30
+
31
+ # Record (or update) a grant for the given account + client + scope.
32
+ def self.record!(account:, client_id:, scope: nil)
33
+ grant = find_or_initialize_by(account_id: account.id, client_id: client_id)
34
+ grant.scope = scope
35
+ grant.save!
36
+ grant
37
+ end
38
+
39
+ def self.scope_tokens(value)
40
+ value.to_s.split(/\s+/).map(&:strip).reject(&:blank?).uniq
41
+ end
42
+ private_class_method :scope_tokens
43
+ end
44
+ end
@@ -0,0 +1,6 @@
1
+ <% content_for :title, "Authorization error" %>
2
+
3
+ <main>
4
+ <h1>Authorization error</h1>
5
+ <p><%= @consent_error.presence || "This authorization request could not be processed." %></p>
6
+ </main>
@@ -0,0 +1,35 @@
1
+ <% content_for :title, "Authorize access" %>
2
+
3
+ <%#
4
+ OAuth consent screen. Deliberately ASSET-FREE: semantic HTML only, no
5
+ external images and no reliance on utility CSS, so it renders acceptably
6
+ under a minimal element-selector layout. Inertia consumers receive the same
7
+ data as props (see ConsentController#consent_props) and render their own
8
+ component instead of this ERB.
9
+ %>
10
+ <main>
11
+ <h1>Authorize access</h1>
12
+
13
+ <p>
14
+ <strong><%= @client.name %></strong> is requesting access to your account.
15
+ </p>
16
+
17
+ <% if @client.description.present? %>
18
+ <p><%= @client.description %></p>
19
+ <% end %>
20
+
21
+ <% if @scopes.any? %>
22
+ <p>This will grant the following:</p>
23
+ <ul>
24
+ <% @scopes.each do |scope| %>
25
+ <li><%= scope %></li>
26
+ <% end %>
27
+ </ul>
28
+ <% end %>
29
+
30
+ <%= form_with url: consent_path, method: :post, local: true do |form| %>
31
+ <%= form.hidden_field :consent_request, value: params[:consent_request] %>
32
+ <button type="submit" name="decision" value="approve">Approve</button>
33
+ <button type="submit" name="decision" value="deny">Deny</button>
34
+ <% end %>
35
+ </main>
@@ -0,0 +1,23 @@
1
+ <%#
2
+ Asset-free social login buttons for the passwordless / no-method branches.
3
+ Semantic HTML only — no Tailwind utility classes — so it renders under a
4
+ minimal element-selector layout. Each provider posts to login_path with a
5
+ hidden `connection`, triggering LoginController#create's social branch.
6
+ %>
7
+ <section aria-label="Other sign-in options">
8
+ <p>Or continue with</p>
9
+ <% if StandardId.config.google_client_id.present? %>
10
+ <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
11
+ <%= form.hidden_field :connection, value: "google" %>
12
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
13
+ <button type="submit">Google</button>
14
+ <% end %>
15
+ <% end %>
16
+ <% if StandardId.config.apple_client_id.present? %>
17
+ <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
18
+ <%= form.hidden_field :connection, value: "apple" %>
19
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
20
+ <button type="submit">Apple</button>
21
+ <% end %>
22
+ <% end %>
23
+ </section>
@@ -1,108 +1,163 @@
1
1
  <% content_for :title, "Sign In" %>
2
2
 
3
- <div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
4
- <div class="sm:mx-auto sm:w-full sm:max-w-md">
5
- <img src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600" alt="Your Company" class="mx-auto h-10 w-auto dark:hidden" />
6
- <img src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500" alt="Your Company" class="mx-auto hidden h-10 w-auto dark:block" />
7
- <h2 class="mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white">Sign in to your account</h2>
8
- </div>
3
+ <% if @passwordless_enabled %>
4
+ <%#
5
+ Passwordless-first branch (matches LoginController#create precedence).
6
+ Deliberately ASSET-FREE: semantic HTML only, no external tailwindcss.com
7
+ logo image and no reliance on Tailwind utility classes, so it renders
8
+ acceptably under a minimal element-selector layout (e.g. layouts/public).
9
+ %>
10
+ <main>
11
+ <h1>Sign in to your account</h1>
12
+
13
+ <% if flash[:alert].present? %>
14
+ <p role="alert"><%= flash[:alert] %></p>
15
+ <% end %>
16
+ <% if flash[:notice].present? %>
17
+ <p role="status"><%= flash[:notice] %></p>
18
+ <% end %>
19
+
20
+ <%= form_with url: login_path, method: :post, local: true do |form| %>
21
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
22
+
23
+ <p>
24
+ <%= form.label :email, "Email address" %><br />
25
+ <%= form.email_field "login[email]", required: true, autofocus: true, autocomplete: "email" %>
26
+ </p>
9
27
 
10
- <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
11
- <div class="bg-white px-6 py-12 shadow sm:rounded-lg sm:px-12 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:outline-1 dark:-outline-offset-1 dark:outline-white/10">
28
+ <p>
29
+ <%= form.submit "Continue" %>
30
+ </p>
31
+ <% end %>
12
32
 
13
- <% if flash[:alert].present? %>
14
- <div class="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300"><%= flash[:alert] %></div>
15
- <% end %>
16
- <% if flash[:notice].present? %>
17
- <div class="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-300"><%= flash[:notice] %></div>
18
- <% end %>
33
+ <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
34
+ <%= render "standard_id/web/login/social_buttons" %>
35
+ <% end %>
19
36
 
20
- <%= form_with url: login_path, method: :post, local: true, html: { class: "space-y-6" } do |form| %>
21
- <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
37
+ <p>
38
+ Not a member?
39
+ <%= link_to "Sign up", signup_path(redirect_uri: @redirect_uri) %>
40
+ </p>
41
+ </main>
42
+
43
+ <% elsif @password_enabled %>
44
+ <%# Password branch — markup/styling intentionally UNCHANGED so existing
45
+ ERB consumers (e.g. nutripod) are unaffected. %>
46
+ <div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
47
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
48
+ <img src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600" alt="Your Company" class="mx-auto h-10 w-auto dark:hidden" />
49
+ <img src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500" alt="Your Company" class="mx-auto hidden h-10 w-auto dark:block" />
50
+ <h2 class="mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white">Sign in to your account</h2>
51
+ </div>
22
52
 
23
- <div>
24
- <%= form.label :email, "Email address", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
25
- <div class="mt-2">
26
- <%= form.email_field "login[email]", required: true, autofocus: true, autocomplete: "email", class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" %>
53
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
54
+ <div class="bg-white px-6 py-12 shadow sm:rounded-lg sm:px-12 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:outline-1 dark:-outline-offset-1 dark:outline-white/10">
55
+
56
+ <% if flash[:alert].present? %>
57
+ <div class="mb-4 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300"><%= flash[:alert] %></div>
58
+ <% end %>
59
+ <% if flash[:notice].present? %>
60
+ <div class="mb-4 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-300"><%= flash[:notice] %></div>
61
+ <% end %>
62
+
63
+ <%= form_with url: login_path, method: :post, local: true, html: { class: "space-y-6" } do |form| %>
64
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
65
+
66
+ <div>
67
+ <%= form.label :email, "Email address", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
68
+ <div class="mt-2">
69
+ <%= form.email_field "login[email]", required: true, autofocus: true, autocomplete: "email", class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" %>
70
+ </div>
27
71
  </div>
28
- </div>
29
72
 
30
- <div>
31
- <%= form.label :password, "Password", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
32
- <div class="mt-2">
33
- <%= form.password_field "login[password]", required: true, autocomplete: "current-password", class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" %>
73
+ <div>
74
+ <%= form.label :password, "Password", class: "block text-sm/6 font-medium text-gray-900 dark:text-white" %>
75
+ <div class="mt-2">
76
+ <%= form.password_field "login[password]", required: true, autocomplete: "current-password", class: "block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500" %>
77
+ </div>
34
78
  </div>
35
- </div>
36
-
37
- <div class="flex items-center justify-between">
38
- <div class="flex gap-3">
39
- <div class="flex h-6 shrink-0 items-center">
40
- <div class="group grid size-4 grid-cols-1">
41
- <%= form.check_box "login[remember_me]", id: "remember-me", class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 forced-colors:appearance-auto" %>
42
- <svg viewBox="0 0 14 14" fill="none" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25 dark:group-has-[:disabled]:stroke-white/25">
43
- <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:checked]:opacity-100" />
44
- <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:indeterminate]:opacity-100" />
45
- </svg>
79
+
80
+ <div class="flex items-center justify-between">
81
+ <div class="flex gap-3">
82
+ <div class="flex h-6 shrink-0 items-center">
83
+ <div class="group grid size-4 grid-cols-1">
84
+ <%= form.check_box "login[remember_me]", id: "remember-me", class: "col-start-1 row-start-1 appearance-none rounded border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 forced-colors:appearance-auto" %>
85
+ <svg viewBox="0 0 14 14" fill="none" class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25 dark:group-has-[:disabled]:stroke-white/25">
86
+ <path d="M3 8L6 11L11 3.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:checked]:opacity-100" />
87
+ <path d="M3 7H11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-0 group-has-[:indeterminate]:opacity-100" />
88
+ </svg>
89
+ </div>
46
90
  </div>
91
+ <label for="remember-me" class="block text-sm/6 text-gray-900 dark:text-white">Remember me</label>
47
92
  </div>
48
- <label for="remember-me" class="block text-sm/6 text-gray-900 dark:text-white">Remember me</label>
49
- </div>
50
93
 
51
- <div class="text-sm/6">
52
- <%= link_to "Forgot password?", reset_password_start_path, class: "font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300" %>
94
+ <div class="text-sm/6">
95
+ <%= link_to "Forgot password?", reset_password_start_path, class: "font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300" %>
96
+ </div>
53
97
  </div>
54
- </div>
55
-
56
- <div>
57
- <%= form.submit "Sign in", class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500" %>
58
- </div>
59
- <% end %>
60
-
61
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
62
- <div>
63
- <div class="mt-10 flex items-center gap-x-6">
64
- <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
65
- <p class="text-nowrap text-sm/6 font-medium text-gray-900 dark:text-white">Or continue with</p>
66
- <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
98
+
99
+ <div>
100
+ <%= form.submit "Sign in", class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500" %>
67
101
  </div>
102
+ <% end %>
68
103
 
69
- <div class="mt-6 grid grid-cols-2 gap-4">
70
- <% if StandardId.config.google_client_id.present? %>
71
- <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
72
- <%= form.hidden_field :connection, value: "google" %>
73
- <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
74
- <button type="submit" class="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/5 dark:hover:bg-white/20">
75
- <svg viewBox="0 0 24 24" aria-hidden="true" class="h-5 w-5">
76
- <path d="M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z" fill="#EA4335" />
77
- <path d="M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z" fill="#4285F4" />
78
- <path d="M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z" fill="#FBBC05" />
79
- <path d="M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z" fill="#34A853" />
80
- </svg>
81
- <span class="text-sm/6 font-semibold">Google</span>
82
- </button>
104
+ <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
105
+ <div>
106
+ <div class="mt-10 flex items-center gap-x-6">
107
+ <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
108
+ <p class="text-nowrap text-sm/6 font-medium text-gray-900 dark:text-white">Or continue with</p>
109
+ <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
110
+ </div>
111
+
112
+ <div class="mt-6 grid grid-cols-2 gap-4">
113
+ <% if StandardId.config.google_client_id.present? %>
114
+ <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
115
+ <%= form.hidden_field :connection, value: "google" %>
116
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
117
+ <button type="submit" class="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/5 dark:hover:bg-white/20">
118
+ <svg viewBox="0 0 24 24" aria-hidden="true" class="h-5 w-5">
119
+ <path d="M12.0003 4.75C13.7703 4.75 15.3553 5.36002 16.6053 6.54998L20.0303 3.125C17.9502 1.19 15.2353 0 12.0003 0C7.31028 0 3.25527 2.69 1.28027 6.60998L5.27028 9.70498C6.21525 6.86002 8.87028 4.75 12.0003 4.75Z" fill="#EA4335" />
120
+ <path d="M23.49 12.275C23.49 11.49 23.415 10.73 23.3 10H12V14.51H18.47C18.18 15.99 17.34 17.25 16.08 18.1L19.945 21.1C22.2 19.01 23.49 15.92 23.49 12.275Z" fill="#4285F4" />
121
+ <path d="M5.26498 14.2949C5.02498 13.5699 4.88501 12.7999 4.88501 11.9999C4.88501 11.1999 5.01998 10.4299 5.26498 9.7049L1.275 6.60986C0.46 8.22986 0 10.0599 0 11.9999C0 13.9399 0.46 15.7699 1.28 17.3899L5.26498 14.2949Z" fill="#FBBC05" />
122
+ <path d="M12.0004 24.0001C15.2404 24.0001 17.9654 22.935 19.9454 21.095L16.0804 18.095C15.0054 18.82 13.6204 19.245 12.0004 19.245C8.8704 19.245 6.21537 17.135 5.2654 14.29L1.27539 17.385C3.25539 21.31 7.3104 24.0001 12.0004 24.0001Z" fill="#34A853" />
123
+ </svg>
124
+ <span class="text-sm/6 font-semibold">Google</span>
125
+ </button>
126
+ <% end %>
83
127
  <% end %>
84
- <% end %>
85
-
86
- <% if StandardId.config.apple_client_id.present? %>
87
- <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
88
- <%= form.hidden_field :connection, value: "apple" %>
89
- <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
90
- <button type="submit" class="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/5 dark:hover:bg-white/20">
91
- <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5 fill-[#24292F] dark:fill-white">
92
- <path d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" fill-rule="evenodd" />
93
- </svg>
94
- <span class="text-sm/6 font-semibold">Apple</span>
95
- </button>
128
+
129
+ <% if StandardId.config.apple_client_id.present? %>
130
+ <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
131
+ <%= form.hidden_field :connection, value: "apple" %>
132
+ <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
133
+ <button type="submit" class="flex w-full items-center justify-center gap-3 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:ring-transparent dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/5 dark:hover:bg-white/20">
134
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5 fill-[#24292F] dark:fill-white">
135
+ <path d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" fill-rule="evenodd" />
136
+ </svg>
137
+ <span class="text-sm/6 font-semibold">Apple</span>
138
+ </button>
139
+ <% end %>
96
140
  <% end %>
97
- <% end %>
141
+ </div>
98
142
  </div>
99
- </div>
100
- <% end %>
101
- </div>
143
+ <% end %>
144
+ </div>
102
145
 
103
- <p class="mt-10 text-center text-sm/6 text-gray-500 dark:text-gray-400">
104
- Not a member?
105
- <%= link_to "Sign up", signup_path(redirect_uri: @redirect_uri), class: "font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300" %>
106
- </p>
146
+ <p class="mt-10 text-center text-sm/6 text-gray-500 dark:text-gray-400">
147
+ Not a member?
148
+ <%= link_to "Sign up", signup_path(redirect_uri: @redirect_uri), class: "font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300" %>
149
+ </p>
150
+ </div>
107
151
  </div>
108
- </div>
152
+
153
+ <% else %>
154
+ <%# Neither password nor passwordless login is enabled — render a message
155
+ instead of crashing. Social login (if configured) is still offered. %>
156
+ <main>
157
+ <h1>Sign in</h1>
158
+ <p>No login method is enabled.</p>
159
+ <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
160
+ <%= render "standard_id/web/login/social_buttons" %>
161
+ <% end %>
162
+ </main>
163
+ <% end %>
data/config/routes/api.rb CHANGED
@@ -26,6 +26,7 @@ StandardId::ApiEngine.routes.draw do
26
26
  scope ".well-known", module: :well_known do
27
27
  get "jwks.json", to: "jwks#show", as: :jwks
28
28
  get "openid-configuration", to: "openid_configuration#show", as: :openid_configuration
29
+ get "oauth-authorization-server", to: "oauth_authorization_server#show", as: :oauth_authorization_server
29
30
  end
30
31
  end
31
32
  end
data/config/routes/web.rb CHANGED
@@ -2,6 +2,11 @@ StandardId::WebEngine.routes.draw do
2
2
  scope module: :web do
3
3
  # Authentication flows
4
4
  resource :login, only: [:show, :create], controller: :login
5
+
6
+ # OAuth consent screen (authenticated HTML). The API authorize endpoint
7
+ # hands off here with a signed payload when a client requires consent.
8
+ resource :consent, only: [:show, :create], controller: :consent
9
+
5
10
  resource :login_verify, only: [:show, :update], controller: :login_verify
6
11
  resource :logout, only: [:create], controller: :logout
7
12
  resource :signup, only: [:show, :create], controller: :signup
@@ -0,0 +1,23 @@
1
+ class CreateStandardIdClientGrants < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :standard_id_client_grants, id: primary_key_type do |t|
4
+ # The account that granted consent. Required — consent is per-user.
5
+ t.references :account, null: false, foreign_key: true, index: true, type: foreign_key_type
6
+
7
+ # The OAuth client the consent was granted to. Stored as client_id (the
8
+ # public identifier) to mirror authorization_codes' client binding.
9
+ t.string :client_id, null: false
10
+
11
+ # The space-delimited scope string the user approved. Lets a future
12
+ # change require re-consent when a client requests a broader scope than
13
+ # was previously granted.
14
+ t.string :scope
15
+
16
+ t.timestamps
17
+
18
+ # One active grant per (account, client). Re-approving updates the row.
19
+ t.index [:account_id, :client_id], unique: true, name: "idx_standard_id_client_grants_on_account_client"
20
+ t.index :client_id
21
+ end
22
+ end
23
+ end
@@ -1,8 +1,13 @@
1
1
  module StandardId
2
2
  module Oauth
3
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, :nonce
4
+ expect_params :client_id
5
+ # :audience is optional (RFC 6749 / RFC 8707 §2 treats `resource`/`audience`
6
+ # as OPTIONAL at /authorize). Token-time validation in
7
+ # TokenGrantFlow#validate_audience! already no-ops when audience is blank or
8
+ # when no allowed_audiences are configured, so omitting it is safe and lets
9
+ # standards-compliant clients (e.g. MCP) authorize without it.
10
+ permit_params :audience, :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method, :nonce
6
11
 
7
12
  private
8
13
 
@@ -0,0 +1,36 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Tamper-proof carrier for the original /authorize parameters across the
4
+ # consent hand-off (API authorize -> WebEngine consent screen -> resume).
5
+ #
6
+ # Mirrors the OTP flow's use of Rails.application.message_verifier: the
7
+ # params are signed (not encrypted — they are not secret, but must not be
8
+ # mutable by the user) and expire so a stale consent link can't be replayed
9
+ # indefinitely. redirect_uri and PKCE are revalidated when the resumed
10
+ # /authorize re-runs, so signing here defends the integrity of the carried
11
+ # values, not the eventual code issuance.
12
+ module ConsentPayload
13
+ VERIFIER_PURPOSE = :standard_id_oauth_consent
14
+ # Generous TTL: the user may take a while to read the consent screen.
15
+ DEFAULT_EXPIRY = 600 # seconds (10 minutes)
16
+
17
+ module_function
18
+
19
+ def encode(params, expires_in: DEFAULT_EXPIRY)
20
+ verifier.generate(params.to_h.symbolize_keys, expires_in: expires_in.seconds)
21
+ end
22
+
23
+ # Returns the params Hash (symbolized keys) or nil if the payload is
24
+ # missing, tampered, or expired.
25
+ def decode(token)
26
+ return nil if token.blank?
27
+
28
+ verifier.verified(token)&.symbolize_keys
29
+ end
30
+
31
+ def verifier
32
+ Rails.application.message_verifier(VERIFIER_PURPOSE)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,53 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Shared builder for the OIDC / OAuth 2.0 metadata documents served at:
4
+ # * /.well-known/openid-configuration (OpenID Connect Discovery)
5
+ # * /.well-known/oauth-authorization-server (RFC 8414)
6
+ #
7
+ # Both well-known controllers render this single builder so the two
8
+ # documents cannot drift. Endpoint URLs are derived from the configured
9
+ # issuer.
10
+ #
11
+ # NOTE on mounting (RFC 8414 caveat): the ApiEngine is consumer-mounted at
12
+ # a sub-path (e.g. `/auth/api`), so the gem can only serve
13
+ # `/auth/api/.well-known/oauth-authorization-server`. A strict RFC 8414
14
+ # client that derives a *root-anchored* metadata URL from a path-carrying
15
+ # issuer would probe `<host>/.well-known/oauth-authorization-server/auth/api`,
16
+ # which falls outside any engine mount. Hosts that need the root-anchored
17
+ # form must add their own root route — the gem cannot.
18
+ module DiscoveryDocument
19
+ module_function
20
+
21
+ # @param issuer [String] the configured issuer (e.g. "https://auth.example.com")
22
+ # @param registration_enabled [Boolean] when true, advertises the RFC 7591
23
+ # dynamic client registration endpoint. Defaults to false; the seam is
24
+ # kept here so Phase 2 (DCR) can flip it on via config without touching
25
+ # either controller. While false, no registration_endpoint is emitted.
26
+ # @return [Hash]
27
+ def build(issuer, registration_enabled: false)
28
+ base = issuer.to_s.chomp("/")
29
+
30
+ doc = {
31
+ issuer: issuer,
32
+ authorization_endpoint: "#{base}/authorize",
33
+ token_endpoint: "#{base}/oauth/token",
34
+ revocation_endpoint: "#{base}/oauth/revoke",
35
+ userinfo_endpoint: "#{base}/userinfo",
36
+ jwks_uri: "#{base}/.well-known/jwks.json",
37
+ response_types_supported: %w[code],
38
+ grant_types_supported: %w[authorization_code refresh_token client_credentials],
39
+ subject_types_supported: %w[public],
40
+ id_token_signing_alg_values_supported: [StandardId.config.oauth.signing_algorithm.to_s.upcase],
41
+ token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
42
+ # PKCE is always enforced (require_pkce defaults true and cannot be
43
+ # disabled for public clients), so advertise the supported method.
44
+ code_challenge_methods_supported: %w[S256]
45
+ }
46
+
47
+ doc[:registration_endpoint] = "#{base}/oauth/register" if registration_enabled
48
+
49
+ doc
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.20.0"
2
+ VERSION = "0.21.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -44,6 +44,8 @@ require "standard_id/oauth/subflows/base"
44
44
  require "standard_id/oauth/subflows/traditional_code_grant"
45
45
  require "standard_id/oauth/subflows/social_login_grant"
46
46
  require "standard_id/oauth/passwordless_otp_flow"
47
+ require "standard_id/oauth/discovery_document"
48
+ require "standard_id/oauth/consent_payload"
47
49
  require "standard_id/passwordless/base_strategy"
48
50
  require "standard_id/passwordless/email_strategy"
49
51
  require "standard_id/passwordless/sms_strategy"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -108,7 +108,7 @@ executables: []
108
108
  extensions: []
109
109
  extra_rdoc_files: []
110
110
  files:
111
- - MIT-LICENSE
111
+ - LICENSE
112
112
  - README.md
113
113
  - Rakefile
114
114
  - app/assets/stylesheets/standard_id/application.css
@@ -139,10 +139,12 @@ files:
139
139
  - app/controllers/standard_id/api/sessions_controller.rb
140
140
  - app/controllers/standard_id/api/userinfo_controller.rb
141
141
  - app/controllers/standard_id/api/well_known/jwks_controller.rb
142
+ - app/controllers/standard_id/api/well_known/oauth_authorization_server_controller.rb
142
143
  - app/controllers/standard_id/api/well_known/openid_configuration_controller.rb
143
144
  - app/controllers/standard_id/web/account_controller.rb
144
145
  - app/controllers/standard_id/web/auth/callback/providers_controller.rb
145
146
  - app/controllers/standard_id/web/base_controller.rb
147
+ - app/controllers/standard_id/web/consent_controller.rb
146
148
  - app/controllers/standard_id/web/login_controller.rb
147
149
  - app/controllers/standard_id/web/login_verify_controller.rb
148
150
  - app/controllers/standard_id/web/logout_controller.rb
@@ -176,6 +178,7 @@ files:
176
178
  - app/models/standard_id/authorization_code.rb
177
179
  - app/models/standard_id/browser_session.rb
178
180
  - app/models/standard_id/client_application.rb
181
+ - app/models/standard_id/client_grant.rb
179
182
  - app/models/standard_id/client_secret_credential.rb
180
183
  - app/models/standard_id/code_challenge.rb
181
184
  - app/models/standard_id/credential.rb
@@ -195,6 +198,9 @@ files:
195
198
  - app/views/standard_id/web/account/edit.html.erb
196
199
  - app/views/standard_id/web/account/show.html.erb
197
200
  - app/views/standard_id/web/auth/callback/providers/mobile_callback.html.erb
201
+ - app/views/standard_id/web/consent/error.html.erb
202
+ - app/views/standard_id/web/consent/show.html.erb
203
+ - app/views/standard_id/web/login/_social_buttons.html.erb
198
204
  - app/views/standard_id/web/login/show.html.erb
199
205
  - app/views/standard_id/web/login_verify/show.html.erb
200
206
  - app/views/standard_id/web/reset_password/confirm/show.html.erb
@@ -219,6 +225,7 @@ files:
219
225
  - db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb
220
226
  - db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb
221
227
  - db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb
228
+ - db/migrate/20260611000000_create_standard_id_client_grants.rb
222
229
  - lib/generators/standard_id/install/install_generator.rb
223
230
  - lib/generators/standard_id/install/templates/standard_id.rb
224
231
  - lib/standard_id.rb
@@ -254,6 +261,8 @@ files:
254
261
  - lib/standard_id/oauth/authorization_flow.rb
255
262
  - lib/standard_id/oauth/base_request_flow.rb
256
263
  - lib/standard_id/oauth/client_credentials_flow.rb
264
+ - lib/standard_id/oauth/consent_payload.rb
265
+ - lib/standard_id/oauth/discovery_document.rb
257
266
  - lib/standard_id/oauth/implicit_authorization_flow.rb
258
267
  - lib/standard_id/oauth/oauth_session_persistence.rb
259
268
  - lib/standard_id/oauth/password_flow.rb