standard_id 0.20.1 → 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.
- checksums.yaml +4 -4
- data/{MIT-LICENSE → LICENSE} +3 -1
- data/app/controllers/standard_id/api/authorization_controller.rb +82 -0
- data/app/controllers/standard_id/api/well_known/oauth_authorization_server_controller.rb +35 -0
- data/app/controllers/standard_id/api/well_known/openid_configuration_controller.rb +1 -21
- data/app/controllers/standard_id/web/consent_controller.rb +144 -0
- data/app/controllers/standard_id/web/login_controller.rb +4 -0
- data/app/models/standard_id/client_grant.rb +44 -0
- data/app/views/standard_id/web/consent/error.html.erb +6 -0
- data/app/views/standard_id/web/consent/show.html.erb +35 -0
- data/app/views/standard_id/web/login/_social_buttons.html.erb +23 -0
- data/app/views/standard_id/web/login/show.html.erb +143 -88
- data/config/routes/api.rb +1 -0
- data/config/routes/web.rb +5 -0
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +23 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +7 -2
- data/lib/standard_id/oauth/consent_payload.rb +36 -0
- data/lib/standard_id/oauth/discovery_document.rb +53 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +2 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4999da4a28bb0450d14da9b7383099cbf67860c9c739c7785cc84f8ba2cc84f0
|
|
4
|
+
data.tar.gz: f6d6574ddb0c3e331dd955bd3fd1c65a3b30c523f7d721b7274762d558f017ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20d8e4560eacf2a272e2cadafcb5647a82781813a5164f510a91bf8f80fc6d3bb17a6287fee15b41704907b323a3039c279213603cfdf619639298b8ca952f80
|
|
7
|
+
data.tar.gz: 60b2c0fa613bcf40eae4197e73d122d957769f4faae6f85dafdec25e463ef375c336fa07308459e0c0c1d1c49ce7ef03ff0fe5d50f1839a0f1213323be74f3cc
|
data/{MIT-LICENSE → LICENSE}
RENAMED
|
@@ -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:
|
|
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
|
|
@@ -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
|
|
@@ -33,6 +33,10 @@ module StandardId
|
|
|
33
33
|
def show
|
|
34
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
|
|
@@ -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,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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
28
|
+
<p>
|
|
29
|
+
<%= form.submit "Continue" %>
|
|
30
|
+
</p>
|
|
31
|
+
<% end %>
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
</
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
141
|
+
</div>
|
|
98
142
|
</div>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
</div>
|
|
143
|
+
<% end %>
|
|
144
|
+
</div>
|
|
102
145
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
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
|
data/lib/standard_id/version.rb
CHANGED
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.
|
|
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
|
-
-
|
|
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
|