diff --git a/.gitignore b/.gitignore index 5ac8c1859..dbfa0f999 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ spec/examples.txt public/commit_id.txt vendor/cache/ vendor/bundle/ + +# Local docker-compose overrides (port mappings, volume mounts) +docker-compose.override.yml diff --git a/Gemfile b/Gemfile index d1c112662..bfe0a21f1 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,8 @@ gem 'dalli' gem 'deep_cloneable', '~> 3.2.0' gem 'devise', '~> 4.9' gem 'doorkeeper', '~> 5.8' -gem 'doorkeeper-jwt', '~> 0.2.1' +gem 'doorkeeper-jwt', '~> 0.4' +gem 'doorkeeper-openid_connect', '~> 1.9' gem 'faraday', '~> 1.10' gem 'faraday-http-cache', '~> 2.4' gem 'faraday_middleware', '~> 1.2' @@ -33,7 +34,7 @@ gem 'librato-metrics', '~> 2.1.2' gem 'lograge' gem 'mime-types' gem 'p3p', '~> 2.0' -gem 'panoptes-client' +gem 'panoptes-client', '~> 1.3' gem 'pg', '~> 1.4' gem 'pg_search' gem 'puma', '~> 6.4.3' diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index b6b576f6c..6d2489160 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -20,11 +20,12 @@ def self.force_secure_scheme?(host) orm :active_record use_refresh_token + use_pkce enable_application_owner :confirmation => true default_scopes :public - optional_scopes *Doorkeeper::Panoptes::Scopes.optional + optional_scopes :openid, *Doorkeeper::Panoptes::Scopes.optional realm "Panoptes" @@ -99,5 +100,5 @@ def self.force_secure_scheme?(host) secret_key_path Rails.root.join("config", "keys", "doorkeeper-jwt-#{Rails.env}.pem") # Sign using RSA SHA-512 - encryption_method :rs512 + signing_method :rs512 end diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb new file mode 100644 index 000000000..79a9e36fe --- /dev/null +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/BlockLength +Doorkeeper::OpenidConnect.configure do + issuer do |_resource_owner, _application| + if Rails.env.production? + 'https://panoptes.zooniverse.org' + elsif Rails.env.staging? # rubocop:disable Rails/UnknownEnv + 'https://panoptes-staging.zooniverse.org' + else + 'http://localhost:3000' + end + end + + # Use the same RSA key that Doorkeeper::JWT uses for signing access tokens. + signing_key lambda { + key_path = Rails.root.join('config', 'keys', "doorkeeper-jwt-#{Rails.env}.pem") + File.read(key_path) + } + + signing_algorithm :rs512 + + # Resolve the resource owner (User) from a Doorkeeper access token. + resource_owner_from_access_token do |access_token| + User.find_by(id: access_token.resource_owner_id) + end + + # Return the time the user last authenticated. Devise's current_sign_in_at tracks this. + auth_time_from_resource_owner do |resource_owner| + resource_owner.current_sign_in_at&.to_i + end + + # Re-authentication handler. For the password grant flow used in development, + # the user is always freshly authenticated so we return them directly. + reauthenticate_resource_owner do |resource_owner, _return_to| + resource_owner + end + + # OIDC subject identifier - unique, stable user ID. + subject do |resource_owner, _application| + resource_owner.id + end + + subject_types_supported %i[public] + + claims do + claim :login, scope: :openid do |resource_owner, _scopes, _access_token| + resource_owner.login + end + + claim :name, scope: :openid do |resource_owner, _scopes, _access_token| + resource_owner.display_name + end + + claim :credited_name, scope: :openid do |resource_owner, _scopes, _access_token| + resource_owner.credited_name + end + + claim :email, scope: :openid, response: %i[id_token user_info] do |resource_owner, _scopes, _access_token| + resource_owner.email + end + + claim :email_verified, scope: :openid, response: %i[id_token user_info] do |resource_owner, _scopes, _access_token| + resource_owner.confirmed_at.present? + end + + claim :admin, scope: :openid do |resource_owner, _scopes, _access_token| + resource_owner.admin? + end + + claim :created_at, scope: :openid do |resource_owner, _scopes, _access_token| + resource_owner.created_at.to_i + end + + claim :zooniverse_id, scope: :openid do |resource_owner, _scopes, _access_token| + resource_owner.zooniverse_id + end + end +end +# rubocop:enable Metrics/BlockLength diff --git a/config/routes.rb b/config/routes.rb index 803a1fe5d..5dc908e1c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,8 @@ applications: 'applications' end + use_doorkeeper_openid_connect + devise_for :users, controllers: { confirmations: 'confirmations', passwords: 'passwords' }, skip: [ :sessions, :registrations ] diff --git a/db/dev_seed_data/dev_seed_noninteractive.rb b/db/dev_seed_data/dev_seed_noninteractive.rb new file mode 100644 index 000000000..aca6f3d30 --- /dev/null +++ b/db/dev_seed_data/dev_seed_noninteractive.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +exit unless Rails.env.development? + +# Non-interactive version of dev_seed_data.rb for automated setup. +# Uses a default password for the local admin user. + +DEFAULT_PASSWORD = 'password1234' + +Rails.logger.info "\n=== Panoptes Local Dev Setup (non-interactive) ===" + +# Setup admin user +attrs = { + admin: true, + password: DEFAULT_PASSWORD, + login: 'zooniverse_admin', + email: 'no-reply@zooniverse.org' +} +admin = User.where(login: 'zooniverse_admin').first_or_create(attrs, &:build_identity_group) + +if admin.persisted? + Rails.logger.info "Admin user: #{admin.login} (email: #{admin.email})" + Rails.logger.info "Password: #{DEFAULT_PASSWORD}" +else + Rails.logger.info "ERROR: Failed to create admin user: #{admin.errors.full_messages.join(', ')}" + exit 1 +end + +# Setup Doorkeeper first-party OAuth application with openid scope +app = Doorkeeper::Application.where(name: 'DevAppClient').first_or_create do |da| + da.owner = admin + da.name = 'DevAppClient' + da.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob' + da.trust_level = 2 + da.confidential = false + scopes = %i[public openid] | Doorkeeper::Panoptes::Scopes.optional + da.default_scope = scopes.map(&:to_s) +end + +if app.persisted? + Rails.logger.info "OAuth app: #{app.name}" + Rails.logger.info "Client ID: #{app.uid}" + Rails.logger.info "\nAccess at: http://localhost:3000/oauth/applications" +else + Rails.logger.info "ERROR: Failed to create OAuth app: #{app.errors.full_messages.join(', ')}" + exit 1 +end + +Rails.logger.info "\n=== Setup complete ===" diff --git a/db/migrate/20260420200000_create_doorkeeper_openid_connect_tables.rb b/db/migrate/20260420200000_create_doorkeeper_openid_connect_tables.rb new file mode 100644 index 000000000..cd47d754d --- /dev/null +++ b/db/migrate/20260420200000_create_doorkeeper_openid_connect_tables.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateDoorkeeperOpenidConnectTables < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + safety_assured do + # Schema defined by doorkeeper-openid_connect; timestamps not applicable. + create_table :oauth_openid_requests do |t| # rubocop:disable Rails/CreateTableWithTimestamps + t.references :access_grant, null: false, index: true + t.string :nonce, null: false + end + + add_foreign_key( + :oauth_openid_requests, + :oauth_access_grants, + column: :access_grant_id, + on_delete: :cascade + ) + end + end +end diff --git a/db/migrate/20260421100000_add_pkce_to_doorkeeper_access_grants.rb b/db/migrate/20260421100000_add_pkce_to_doorkeeper_access_grants.rb new file mode 100644 index 000000000..c1c499cd8 --- /dev/null +++ b/db/migrate/20260421100000_add_pkce_to_doorkeeper_access_grants.rb @@ -0,0 +1,6 @@ +class AddPkceToDoorkeeperAccessGrants < ActiveRecord::Migration[6.1] + def change + add_column :oauth_access_grants, :code_challenge, :string, null: true + add_column :oauth_access_grants, :code_challenge_method, :string, null: true + end +end diff --git a/docker-compose.yml b/docker-compose.yml index 7c63d0557..8c277eb9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: - "POSTGRES_USER=panoptes" - "POSTGRES_PASSWORD=panoptes" ports: - - "5432:5432" + - "${PG_PORT:-5432}:5432" redis: image: redis @@ -21,7 +21,7 @@ services: - ./:/rails_app - gem_cache:/usr/local/bundle ports: - - "3000:3000" + - "${PANOPTES_PORT:-3000}:3000" environment: - "RAILS_ENV=development" - "DATABASE_URL=postgresql://panoptes:panoptes@pg"