diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index d9dde41490f16..73b6b67ef3fbf 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -29,6 +29,15 @@ def app_association_ios render plain: SiteSetting.app_association_ios, content_type: "application/json" end + def discourse_id_challenge + token = Discourse.redis.get("discourse_id_challenge_token") + raise Discourse::NotFound if token.blank? + + expires_in 5.minutes + + render json: { token:, domain: Discourse.current_hostname } + end + private def default_manifest diff --git a/app/services/discourse_id/register.rb b/app/services/discourse_id/register.rb new file mode 100644 index 0000000000000..eb7a0d21c18e3 --- /dev/null +++ b/app/services/discourse_id/register.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class DiscourseId::Register + include Service::Base + + params { attribute :force, :boolean, default: false } + + policy :not_already_registered? + step :request_challenge + step :store_challenge_token + step :register_with_challenge + step :store_credentials + step :enable_discourse_id + + private + + def not_already_registered?(params:) + return true if params.force + + SiteSetting.discourse_id_client_id.blank? && SiteSetting.discourse_id_client_secret.blank? + end + + def request_challenge + uri = URI("#{discourse_id_url}/challenge") + use_ssl = Rails.env.production? || uri.scheme == "https" + + request = Net::HTTP::Post.new(uri) + request.content_type = "application/json" + request.body = { domain: Discourse.current_hostname }.to_json + + begin + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl:) { |http| http.request(request) } + rescue StandardError => e + return fail!(error: "Challenge request failed: #{e.message}") + end + + if response.code.to_i != 200 + return fail!(error: "Failed to request challenge: #{response.code}\nError: #{response.body}") + end + + begin + json = JSON.parse(response.body) + rescue JSON::ParserError => e + return fail!(error: "Challenge response invalid JSON: #{e.message}") + end + + if json["domain"] != Discourse.current_hostname + return fail!(error: "Domain mismatch in challenge response") + end + + context[:token] = json["token"] + end + + def store_challenge_token(token:) + Discourse.redis.setex("discourse_id_challenge_token", 600, token) + end + + def register_with_challenge(token:) + uri = URI("#{discourse_id_url}/register") + use_ssl = Rails.env.production? || uri.scheme == "https" + + request = Net::HTTP::Post.new(uri) + request.content_type = "application/json" + request.body = { + client_name: SiteSetting.title, + redirect_uri: "#{Discourse.base_url}/auth/discourse_id/callback", + challenge_token: token, + logo_uri: SiteSetting.site_logo_url.presence, + logo_small_uri: SiteSetting.site_logo_small_url.presence, + description: SiteSetting.site_description.presence, + }.compact.to_json + + begin + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl:) { |http| http.request(request) } + rescue StandardError => e + return fail!(error: "Registration request failed: #{e.message}") + end + + if response.code.to_i != 200 + return fail!(error: "Registration failed: #{response.code}\nError: #{response.body}") + end + + begin + context[:data] = JSON.parse(response.body) + rescue JSON::ParserError => e + fail!(error: "Registration response invalid JSON: #{e.message}") + end + end + + def store_credentials(data:) + SiteSetting.discourse_id_client_id = data["client_id"] + SiteSetting.discourse_id_client_secret = data["client_secret"] + end + + def enable_discourse_id + SiteSetting.enable_discourse_id = true + end + + def discourse_id_url + SiteSetting.discourse_id_provider_url.presence || "https://id.discourse.com" + end +end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 45a1a39bdd02d..c9375c173d582 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -5898,3 +5898,6 @@ en: reserved_id: "has a reserved keyword as id: %{id}" unsafe_description: "has an unsafe HTML description" invalid_tag_group: "has invalid tag group: %{tag_group_name}" + + discourse_id: + already_registered: "This site is already registered with Discourse ID" diff --git a/config/routes.rb b/config/routes.rb index 266a2fd77dbcb..365a0b5103416 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1628,6 +1628,7 @@ def patch(*) get "manifest.webmanifest" => "metadata#manifest", :as => :manifest get "manifest.json" => "metadata#manifest" get ".well-known/assetlinks.json" => "metadata#app_association_android" + get ".well-known/discourse-id-challenge" => "metadata#discourse_id_challenge" # Apple accepts either of these paths for the apple-app-site-association file # Might as well support both get "apple-app-site-association" => "metadata#app_association_ios", :format => false diff --git a/lib/tasks/admin.rake b/lib/tasks/admin.rake index a5fa3266af6ba..e42597538aa84 100644 --- a/lib/tasks/admin.rake +++ b/lib/tasks/admin.rake @@ -110,3 +110,76 @@ task "admin:create" => :environment do say("\nYour account now has Admin privileges!") end end + +desc "Register this Discourse instance with Discourse ID" +task "admin:register_discourse_id" => :environment do + require "highline/import" + + begin + puts + puts "=== Discourse ID Registration ===" + puts + + if SiteSetting.discourse_id_client_id.present? && + SiteSetting.discourse_id_client_secret.present? + puts "⚠️ This site is already registered with Discourse ID." + puts " Client ID: #{SiteSetting.discourse_id_client_id}" + puts + + force = ask("Do you want to re-register? This will replace existing credentials. (y/N): ") + if force.downcase != "y" + puts "Registration cancelled." + exit 0 + end + + puts "Proceeding with forced re-registration..." + force_param = true + else + puts "🔗 This will register your Discourse instance with Discourse ID." + puts " Provider URL: #{SiteSetting.discourse_id_provider_url.presence || "https://id.discourse.com"}" + puts " Site Title: #{SiteSetting.title}" + puts " Site URL: #{Discourse.base_url}" + puts + + confirm = ask("Continue with registration? (Y/n): ") + if confirm.downcase == "n" + puts "Registration cancelled." + exit 0 + end + + force_param = false + end + + puts + puts "🚀 Starting registration process..." + + result = DiscourseId::Register.call(params: { force: force_param }) + + if result.success? + puts + puts "✅ Registration successful!" + puts " Client ID: #{SiteSetting.discourse_id_client_id}" + puts " Discourse ID is now enabled: #{SiteSetting.enable_discourse_id}" + puts + puts "🎉 Your Discourse instance is now registered with Discourse ID!" + puts " Users can now use Discourse ID to log in to your site." + else + puts + puts "❌ Registration failed!" + puts " Error: #{result.error}" if result.error + puts " Please check your network connection and try again." + puts " If the problem persists, contact support." + exit 1 + end + rescue Interrupt + puts + puts "Registration cancelled by user." + exit 1 + rescue => e + puts + puts "❌ An unexpected error occurred:" + puts " #{e.class}: #{e.message}" + puts " Please try again or contact support if the problem persists." + exit 1 + end +end diff --git a/spec/requests/metadata_controller_spec.rb b/spec/requests/metadata_controller_spec.rb index 8f2312d1ba28c..e25e440581a70 100644 --- a/spec/requests/metadata_controller_spec.rb +++ b/spec/requests/metadata_controller_spec.rb @@ -205,4 +205,36 @@ expect(response.status).to eq(404) end end + + describe "#discourse_id_challenge" do + context "when challenge token is present in Redis" do + let(:token) { SecureRandom.hex(16) } + + before { Discourse.redis.setex("discourse_id_challenge_token", 600, token) } + after { Discourse.redis.del("discourse_id_challenge_token") } + + it "returns the challenge token and domain" do + get "/.well-known/discourse-id-challenge" + + expect(response.status).to eq(200) + expect(response.media_type).to eq("application/json") + expect(response.headers["Cache-Control"]).to eq("max-age=300, private") + + json = response.parsed_body + expect(json["token"]).to eq(token) + expect(json["domain"]).to eq(Discourse.current_hostname) + end + end + + context "when no challenge token is present" do + before { Discourse.redis.del("discourse_id_challenge_token") } + + it "returns 404" do + get "/.well-known/discourse-id-challenge" + + expect(response.status).to eq(404) + expect(response.cache_control).to eq({}) + end + end + end end diff --git a/spec/services/discourse_id/register_spec.rb b/spec/services/discourse_id/register_spec.rb new file mode 100644 index 0000000000000..b18a2f64e22d4 --- /dev/null +++ b/spec/services/discourse_id/register_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseId::Register do + describe "#call" do + subject(:result) { described_class.call(params:) } + + let(:params) { { force: false } } + let(:challenge_token) { "test_challenge_token_123" } + let(:client_id) { "test_client_id" } + let(:client_secret) { "test_client_secret" } + let(:discourse_id_url) { "https://id.discourse.com" } + + fab!(:logo_upload) { Fabricate(:upload) } + fab!(:logo_small_upload) { Fabricate(:upload) } + + before do + SiteSetting.discourse_id_provider_url = discourse_id_url + SiteSetting.title = "Test Forum" + SiteSetting.site_description = "A test forum" + SiteSetting.logo = logo_upload + SiteSetting.logo_small = logo_small_upload + end + + context "when already registered and force is false" do + before do + SiteSetting.discourse_id_client_id = client_id + SiteSetting.discourse_id_client_secret = client_secret + end + + it { is_expected.to fail_a_policy(:not_already_registered?) } + end + + context "when already registered but force is true" do + let(:params) { { force: true } } + + before do + SiteSetting.discourse_id_client_id = client_id + SiteSetting.discourse_id_client_secret = client_secret + end + + context "when challenge request fails" do + before do + stub_request(:post, "#{discourse_id_url}/challenge").to_raise( + StandardError.new("Network error"), + ) + end + + it { is_expected.to fail_a_step(:request_challenge) } + end + + context "when challenge request returns non-200 status" do + before do + stub_request(:post, "#{discourse_id_url}/challenge").to_return( + status: 400, + body: "Bad Request", + ) + end + + it { is_expected.to fail_a_step(:request_challenge) } + end + + context "when challenge response is invalid JSON" do + before do + stub_request(:post, "#{discourse_id_url}/challenge").to_return( + status: 200, + body: "invalid json", + ) + end + + it { is_expected.to fail_a_step(:request_challenge) } + end + + context "when challenge response has domain mismatch" do + before do + stub_request(:post, "#{discourse_id_url}/challenge").to_return( + status: 200, + body: { domain: "wrong-domain.com", token: challenge_token }.to_json, + ) + end + + it { is_expected.to fail_a_step(:request_challenge) } + end + + context "when challenge request succeeds" do + before do + stub_request(:post, "#{discourse_id_url}/challenge").with( + body: { domain: Discourse.current_hostname }.to_json, + headers: { + "Content-Type" => "application/json", + }, + ).to_return( + status: 200, + body: { domain: Discourse.current_hostname, token: challenge_token }.to_json, + ) + end + + context "when registration request fails" do + before do + stub_request(:post, "#{discourse_id_url}/register").to_raise( + StandardError.new("Connection timeout"), + ) + end + + it { is_expected.to fail_a_step(:register_with_challenge) } + end + + context "when registration returns non-200 status" do + before do + stub_request(:post, "#{discourse_id_url}/register").to_return( + status: 422, + body: "Validation failed", + ) + end + + it { is_expected.to fail_a_step(:register_with_challenge) } + end + + context "when registration response is invalid JSON" do + before do + stub_request(:post, "#{discourse_id_url}/register").to_return( + status: 200, + body: "not json", + ) + end + + it { is_expected.to fail_a_step(:register_with_challenge) } + end + + context "when registration succeeds" do + let(:response_data) do + { client_id: "new_client_id_123", client_secret: "new_client_secret_456" } + end + + before do + stub_request(:post, "#{discourse_id_url}/register").with( + body: { + client_name: SiteSetting.title, + redirect_uri: "#{Discourse.base_url}/auth/discourse_id/callback", + challenge_token: challenge_token, + logo_uri: SiteSetting.site_logo_url, + logo_small_uri: SiteSetting.site_logo_small_url, + description: SiteSetting.site_description, + }.to_json, + headers: { + "Content-Type" => "application/json", + }, + ).to_return(status: 200, body: response_data.to_json) + end + + it { is_expected.to run_successfully } + + it "stores the challenge token in Redis" do + result + expect(Discourse.redis.get("discourse_id_challenge_token")).to eq(challenge_token) + end + + it "stores credentials in SiteSetting" do + result + expect(SiteSetting.discourse_id_client_id).to eq("new_client_id_123") + expect(SiteSetting.discourse_id_client_secret).to eq("new_client_secret_456") + end + + it "enables Discourse ID" do + expect { result }.to change { SiteSetting.enable_discourse_id }.to(true) + end + + it "sets Redis expiration for challenge token" do + result + expect(Discourse.redis.ttl("discourse_id_challenge_token")).to be > 0 + end + + context "when site has no logo URLs" do + before do + SiteSetting.logo = nil + SiteSetting.logo_small = nil + + stub_request(:post, "#{discourse_id_url}/register").with( + body: { + client_name: SiteSetting.title, + redirect_uri: "#{Discourse.base_url}/auth/discourse_id/callback", + challenge_token: challenge_token, + description: SiteSetting.site_description, + }.to_json, + ).to_return(status: 200, body: response_data.to_json) + end + + it "omits logo fields from registration request" do + result + expect(WebMock).to have_requested(:post, "#{discourse_id_url}/register").with { |req| + body = JSON.parse(req.body) + !body.key?("logo_uri") && !body.key?("logo_small_uri") + } + end + end + + context "when site has no description" do + before do + SiteSetting.site_description = nil + + stub_request(:post, "#{discourse_id_url}/register").with( + body: { + client_name: SiteSetting.title, + redirect_uri: "#{Discourse.base_url}/auth/discourse_id/callback", + challenge_token: challenge_token, + logo_uri: SiteSetting.site_logo_url, + logo_small_uri: SiteSetting.site_logo_small_url, + }.to_json, + ).to_return(status: 200, body: response_data.to_json) + end + + it "omits description from registration request" do + result + expect(WebMock).to have_requested(:post, "#{discourse_id_url}/register").with { |req| + body = JSON.parse(req.body) + !body.key?("description") + } + end + end + end + end + end + + context "when not already registered" do + before do + SiteSetting.discourse_id_client_id = "" + SiteSetting.discourse_id_client_secret = "" + + stub_request(:post, "#{discourse_id_url}/challenge").to_return( + status: 200, + body: { domain: Discourse.current_hostname, token: challenge_token }.to_json, + ) + + stub_request(:post, "#{discourse_id_url}/register").to_return( + status: 200, + body: { client_id:, client_secret: }.to_json, + ) + end + + it { is_expected.to run_successfully } + + it "completes the full registration flow" do + result + expect(SiteSetting.discourse_id_client_id).to eq(client_id) + expect(SiteSetting.discourse_id_client_secret).to eq(client_secret) + expect(SiteSetting.enable_discourse_id).to be(true) + end + end + + context "when using custom discourse_id_provider_url" do + let(:custom_url) { "https://custom-id.example.com" } + + before do + SiteSetting.discourse_id_provider_url = custom_url + SiteSetting.discourse_id_client_id = "" + SiteSetting.discourse_id_client_secret = "" + + stub_request(:post, "#{custom_url}/challenge").to_return( + status: 200, + body: { domain: Discourse.current_hostname, token: challenge_token }.to_json, + ) + + stub_request(:post, "#{custom_url}/register").to_return( + status: 200, + body: { client_id:, client_secret: }.to_json, + ) + end + + it "uses the custom URL for requests" do + result + expect(WebMock).to have_requested(:post, "#{custom_url}/challenge") + expect(WebMock).to have_requested(:post, "#{custom_url}/register") + end + end + end +end