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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 664c61bf0dc0dbcf758fe4d114fad3776d0327777b64dde60f6d9d9f11db14cc
4
- data.tar.gz: c87f620480f13c64051a0d915b37e2fd295f224f0a41fe0c4820bb8131321c70
3
+ metadata.gz: 4999da4a28bb0450d14da9b7383099cbf67860c9c739c7785cc84f8ba2cc84f0
4
+ data.tar.gz: f6d6574ddb0c3e331dd955bd3fd1c65a3b30c523f7d721b7274762d558f017ca
5
5
  SHA512:
6
- metadata.gz: 4531519bbd926a39fda0180809a305f1e7de319cb278d1b75b178616548b586d9b767db3eb9e58e661045bc513efbc6785b5a63fa858b91fd74a832170f96c50
7
- data.tar.gz: b7625be363e4f848fdd7c61f51c287ed8153e2671b64e52e0acef48de4e0ff95668529e5099f7408d4713321406728c8a6d1225418bc4ba03444bd0c12916430
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
@@ -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
@@ -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,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.1"
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.1
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