Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/controllers/metadata_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions app/services/discourse_id/register.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions lib/tasks/admin.rake
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions spec/requests/metadata_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading