diff --git a/Gemfile b/Gemfile index 8bcc98e82..d1b958ca5 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,7 @@ gem 'nokogiri' gem 'omniauth' gem 'omniauth-github' gem 'omniauth-rails_csrf_protection' +gem 'jwt' gem 'pg' gem 'pickadate-rails' gem 'premailer-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 250372d73..7db9b80a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -652,6 +652,7 @@ DEPENDENCIES importmap-rails irb jquery-rails + jwt launchy letter_opener listen (~> 3.10) diff --git a/config/application.rb b/config/application.rb index 9576f452b..1f848cfec 100644 --- a/config/application.rb +++ b/config/application.rb @@ -14,7 +14,7 @@ class Application < Rails::Application # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. # Common ones are `templates`, `generators`, or `middleware`, for example. - config.autoload_lib(ignore: %w[assets tasks]) + config.autoload_lib(ignore: %w[assets tasks omniauth]) # Configuration for the application, engines, and railties goes here. # diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index df4c42611..c04fcec8c 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -1,3 +1,5 @@ +require 'omniauth/strategies/codebar' + Rails.application.config.middleware.use OmniAuth::Builder do if ENV['GITHUB_KEY'].blank? || ENV['GITHUB_SECRET'].blank? warn '*' * 80 @@ -10,6 +12,10 @@ else provider :github, ENV['GITHUB_KEY'], ENV['GITHUB_SECRET'], scope: 'user:email' end + + provider :codebar, + auth_url: ENV.fetch('CODEBAR_AUTH_URL', 'http://localhost:3001'), + audience: ENV.fetch('CODEBAR_AUDIENCE', 'planner') end OmniAuth.config.allowed_request_methods = [:post, :get] diff --git a/lib/omniauth/strategies/codebar.rb b/lib/omniauth/strategies/codebar.rb new file mode 100644 index 000000000..c474c4fd5 --- /dev/null +++ b/lib/omniauth/strategies/codebar.rb @@ -0,0 +1,223 @@ +require 'omniauth' +require 'net/http' +require 'uri' +require 'json' +require 'base64' +require 'digest' + +module OmniAuth + module Strategies + class Codebar + include OmniAuth::Strategy + + option :name, 'codebar' + option :auth_url, ENV.fetch('CODEBAR_AUTH_URL', 'http://localhost:3001') + # The OAuth provider sets aud to the client_id ("planner") in the id_token. + option :audience, ENV.fetch('CODEBAR_AUDIENCE', 'planner') + option :client_options, {} + + # Request phase: redirect to auth app OAuth 2.1 authorize endpoint with PKCE + def request_phase + state = SecureRandom.hex(16) + session['omniauth.codebar.state'] = state + + # Generate PKCE verifier and challenge + code_verifier = generate_code_verifier + session['omniauth.codebar.code_verifier'] = code_verifier + code_challenge = generate_code_challenge(code_verifier) + + redirect_uri = callback_url + session['omniauth.codebar.redirect_uri'] = redirect_uri + params = { + client_id: 'planner', + redirect_uri: redirect_uri, + response_type: 'code', + state: state, + scope: 'openid profile', + code_challenge: code_challenge, + code_challenge_method: 'S256' + } + + redirect "#{options.auth_url}/api/auth/oauth2/authorize?#{URI.encode_www_form(params)}" + end + + # Callback phase: exchange code for tokens and build auth hash + def callback_phase + error = request.params['error'] + if error + fail!(:auth_error, StandardError.new(error)) + return + end + + # Verify state/nonce + stored_state = session.delete('omniauth.codebar.state') + received_state = request.params['state'] + + if stored_state.nil? || received_state.nil? || stored_state != received_state + fail!(:csrf_detected, StandardError.new('State mismatch')) + return + end + + code = request.params['code'] + if code.nil? || code.empty? + fail!(:missing_code, StandardError.new('Missing authorization code')) + return + end + + code_verifier = session.delete('omniauth.codebar.code_verifier') + if code_verifier.nil? || code_verifier.empty? + fail!(:missing_pkce, StandardError.new('Missing PKCE verifier')) + return + end + + # Exchange code for tokens (server-to-server) + tokens = exchange_code(code, code_verifier) + if tokens.nil? + fail!(:exchange_failed, StandardError.new('Failed to exchange code')) + return + end + + id_token = tokens['id_token'] + if id_token.nil? || id_token.empty? + fail!(:missing_id_token, StandardError.new('Missing id_token in token response')) + return + end + + # Verify JWT + payload = verify_jwt(id_token) + if payload.nil? + fail!(:invalid_jwt, StandardError.new('JWT verification failed')) + return + end + + # Build omniauth.auth hash + email = payload['email'] || payload['sub'] + @env['omniauth.auth'] = AuthHash.new({ + provider: name, + uid: email, + info: { + email: email, + name: payload['name'] || email + }, + credentials: { + token: tokens['access_token'], + expires: tokens['expires_at'], + refresh_token: tokens['refresh_token'] + }, + extra: { + raw_info: payload + } + }) + + call_app! + rescue StandardError => e + return if env['omniauth.error'] + fail!(:unknown_error, e) + end + + private + + def http_for(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 5 + http.use_ssl = (uri.scheme == 'https') + http + end + + # Generate PKCE code verifier (43-128 random characters) + def generate_code_verifier + SecureRandom.urlsafe_base64(32) + end + + # Generate PKCE code challenge from verifier + def generate_code_challenge(verifier) + Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + end + + # Exchange authorization code for tokens via OAuth 2.1 token endpoint + def exchange_code(code, code_verifier) + uri = URI("#{options.auth_url}/api/auth/oauth2/token") + request = Net::HTTP::Post.new(uri.path) + request['Content-Type'] = 'application/x-www-form-urlencoded' + request.body = URI.encode_www_form({ + grant_type: 'authorization_code', + code: code, + client_id: 'planner', + redirect_uri: session.delete('omniauth.codebar.redirect_uri') || callback_url, + code_verifier: code_verifier + }) + + response = http_for(uri).request(request) + + if response.code.to_i == 200 + JSON.parse(response.body) + else + Rails.logger.warn "Codebar auth: token exchange failed: HTTP #{response.code} — #{response.body}" + nil + end + rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, JSON::ParserError => e + Rails.logger.warn "Codebar auth: exchange failed: #{e.class}: #{e.message}" + nil + end + + # Verify JWT signature using auth app's JWKS. + def verify_jwt(token) + jwks = fetch_jwks + return nil unless jwks + + decode = ->(jwks) { + JWT.decode(token, nil, true, { + algorithms: %w[RS256], + jwks: jwks, + iss: options.auth_url, + aud: options.audience, + verify_iss: true, + verify_aud: true + }).first + } + + decode.call(jwks) + rescue JWT::DecodeError => e + if e.message.match?(/public key for kid|kid/) + jwks = fetch_jwks(bust_cache: true) + jwks ? decode.call(jwks) : nil + else + Rails.logger.warn "Codebar auth: JWT decode error: #{e.message}" + nil + end + rescue JWT::ExpiredSignature + Rails.logger.warn "Codebar auth: JWT expired" + nil + end + + # Fetch JWKS from auth app, cached for 15 minutes. + # Pass bust_cache: true to skip cache and force refresh. + def fetch_jwks(bust_cache: false) + jwks_uri = URI("#{options.auth_url}/api/auth/jwks") + cache_key = "codebar_auth_jwks_#{options.auth_url}" + + unless bust_cache + cached = Rails.cache.read(cache_key) + return cached if cached + end + + response = http_for(jwks_uri).request(Net::HTTP::Get.new(jwks_uri.path)) + + if response.code.to_i == 200 + jwks = JSON.parse(response.body) + Rails.cache.write(cache_key, jwks, expires_in: 15.minutes) + jwks + else + Rails.logger.warn "Codebar auth: JWKS fetch returned HTTP #{response.code}" + nil + end + rescue StandardError => e + Rails.logger.warn "Codebar auth: JWKS fetch failed: #{e.class}: #{e.message}" + nil + end + + + end + end +end diff --git a/spec/lib/omniauth/strategies/codebar_spec.rb b/spec/lib/omniauth/strategies/codebar_spec.rb new file mode 100644 index 000000000..9f7181e20 --- /dev/null +++ b/spec/lib/omniauth/strategies/codebar_spec.rb @@ -0,0 +1,223 @@ +require 'rails_helper' +require 'omniauth' +require 'webmock/rspec' +require 'jwt' + +RSpec.describe OmniAuth::Strategies::Codebar do + subject(:strategy) { described_class.new(app, auth_url: auth_url, audience: 'planner') } + + let(:app) { ->(_env) { [200, {}, ['OK']] } } + let(:auth_url) { 'http://localhost:3001' } + let(:token_url) { "#{auth_url}/api/auth/oauth2/token" } + let(:jwks_url) { "#{auth_url}/api/auth/jwks" } + + let(:email) { 'user@example.com' } + let(:name) { 'Alice Smith' } + + let(:base_env) do + { + 'rack.session' => {}, + 'rack.input' => StringIO.new(''), + 'REQUEST_METHOD' => 'GET', + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '3000', + 'rack.url_scheme' => 'http', + } + end + + before do + OmniAuth.config.test_mode = false + end + + after do + OmniAuth.config.test_mode = true + WebMock.reset! + end + + def build_env(path, query: '', session: {}) + base_env.merge( + 'PATH_INFO' => path, + 'QUERY_STRING' => query, + 'rack.session' => session, + ) + end + + describe '#request_phase' do + it 'stores state and PKCE verifier in session and redirects to authorize endpoint' do + env = build_env('/auth/codebar') + status, headers, _body = strategy.call(env) + + expect(status).to eq(302) + expect(env['rack.session']['omniauth.codebar.state']).to be_present + expect(env['rack.session']['omniauth.codebar.code_verifier']).to be_present + + location = headers['Location'] + expect(location).to include('/api/auth/oauth2/authorize') + expect(location).to include('client_id=planner') + expect(location).to include('response_type=code') + expect(location).to include('code_challenge=') + expect(location).to include('code_challenge_method=S256') + expect(location).to include('scope=openid+profile') + expect(env['rack.session']['omniauth.codebar.redirect_uri']).to eq('http://localhost:3000/auth/codebar/callback') + end + + it 'generates unique state per request' do + env1 = build_env('/auth/codebar') + strategy.call(env1) + state1 = env1['rack.session']['omniauth.codebar.state'] + + env2 = build_env('/auth/codebar') + strategy.call(env2) + state2 = env2['rack.session']['omniauth.codebar.state'] + + expect(state1).not_to eq(state2) + end + end + + describe 'callback error paths' do + it 'fails with csrf_detected when state is missing from session' do + env = build_env('/auth/codebar/callback', query: 'code=abc&state=some-state') + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:csrf_detected) + end + + it 'fails with csrf_detected when state does not match' do + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=wrong-state', + session: { 'omniauth.codebar.state' => 'correct-state' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:csrf_detected) + end + + it 'fails with missing_code when code is absent' do + env = build_env('/auth/codebar/callback', + query: 'state=some-state', + session: { 'omniauth.codebar.state' => 'some-state' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:missing_code) + end + + it 'fails with missing_code when code is empty' do + env = build_env('/auth/codebar/callback', + query: 'code=&state=some-state', + session: { 'omniauth.codebar.state' => 'some-state' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:missing_code) + end + + it 'fails with missing_pkce when code_verifier is missing' do + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=some-state', + session: { 'omniauth.codebar.state' => 'some-state' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:missing_pkce) + end + + it 'fails with exchange_failed when token endpoint returns error' do + stub_request(:post, token_url) + .to_return(status: 400, body: '{"error":"invalid_grant"}') + + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=some-state', + session: { 'omniauth.codebar.state' => 'some-state', 'omniauth.codebar.code_verifier' => 'verifier', 'omniauth.codebar.redirect_uri' => 'http://localhost:3000/auth/codebar/callback' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:exchange_failed) + end + + it 'fails with missing_id_token when token response lacks id_token' do + stub_request(:post, token_url) + .to_return(status: 200, body: { access_token: 'foo' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=some-state', + session: { 'omniauth.codebar.state' => 'some-state', 'omniauth.codebar.code_verifier' => 'verifier', 'omniauth.codebar.redirect_uri' => 'http://localhost:3000/auth/codebar/callback' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:missing_id_token) + end + + it 'fails with invalid_jwt when JWT verification fails' do + stub_request(:post, token_url) + .to_return(status: 200, body: { access_token: 'foo', id_token: 'invalid.jwt.token' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + stub_request(:get, jwks_url) + .to_return(status: 200, body: { keys: [{ kty: 'RSA', kid: 'test', n: 'abc', e: 'AQAB' }] }.to_json) + + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=some-state', + session: { 'omniauth.codebar.state' => 'some-state', 'omniauth.codebar.code_verifier' => 'verifier', 'omniauth.codebar.redirect_uri' => 'http://localhost:3000/auth/codebar/callback' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_a(StandardError) + expect(env['omniauth.error.type']).to eq(:invalid_jwt) + end + end + + describe 'successful callback' do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:jwk) { JWT::JWK.new(rsa_key, { kid: 'test-key-1' }) } + let(:id_token) do + JWT.encode( + { 'sub' => email, 'name' => name, 'iss' => auth_url, 'aud' => 'planner', 'iat' => Time.now.to_i, 'exp' => Time.now.to_i + 3600 }, + rsa_key, + 'RS256', + { kid: 'test-key-1' } + ) + end + + before do + stub_request(:post, token_url) + .to_return(status: 200, body: { + access_token: 'test-access-token', + id_token: id_token, + token_type: 'Bearer', + expires_in: 900 + }.to_json, headers: { 'Content-Type' => 'application/json' }) + + stub_request(:get, jwks_url) + .to_return(status: 200, body: { keys: [jwk.export] }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'builds the auth hash with correct data' do + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=some-state', + session: { 'omniauth.codebar.state' => 'some-state', 'omniauth.codebar.code_verifier' => 'verifier', 'omniauth.codebar.redirect_uri' => 'http://localhost:3000/auth/codebar/callback' }) + strategy.call!(env) + + auth_hash = env['omniauth.auth'] + expect(auth_hash).to be_present + expect(auth_hash[:provider]).to eq('codebar') + expect(auth_hash[:uid]).to eq(email) + expect(auth_hash[:info][:email]).to eq(email) + expect(auth_hash[:info][:name]).to eq(name) + expect(auth_hash[:credentials][:token]).to eq('test-access-token') + expect(auth_hash[:extra][:raw_info]).to include('sub' => email, 'name' => name) + end + end + + describe 'call! routing' do + it 'routes /auth/codebar to request phase' do + env = build_env('/auth/codebar') + strategy.call!(env) + expect(env['rack.session']['omniauth.codebar.state']).to be_present + expect(env['rack.session']['omniauth.codebar.code_verifier']).to be_present + end + + it 'routes /auth/codebar/callback to callback phase' do + stub_request(:post, token_url) + .to_return(status: 400, body: '{"error":"invalid_grant"}') + + env = build_env('/auth/codebar/callback', + query: 'code=abc&state=test-state', + session: { 'omniauth.codebar.state' => 'test-state', 'omniauth.codebar.code_verifier' => 'verifier', 'omniauth.codebar.redirect_uri' => 'http://localhost:3000/auth/codebar/callback' }) + strategy.call!(env) + expect(env['omniauth.error']).to be_present + expect(env['omniauth.error.type']).to eq(:exchange_failed) + end + end +end