diff --git a/.gitignore b/.gitignore index 70544e847..3fc25bd96 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ vendor/cache/ config/database.yml .rvmrc_custom .rvmrc.local +config/oidc_key.pem # Mailing list stuff config/email_offset diff --git a/Changelog.md b/Changelog.md index daaf272ac..9b132e94d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -30,6 +30,15 @@ bind to an UNIX socket at `unix:tmp/diaspora.sock`. Please change your local With the port to Bootstrap 3, app/views/terms/default.haml has a new structure. If you have created a customised app/views/terms/terms.haml or app/views/terms/terms.erb file, you will need to edit those files to base your customisations on the new default.haml file. +## API authentication + +This release makes diaspora\* a OpenID Connect provider. This means you can authenticate to third parties with your diaspora\* account and let +them act as your diaspora* account on your behalf. This feature is still considered in early development, we still expect edge cases and advanced +features of the specificiation to not be handled correctly or be missing. But we expect a basic OpenID Connect compliant client to work. Please submit issues! +We will also most likely still change the authorization scopes we offer and started with a very minimal set. +Most work still required is on documentation as well as designing and implementing the data API for all of Diaspora's functionality. +Contributions are very welcome, the hard work is done! + ## Refactor * Improve bookmarklet [#5904](https://github.com/diaspora/diaspora/pull/5904) * Update listen configuration to listen on unix sockets by default [#5974](https://github.com/diaspora/diaspora/pull/5974) diff --git a/Gemfile b/Gemfile index eec480e66..509340162 100644 --- a/Gemfile +++ b/Gemfile @@ -149,6 +149,9 @@ gem "omniauth-twitter", "1.2.1" gem "twitter", "5.15.0" gem "omniauth-wordpress", "0.2.2" +# OpenID Connect +gem "openid_connect", "0.8.3" + # Serializers gem "active_model_serializers", "0.9.3" @@ -192,6 +195,8 @@ gem "rubyzip", "1.1.7" # https://github.com/discourse/discourse/pull/238 gem "minitest" +gem "versionist", "1.4.1" + # Windows and OSX have an execjs compatible runtime built-in, Linux users should # install Node.js or use "therubyracer". # @@ -276,6 +281,9 @@ group :test do gem "database_cleaner" , "1.5.1" gem "selenium-webdriver", "2.47.1" + gem "cucumber-api-steps", "0.13", require: false + gem "json_spec", "1.1.4" + # General helpers gem "factory_girl_rails", "4.5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 050ba10f9..cf2775cf7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -57,6 +57,7 @@ GEM ast (2.2.0) astrolabe (1.3.1) parser (~> 2.2) + attr_required (1.0.0) autoprefixer-rails (6.2.2) execjs json @@ -66,6 +67,7 @@ GEM jquery-rails railties bcrypt (3.1.10) + bindata (2.1.0) bootstrap-sass (3.3.6) autoprefixer-rails (>= 5.2.1) sass (>= 3.3.4) @@ -126,6 +128,10 @@ GEM gherkin (~> 2.12) multi_json (>= 1.7.5, < 2.0) multi_test (>= 0.1.2) + cucumber-api-steps (0.13) + cucumber (>= 1.2.1) + jsonpath (>= 0.1.2) + rspec (>= 2.12.0) cucumber-rails (1.4.2) capybara (>= 1.1.2, < 3) cucumber (>= 1.3.8, < 2) @@ -390,6 +396,7 @@ GEM httparty (0.13.7) json (~> 1.8) multi_xml (>= 0.5.2) + httpclient (2.7.1) i18n (0.7.0) i18n-inflector (2.6.7) i18n (>= 0.4.1) @@ -423,8 +430,19 @@ GEM multi_json (>= 1.3) rake json (1.8.3) + json-jwt (1.5.1) + activesupport + bindata + multi_json (>= 1.3) + securecompare + url_safe_base64 json-schema (2.5.2) addressable (~> 2.3.8) + json_spec (1.1.4) + multi_json (~> 1.0) + rspec (>= 2.0, < 4.0) + jsonpath (0.5.7) + multi_json jwt (1.5.2) kaminari (0.16.3) actionpack (>= 3.0.0) @@ -504,6 +522,17 @@ GEM open_graph_reader (0.6.1) faraday (~> 0.9.0) nokogiri (~> 1.6) + openid_connect (0.8.3) + activemodel + attr_required (>= 0.0.5) + json (>= 1.4.3) + json-jwt (>= 0.5.5) + rack-oauth2 (>= 1.0.0) + swd (>= 0.1.2) + tzinfo + validate_email + validate_url + webfinger (>= 0.0.2) orm_adapter (0.5.0) parser (2.2.3.0) ast (>= 1.1, < 3.0) @@ -545,6 +574,12 @@ GEM activesupport rack-mobile-detect (0.4.0) rack + rack-oauth2 (1.2.1) + activesupport (>= 2.3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) + multi_json (>= 1.3.6) + rack (>= 1.1) rack-piwik (0.3.0) rack-pjax (0.8.0) nokogiri (~> 1.5) @@ -708,6 +743,7 @@ GEM scss_lint (0.42.2) rainbow (~> 2.0) sass (~> 3.4.15) + securecompare (1.0.0) selenium-webdriver (2.47.1) childprocess (~> 0.5) multi_json (~> 1.0) @@ -757,6 +793,12 @@ GEM activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) state_machine (1.2.0) + swd (1.0.0) + activesupport (>= 3) + attr_required (>= 0.0.5) + httpclient (>= 2.4) + i18n + json (>= 1.4.3) sysexits (1.2.0) systemu (2.6.5) terminal-table (1.5.2) @@ -797,11 +839,26 @@ GEM kgio (~> 2.6) rack raindrops (~> 0.7) + url_safe_base64 (0.2.2) uuid (2.3.8) macaddr (~> 1.0) valid (1.1.0) + validate_email (0.1.6) + activemodel (>= 3.0) + mail (>= 2.2.5) + validate_url (1.0.2) + activemodel (>= 3.0.0) + addressable + versionist (1.4.1) + activesupport (>= 3) + railties (>= 3) + yard (~> 0.7) warden (1.2.4) rack (>= 1.0) + webfinger (1.0.1) + activesupport + httpclient (>= 2.4) + multi_json webmock (1.22.3) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -811,6 +868,7 @@ GEM xml-simple (1.1.5) xpath (2.0.0) nokogiri (~> 1.3) + yard (0.8.7.6) PLATFORMS ruby @@ -830,6 +888,7 @@ DEPENDENCIES carrierwave (= 0.10.0) compass-rails (= 2.0.5) configurate (= 0.3.1) + cucumber-api-steps (= 0.13) cucumber-rails (= 1.4.2) database_cleaner (= 1.5.1) devise (= 3.5.3) @@ -867,6 +926,7 @@ DEPENDENCIES jshintrb (= 0.3.0) json (= 1.8.3) json-schema (= 2.5.2) + json_spec (= 1.1.4) leaflet-rails (= 0.7.4) logging-rails (= 0.5.0) markerb (= 1.1.0) @@ -882,6 +942,7 @@ DEPENDENCIES omniauth-twitter (= 1.2.1) omniauth-wordpress (= 0.2.2) open_graph_reader (= 0.6.1) + openid_connect (= 0.8.3) pg (= 0.18.4) pronto (= 0.5.3) pronto-haml (= 0.5.0) @@ -952,6 +1013,7 @@ DEPENDENCIES uglifier (= 2.7.2) unicorn (= 5.0.1) uuid (= 2.3.8) + versionist (= 1.4.1) webmock (= 1.22.3) will_paginate (= 3.0.7) diff --git a/app/assets/javascripts/api/authorization_page.js b/app/assets/javascripts/api/authorization_page.js new file mode 100644 index 000000000..d61b941ec --- /dev/null +++ b/app/assets/javascripts/api/authorization_page.js @@ -0,0 +1,5 @@ +$(document).ready(function() { + $("#js-app-logo").error(function () { + $(this).attr("src", ImagePaths.get("user/default.png")); + }); +}); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ff8d91750..151d99bbf 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -45,3 +45,4 @@ //= require bootstrap-switch //= require blueimp-gallery //= require leaflet +//= require api/authorization_page diff --git a/app/assets/stylesheets/_application.scss b/app/assets/stylesheets/_application.scss index 37405d3b0..f61519547 100644 --- a/app/assets/stylesheets/_application.scss +++ b/app/assets/stylesheets/_application.scss @@ -1,7 +1,7 @@ @import 'perfect-scrollbar'; @import 'color-variables'; -@import "bootstrap-complete.scss"; +@import 'bootstrap-complete'; @import 'mixins'; @@ -99,5 +99,11 @@ @import 'statistics'; /* gallery */ -@import "blueimp-gallery"; -@import "gallery"; +@import 'blueimp-gallery'; +@import 'gallery'; + +// settings +@import 'user_applications'; + +// OpenID Connect (API) +@import 'openid_connect_error_page'; diff --git a/app/assets/stylesheets/mobile/mobile.scss b/app/assets/stylesheets/mobile/mobile.scss index 4ad15e0e3..5b76b4acb 100644 --- a/app/assets/stylesheets/mobile/mobile.scss +++ b/app/assets/stylesheets/mobile/mobile.scss @@ -13,6 +13,7 @@ @import "mobile/settings"; @import "mobile/stream_element"; @import "mobile/comments"; +@import 'mobile/openid_connect_error_page'; @import 'typography'; diff --git a/app/assets/stylesheets/mobile/openid_connect_error_page.scss b/app/assets/stylesheets/mobile/openid_connect_error_page.scss new file mode 100644 index 000000000..c87efeb4e --- /dev/null +++ b/app/assets/stylesheets/mobile/openid_connect_error_page.scss @@ -0,0 +1,9 @@ +.landing { margin: -56px -20px 10px; } + +.api-error { + background-color: $light-grey; + box-shadow: $card-shadow; + margin-top: 20px; + + h4 { text-align: center; } +} diff --git a/app/assets/stylesheets/mobile/settings.scss b/app/assets/stylesheets/mobile/settings.scss index 846d5159f..76bbbffb2 100644 --- a/app/assets/stylesheets/mobile/settings.scss +++ b/app/assets/stylesheets/mobile/settings.scss @@ -30,11 +30,18 @@ } } -#blocked_people { - .blocked_person { - border-bottom: 1px solid $border-grey; - margin-top: 0; - .avatar { max-width: 35px; } - .info { color: $text; } +.applications-page .applications-explanation { + margin-bottom: 15px; +} + +.application-img { + margin: auto; + max-width: 150px; + text-align: center; + + .entypo-browser { + font-size: 137px; + height: 160px; + margin-top: -45px; } } diff --git a/app/assets/stylesheets/openid_connect_error_page.scss b/app/assets/stylesheets/openid_connect_error_page.scss new file mode 100644 index 000000000..15e659fc3 --- /dev/null +++ b/app/assets/stylesheets/openid_connect_error_page.scss @@ -0,0 +1,7 @@ +.api-error { + background-color: $light-grey; + box-shadow: $card-shadow; + margin-top: 20px; + + h4 { text-align: center; } +} diff --git a/app/assets/stylesheets/user_applications.scss b/app/assets/stylesheets/user_applications.scss new file mode 100644 index 000000000..87a0005ce --- /dev/null +++ b/app/assets/stylesheets/user_applications.scss @@ -0,0 +1,35 @@ +.application-img { + float: left; + margin: 9px 0; + max-height: 60px; + text-align: center; + width: 60px; + + [class^="entypo-"] { + font-size: 60px; + height: 60px; + margin: 0; + padding: 0; + width: 100%; + + &::before { + position: relative; + top: -15px; + } + } +} + +.application-authorizations { + display: inline-block; + float: right; + padding: 0 0 15px 15px; + width: calc(100% - 60px); +} + +.application-tos-policy > b { + &:first-child { margin-right: 5px; } + &:nth-child(2) { margin-left: 5px; } +} + +.user-consent { margin-top: 20px; } +.approval-button { display: inline; } diff --git a/app/controllers/api/openid_connect/authorizations_controller.rb b/app/controllers/api/openid_connect/authorizations_controller.rb new file mode 100644 index 000000000..36417e20b --- /dev/null +++ b/app/controllers/api/openid_connect/authorizations_controller.rb @@ -0,0 +1,254 @@ +module Api + module OpenidConnect + class AuthorizationsController < ApplicationController + rescue_from Rack::OAuth2::Server::Authorize::BadRequest do |e| + logger.info e.backtrace[0, 10].join("\n") + error, _description = e.message.split(" :: ") + handle_params_error(error, "The request was malformed: please double check the client id and redirect uri.") + end + + rescue_from OpenSSL::SSL::SSLError do |e| + logger.info e.backtrace[0, 10].join("\n") + handle_params_error("bad_request", e.message) + end + + rescue_from JSON::JWS::VerificationFailed do |e| + logger.info e.backtrace[0, 10].join("\n") + handle_params_error("bad_request", e.message) + end + + before_action :auth_user_unless_prompt_none! + + def new + auth = Api::OpenidConnect::Authorization.find_by_client_id_and_user(params[:client_id], current_user) + reset_auth(auth) + if logged_in_before?(params[:max_age]) + reauthenticate(params) + elsif params[:prompt] + prompt = params[:prompt].split(" ") + handle_prompt(prompt, auth) + else + handle_authorization_form(auth) + end + end + + def create + restore_request_parameters + process_authorization_consent(params[:approve]) + end + + def destroy + authorization = Api::OpenidConnect::Authorization.find_by(id: params[:id]) + if authorization + authorization.destroy + else + flash[:error] = I18n.t("api.openid_connect.authorizations.destroy.fail", id: params[:id]) + end + redirect_to api_openid_connect_user_applications_url + end + + private + + def reset_auth(auth) + return unless auth + auth.o_auth_access_tokens.destroy_all + auth.id_tokens.destroy_all + auth.code_used = false + auth.save + end + + def handle_prompt(prompt, auth) + if prompt.include? "select_account" + handle_params_error("account_selection_required", + "There is no support for choosing among multiple accounts") + elsif prompt.include? "consent" + request_authorization_consent_form + else + handle_authorization_form(auth) + end + end + + def handle_authorization_form(auth) + if auth + process_authorization_consent("true") + else + request_authorization_consent_form + end + end + + def request_authorization_consent_form + add_claims_to_scopes + endpoint = Api::OpenidConnect::AuthorizationPoint::EndpointStartPoint.new(current_user) + handle_start_point_response(endpoint) + end + + def add_claims_to_scopes + return unless params[:claims] + claims_json = JSON.parse(params[:claims]) + return unless claims_json + claims_array = claims_json["userinfo"].try(:keys) + return unless claims_array + req = build_rack_request + claims = claims_array.unshift(req[:scope]).join(" ") + req.update_param("scope", claims) + end + + def logged_in_before?(seconds) + if seconds.nil? + false + else + (Time.now - current_user.current_sign_in_at) > seconds.to_i + end + end + + def handle_start_point_response(endpoint) + _status, header, response = endpoint.call(request.env) + if response.redirect? + redirect_to header["Location"] + else + save_params_and_render_consent_form(endpoint) + end + end + + def save_params_and_render_consent_form(endpoint) + @o_auth_application = endpoint.o_auth_application + @response_type = endpoint.response_type + @redirect_uri = endpoint.redirect_uri + @scopes = endpoint.scopes + save_request_parameters + @app = UserApplicationPresenter.new @o_auth_application, @scopes + render :new + end + + def save_request_parameters + session[:client_id] = @o_auth_application.client_id + session[:response_type] = @response_type + session[:redirect_uri] = @redirect_uri + session[:scopes] = scopes_as_space_seperated_values + session[:nonce] = params[:nonce] + end + + def scopes_as_space_seperated_values + @scopes.join(" ") + end + + def process_authorization_consent(approved_string) + endpoint = Api::OpenidConnect::AuthorizationPoint::EndpointConfirmationPoint.new( + current_user, to_boolean(approved_string)) + handle_confirmation_endpoint_response(endpoint) + end + + def handle_confirmation_endpoint_response(endpoint) + _status, header, _response = endpoint.call(request.env) + delete_authorization_session_variables + redirect_to header["Location"] + end + + def delete_authorization_session_variables + session.delete(:client_id) + session.delete(:response_type) + session.delete(:redirect_uri) + session.delete(:scopes) + session.delete(:nonce) + end + + def to_boolean(str) + str.downcase == "true" + end + + def restore_request_parameters + req = build_rack_request + req.update_param("client_id", session[:client_id]) + req.update_param("redirect_uri", session[:redirect_uri]) + req.update_param("response_type", response_type_as_space_seperated_values) + req.update_param("scope", session[:scopes]) + req.update_param("nonce", session[:nonce]) + end + + def build_rack_request + Rack::Request.new(request.env) + end + + def response_type_as_space_seperated_values + [*session[:response_type]].join(" ") + end + + def handle_params_error(error, error_description) + if params[:client_id] && params[:redirect_uri] + handle_params_error_when_client_id_and_redirect_uri_exists(error, error_description) + else + render_error I18n.t("api.openid_connect.error_page.could_not_authorize"), error_description + end + end + + def handle_params_error_when_client_id_and_redirect_uri_exists(error, error_description) + app = Api::OpenidConnect::OAuthApplication.find_by(client_id: params[:client_id]) + if app && app.redirect_uris.include?(params[:redirect_uri]) + redirect_prompt_error_display(error, error_description) + else + render_error I18n.t("api.openid_connect.error_page.could_not_authorize"), + "Invalid client id or redirect uri" + end + end + + def redirect_prompt_error_display(error, error_description) + redirect_params_hash = {error: error, error_description: error_description, state: params[:state]} + redirect_fragment = redirect_params_hash.compact.map {|key, value| key.to_s + "=" + value }.join("&") + redirect_to params[:redirect_uri] + "?" + redirect_fragment + end + + def auth_user_unless_prompt_none! + prompt = params[:prompt] + if prompt && prompt.include?("none") + handle_prompt_none + elsif prompt && prompt.include?("login") + new_params = params.merge!(prompt: prompt.remove("login")) + reauthenticate(new_params) + else + authenticate_user! + end + end + + def handle_prompt_none + if params[:prompt] == "none" + if user_signed_in? + handle_prompt_with_signed_in_user + else + handle_params_error("login_required", "User must already be logged in when `prompt` is `none`") + end + else + handle_params_error("invalid_request", "The 'none' value cannot be used with any other prompt value") + end + end + + def handle_prompt_with_signed_in_user + client_id = params[:client_id] + if client_id + auth = Api::OpenidConnect::Authorization.find_by_client_id_and_user(client_id, current_user) + if auth + process_authorization_consent("true") + else + handle_params_error("interaction_required", "User must already be authorized when `prompt` is `none`") + end + else + handle_params_error("bad_request", "Client ID is missing from request") + end + end + + def reauthenticate(params) + sign_out current_user + redirect_to new_api_openid_connect_authorization_path(params) + end + + def render_error(error_description, detailed_error=nil) + @error_description = error_description + @detailed_error = detailed_error + if request.format == :mobile + render "api/openid_connect/error/error.mobile", layout: "application.mobile" + else + render "api/openid_connect/error/error", layout: "with_header_with_footer" + end + end + end + end +end diff --git a/app/controllers/api/openid_connect/clients_controller.rb b/app/controllers/api/openid_connect/clients_controller.rb new file mode 100644 index 000000000..0a6f7ba94 --- /dev/null +++ b/app/controllers/api/openid_connect/clients_controller.rb @@ -0,0 +1,52 @@ +module Api + module OpenidConnect + class ClientsController < ApplicationController + rescue_from OpenIDConnect::HttpError do |e| + http_error_page_as_json(e) + end + + rescue_from OpenIDConnect::ValidationFailed, + ActiveRecord::RecordInvalid, Api::OpenidConnect::Error::InvalidSectorIdentifierUri do |e| + validation_fail_as_json(e) + end + + rescue_from Api::OpenidConnect::Error::InvalidRedirectUri do |e| + validation_fail_redirect_uri(e) + end + + rescue_from OpenSSL::SSL::SSLError do |e| + validation_fail_as_json(e) + end + + # Inspired by https://github.com/nov/openid_connect_sample/blob/master/app/controllers/connect/clients_controller.rb#L24 + def create + registrar = OpenIDConnect::Client::Registrar.new(request.url, params) + client = Api::OpenidConnect::OAuthApplication.register! registrar + render json: client.as_json(root: false) + end + + def find + client = Api::OpenidConnect::OAuthApplication.find_by(client_name: params[:client_name]) + if client + render json: {client_id: client.client_id} + else + render json: {error: "Client with name #{params[:client_name]} does not exist"} + end + end + + private + + def http_error_page_as_json(e) + render json: {error: :invalid_request, error_description: e.message}, status: 400 + end + + def validation_fail_as_json(e) + render json: {error: :invalid_client_metadata, error_description: e.message}, status: 400 + end + + def validation_fail_redirect_uri(e) + render json: {error: :invalid_redirect_uri, error_description: e.message}, status: 400 + end + end + end +end diff --git a/app/controllers/api/openid_connect/discovery_controller.rb b/app/controllers/api/openid_connect/discovery_controller.rb new file mode 100644 index 000000000..19c9001b4 --- /dev/null +++ b/app/controllers/api/openid_connect/discovery_controller.rb @@ -0,0 +1,61 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# See https://github.com/nov/openid_connect_sample/blob/master/app/controllers/discovery_controller.rb + +module Api + module OpenidConnect + class DiscoveryController < ApplicationController + def webfinger + jrd = { + links: [{ + rel: OpenIDConnect::Discovery::Provider::Issuer::REL_VALUE, + href: root_url + }] + } + jrd[:subject] = params[:resource] if params[:resource].present? + render json: jrd, content_type: "application/jrd+json" + end + + def configuration + render json: OpenIDConnect::Discovery::Provider::Config::Response.new( + issuer: root_url, + registration_endpoint: api_openid_connect_clients_url, + authorization_endpoint: new_api_openid_connect_authorization_url, + token_endpoint: api_openid_connect_access_tokens_url, + userinfo_endpoint: api_openid_connect_user_info_url, + jwks_uri: api_openid_connect_url, + scopes_supported: Api::OpenidConnect::Authorization::SCOPES, + response_types_supported: Api::OpenidConnect::OAuthApplication.available_response_types, + request_object_signing_alg_values_supported: %i(none), + request_parameter_supported: true, + request_uri_parameter_supported: true, + subject_types_supported: %w(public pairwise), + id_token_signing_alg_values_supported: %i(RS256), + token_endpoint_auth_methods_supported: %w(client_secret_basic client_secret_post private_key_jwt), + claims_parameter_supported: true, + claims_supported: %w(sub name nickname profile picture), + userinfo_signing_alg_values_supported: %w(none) + ) + end + end + end +end diff --git a/app/controllers/api/openid_connect/id_tokens_controller.rb b/app/controllers/api/openid_connect/id_tokens_controller.rb new file mode 100644 index 000000000..26eb17bde --- /dev/null +++ b/app/controllers/api/openid_connect/id_tokens_controller.rb @@ -0,0 +1,36 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +module Api + module OpenidConnect + class IdTokensController < ApplicationController + def jwks + render json: JSON::JWK::Set.new(build_jwk).as_json + end + + private + + def build_jwk + JSON::JWK.new(Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY, use: :sig, kid: :default) + end + end + end +end diff --git a/app/controllers/api/openid_connect/token_endpoint_controller.rb b/app/controllers/api/openid_connect/token_endpoint_controller.rb new file mode 100644 index 000000000..36b0ed31c --- /dev/null +++ b/app/controllers/api/openid_connect/token_endpoint_controller.rb @@ -0,0 +1,60 @@ +module Api + module OpenidConnect + class TokenEndpointController < ApplicationController + def create + req = Rack::Request.new(request.env) + if req["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + handle_jwt_bearer(req) + end + self.status, response.headers, self.response_body = Api::OpenidConnect::TokenEndpoint.new.call(request.env) + nil + end + + private + + def handle_jwt_bearer(req) + jwt_string = req["client_assertion"] + jwt = JSON::JWT.decode jwt_string, :skip_verification + o_auth_app = Api::OpenidConnect::OAuthApplication.find_by(client_id: jwt["iss"]) + raise Rack::OAuth2::Server::Authorize::BadRequest(:invalid_request) unless o_auth_app + public_key = fetch_public_key(o_auth_app, jwt) + JSON::JWT.decode(jwt_string, JSON::JWK.new(public_key).to_key) + req.update_param("client_id", o_auth_app.client_id) + req.update_param("client_secret", o_auth_app.client_secret) + end + + def fetch_public_key(o_auth_app, jwt) + public_key = fetch_public_key_from_json(o_auth_app.jwks, jwt) + if public_key.empty? && o_auth_app.jwks_uri + response = Faraday.get(o_auth_app.jwks_uri) + public_key = fetch_public_key_from_json(response.body, jwt) + end + raise Rack::OAuth2::Server::Authorize::BadRequest(:unauthorized_client) if public_key.empty? + public_key + end + + def fetch_public_key_from_json(string, jwt) + json = JSON.parse(string) + keys = json["keys"] + public_key = get_key_from_kid(keys, jwt.header["kid"]) + public_key + end + + def get_key_from_kid(keys, kid) + keys.each do |key| + return key if key.has_value?(kid) + end + end + + rescue_from Rack::OAuth2::Server::Authorize::BadRequest, + JSON::JWT::InvalidFormat, JSON::JWK::UnknownAlgorithm do |e| + logger.info e.backtrace[0, 10].join("\n") + render json: {error: :invalid_request, error_description: e.message, status: 400} + end + rescue_from JSON::JWT::VerificationFailed do |e| + logger.info e.backtrace[0, 10].join("\n") + render json: {error: :invalid_grant, error_description: e.message, status: 400} + end + end + end +end diff --git a/app/controllers/api/openid_connect/user_applications_controller.rb b/app/controllers/api/openid_connect/user_applications_controller.rb new file mode 100644 index 000000000..f25603a52 --- /dev/null +++ b/app/controllers/api/openid_connect/user_applications_controller.rb @@ -0,0 +1,11 @@ +module Api + module OpenidConnect + class UserApplicationsController < ApplicationController + before_action :authenticate_user! + + def index + @user_apps = UserApplicationsPresenter.new current_user + end + end + end +end diff --git a/app/controllers/api/openid_connect/user_info_controller.rb b/app/controllers/api/openid_connect/user_info_controller.rb new file mode 100644 index 000000000..666c2a48e --- /dev/null +++ b/app/controllers/api/openid_connect/user_info_controller.rb @@ -0,0 +1,26 @@ +module Api + module OpenidConnect + class UserInfoController < ApplicationController + include Api::OpenidConnect::ProtectedResourceEndpoint + + before_action do + require_access_token %w(openid) + end + + def show + serializer = UserInfoSerializer.new(current_user) + auth = current_token.authorization + serializer.serialization_options = {authorization: auth} + attributes_without_essential = + serializer.attributes.with_indifferent_access.select {|scope| auth.scopes.include? scope } + attributes = attributes_without_essential.merge( + sub: serializer.sub) + render json: attributes.to_json + end + + def current_user + current_token ? current_token.authorization.user : nil + end + end + end +end diff --git a/app/controllers/api/v0/base_controller.rb b/app/controllers/api/v0/base_controller.rb new file mode 100644 index 000000000..39d331215 --- /dev/null +++ b/app/controllers/api/v0/base_controller.rb @@ -0,0 +1,13 @@ +module Api + module V0 + class BaseController < ApplicationController + include Api::OpenidConnect::ProtectedResourceEndpoint + + protected + + def current_user + current_token ? current_token.authorization.user : nil + end + end + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f929728ae..7bce10206 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,7 +3,7 @@ # the COPYRIGHT file. class UsersController < ApplicationController - before_action :authenticate_user!, :except => [:new, :create, :public, :user_photo] + before_action :authenticate_user!, except: %i(new create public user_photo) respond_to :html def edit diff --git a/app/helpers/user_applications_helper.rb b/app/helpers/user_applications_helper.rb new file mode 100644 index 000000000..190c43580 --- /dev/null +++ b/app/helpers/user_applications_helper.rb @@ -0,0 +1,9 @@ +module UserApplicationsHelper + def user_application_name(app) + if app.name? + "#{html_escape app.name} (#{link_to(app.url, app.url)})" + else + link_to(app.url, app.url) + end + end +end diff --git a/app/models/api/openid_connect/authorization.rb b/app/models/api/openid_connect/authorization.rb new file mode 100644 index 000000000..f0ecef411 --- /dev/null +++ b/app/models/api/openid_connect/authorization.rb @@ -0,0 +1,80 @@ +# Inspired by https://github.com/nov/openid_connect_sample/blob/master/app/models/authorization.rb + +module Api + module OpenidConnect + class Authorization < ActiveRecord::Base + belongs_to :user + belongs_to :o_auth_application + + validates :user, presence: true, uniqueness: {scope: :o_auth_application} + validates :o_auth_application, presence: true + validate :validate_scope_names + serialize :scopes, JSON + + has_many :o_auth_access_tokens, dependent: :destroy + has_many :id_tokens, dependent: :destroy + + before_validation :setup, on: :create + + scope :with_redirect_uri, ->(given_uri) { where redirect_uri: given_uri } + + SCOPES = %w(openid sub aud name nickname profile picture read write) + + def setup + self.refresh_token = SecureRandom.hex(32) + end + + def validate_scope_names + return unless scopes + scopes.each do |scope| + errors.add(:scope, "is not a valid scope name") unless SCOPES.include? scope + end + end + + # Inspired by https://github.com/nov/openid_connect_sample/blob/master/app/models/access_token.rb#L26 + def accessible?(required_scopes=nil) + Array(required_scopes).all? { |required_scope| + scopes.include? required_scope + } + end + + def create_code + SecureRandom.hex(32).tap do |code| + update!(code: code) + update!(code_used: false) + end + end + + def create_access_token + o_auth_access_tokens.create!.bearer_token + end + + def create_id_token + id_tokens.create!(nonce: nonce) + end + + def self.find_by_client_id_and_user(client_id, user) + app = Api::OpenidConnect::OAuthApplication.where(client_id: client_id) + find_by(o_auth_application: app, user: user) + end + + def self.find_by_refresh_token(client_id, refresh_token) + app = Api::OpenidConnect::OAuthApplication.where(client_id: client_id) + find_by(o_auth_application: app, refresh_token: refresh_token) + end + + def self.use_code(code) + return unless code + auth = find_by(code: code) + return unless auth + if auth.code_used + auth.destroy + nil + else + auth.update!(code_used: true) + auth + end + end + end + end +end diff --git a/app/models/api/openid_connect/id_token.rb b/app/models/api/openid_connect/id_token.rb new file mode 100644 index 000000000..7fdcd7af0 --- /dev/null +++ b/app/models/api/openid_connect/id_token.rb @@ -0,0 +1,71 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# See https://github.com/nov/openid_connect_sample/blob/master/app/models/id_token.rb + +require "uri" + +module Api + module OpenidConnect + class IdToken < ActiveRecord::Base + belongs_to :authorization + + before_validation :setup, on: :create + + default_scope { where("expires_at >= ?", Time.zone.now.utc) } + + def setup + self.expires_at = 30.minutes.from_now + end + + def to_jwt(options={}) + to_response_object(options).to_jwt(OpenidConnect::IdTokenConfig::PRIVATE_KEY) do |jwt| + jwt.kid = :default + end + end + + def to_response_object(options={}) + OpenIDConnect::ResponseObject::IdToken.new(claims).tap do |id_token| + id_token.code = options[:code] if options[:code] + id_token.access_token = options[:access_token] if options[:access_token] + end + end + + def claims + sub = build_sub + @claims ||= { + iss: AppConfig.environment.url, + sub: sub, + aud: authorization.o_auth_application.client_id, + exp: expires_at.to_i, + iat: created_at.to_i, + auth_time: authorization.user.current_sign_in_at.to_i, + nonce: nonce, + acr: 0 + } + end + + def build_sub + Api::OpenidConnect::SubjectIdentifierCreator.create(authorization) + end + end + end +end diff --git a/app/models/api/openid_connect/o_auth_access_token.rb b/app/models/api/openid_connect/o_auth_access_token.rb new file mode 100644 index 000000000..053bc86df --- /dev/null +++ b/app/models/api/openid_connect/o_auth_access_token.rb @@ -0,0 +1,49 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# See https://github.com/nov/openid_connect_sample/blob/master/app/models/access_token.rb + +module Api + module OpenidConnect + class OAuthAccessToken < ActiveRecord::Base + belongs_to :authorization + + before_validation :setup, on: :create + + validates :token, presence: true, uniqueness: true + validates :authorization, presence: true + + scope :valid, ->(time) { where("expires_at >= ?", time) } + + def setup + self.token = SecureRandom.hex(32) + self.expires_at = 24.hours.from_now + end + + def bearer_token + @bearer_token ||= Rack::OAuth2::AccessToken::Bearer.new( + access_token: token, + expires_in: (expires_at - Time.zone.now.utc).to_i + ) + end + end + end +end diff --git a/app/models/api/openid_connect/o_auth_application.rb b/app/models/api/openid_connect/o_auth_application.rb new file mode 100644 index 000000000..ccceadfea --- /dev/null +++ b/app/models/api/openid_connect/o_auth_application.rb @@ -0,0 +1,119 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# See https://github.com/nov/openid_connect_sample/blob/master/app/models/client.rb + +require "digest" + +module Api + module OpenidConnect + class OAuthApplication < ActiveRecord::Base + has_many :authorizations, dependent: :destroy + has_many :user, through: :authorizations + + validates :client_id, presence: true, uniqueness: true + validates :client_secret, presence: true + validates :client_name, uniqueness: {scope: :redirect_uris} + + %i(redirect_uris response_types grant_types contacts jwks).each do |serializable| + serialize serializable, JSON + end + + before_validation :setup, on: :create + before_validation do + redirect_uris.sort! + end + + def setup + self.client_id = SecureRandom.hex(16) + self.client_secret = SecureRandom.hex(32) + end + + def image_uri + logo_uri ? Diaspora::Camo.image_url(logo_uri) : nil + end + + class << self + def available_response_types + ["id_token", "id_token token", "code"] + end + + def register!(registrar) + registrar.validate! + build_client_application(registrar) + end + + private + + def build_client_application(registrar) + attributes = registrar_attributes(registrar) + check_sector_identifier_uri(attributes) + check_redirect_uris(attributes) + create! attributes + end + + def check_sector_identifier_uri(attributes) + sector_identifier_uri = attributes[:sector_identifier_uri] + return unless sector_identifier_uri + response = Faraday.get(sector_identifier_uri) + sector_identifier_uri_json = JSON.parse(response.body) + redirect_uris = attributes[:redirect_uris] + sector_identifier_uri_includes_redirect_uris = (redirect_uris - sector_identifier_uri_json).empty? + return if sector_identifier_uri_includes_redirect_uris + raise Api::OpenidConnect::Error::InvalidSectorIdentifierUri.new + end + + def check_redirect_uris(attributes) + redirect_uris = attributes[:redirect_uris] + uri_array = redirect_uris.map {|uri| URI(uri) } + any_uri_contains_fragment = uri_array.any? {|uri| !uri.fragment.nil? } + return unless any_uri_contains_fragment + raise Api::OpenidConnect::Error::InvalidRedirectUri.new + end + + def supported_metadata + %i(client_name response_types grant_types application_type + contacts logo_uri client_uri policy_uri tos_uri redirect_uris + sector_identifier_uri subject_type token_endpoint_auth_method jwks jwks_uri) + end + + def registrar_attributes(registrar) + supported_metadata.each_with_object({}) do |key, attr| + value = registrar.public_send(key) + next unless value + case key + when :subject_type + attr[:ppid] = (value == "pairwise") + when :jwks_uri + response = Faraday.get(value) + attr[:jwks] = response.body + attr[:jwks_uri] = value + when :jwks + attr[:jwks] = value.to_json + else + attr[key] = value + end + end + end + end + end + end +end diff --git a/app/models/api/openid_connect/pairwise_pseudonymous_identifier.rb b/app/models/api/openid_connect/pairwise_pseudonymous_identifier.rb new file mode 100644 index 000000000..7aeccc9fa --- /dev/null +++ b/app/models/api/openid_connect/pairwise_pseudonymous_identifier.rb @@ -0,0 +1,45 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# See https://github.com/nov/openid_connect_sample/blob/master/app/models/pairwise_pseudonymous_identifier.rb + +module Api + module OpenidConnect + class PairwisePseudonymousIdentifier < ActiveRecord::Base + self.table_name = "ppid" + + belongs_to :o_auth_application + belongs_to :user + + validates :user, presence: true + validates :identifier, presence: true, uniqueness: {scope: :user} + validates :guid, presence: true, uniqueness: true + + before_validation :setup, on: :create + + private + + def setup + self.guid = SecureRandom.hex(16) + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index cc0dd2331..ec009aa47 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,6 +76,10 @@ class User < ActiveRecord::Base has_many :reports + has_many :pairwise_pseudonymous_identifiers, class_name: "Api::OpenidConnect::PairwisePseudonymousIdentifier" + has_many :authorizations, class_name: "Api::OpenidConnect::Authorization" + has_many :o_auth_applications, through: :authorizations, class_name: "Api::OpenidConnect::OAuthApplication" + before_save :guard_unconfirmed_email, :save_person! @@ -601,12 +605,10 @@ class User < ActiveRecord::Base private def clearable_fields - self.attributes.keys - ["id", "username", "encrypted_password", - "created_at", "updated_at", "locked_at", - "serialized_private_key", "getting_started", - "disable_mail", "show_community_spotlight_in_stream", - "strip_exif", "email", "remove_after", - "export", "exporting", "exported_at", - "exported_photos_file", "exporting_photos", "exported_photos_at"] + attributes.keys - %w(id username encrypted_password created_at updated_at locked_at + serialized_private_key getting_started + disable_mail show_community_spotlight_in_stream + strip_exif email remove_after export exporting exported_at + exported_photos_file exporting_photos exported_photos_at) end end diff --git a/app/presenters/user_application_presenter.rb b/app/presenters/user_application_presenter.rb new file mode 100644 index 000000000..0bc2ea9d4 --- /dev/null +++ b/app/presenters/user_application_presenter.rb @@ -0,0 +1,47 @@ +class UserApplicationPresenter + attr_reader :scopes + + def initialize(application, scopes, authorization_id=nil) + @app = application + @scopes = scopes + @authorization_id = authorization_id + end + + def id + @authorization_id + end + + def name + @app.client_name + end + + def image + @app.image_uri + end + + def terms_of_services + @app.tos_uri + end + + def policy + @app.policy_uri + end + + def name? + @app.client_name.present? + end + + def terms_of_services? + @app.tos_uri.present? + end + + def policy? + @app.policy_uri.present? + end + + def url + client_redirect = URI(@app.redirect_uris[0]) + client_redirect.path = "/" + client_redirect.to_s + end +end diff --git a/app/presenters/user_applications_presenter.rb b/app/presenters/user_applications_presenter.rb new file mode 100644 index 000000000..f04b97394 --- /dev/null +++ b/app/presenters/user_applications_presenter.rb @@ -0,0 +1,20 @@ +class UserApplicationsPresenter + def initialize(user) + @user = user + end + + def user_applications + @applications ||= @user.o_auth_applications.map do |app| + authorization = Api::OpenidConnect::Authorization.find_by_client_id_and_user(app.client_id, @user) + UserApplicationPresenter.new app, authorization.scopes, authorization.id + end + end + + def applications_count + user_applications.size + end + + def applications? + applications_count > 0 + end +end diff --git a/app/serializers/user_info_serializer.rb b/app/serializers/user_info_serializer.rb new file mode 100644 index 000000000..4ef29f3b7 --- /dev/null +++ b/app/serializers/user_info_serializer.rb @@ -0,0 +1,24 @@ +class UserInfoSerializer < ActiveModel::Serializer + attributes :sub, :name, :nickname, :profile, :picture + + def sub + auth = serialization_options[:authorization] + Api::OpenidConnect::SubjectIdentifierCreator.create(auth) + end + + def name + (object.first_name || "") + (object.last_name || "") + end + + def nickname + object.name + end + + def profile + File.join(AppConfig.environment.url, "people", object.guid).to_s + end + + def picture + File.join(AppConfig.environment.url, object.image_url).to_s + end +end diff --git a/app/views/api/openid_connect/authorizations/_grants_list.haml b/app/views/api/openid_connect/authorizations/_grants_list.haml new file mode 100644 index 000000000..63d59211f --- /dev/null +++ b/app/views/api/openid_connect/authorizations/_grants_list.haml @@ -0,0 +1,31 @@ +.application-img + - if app.image + = image_tag app.image, class: "img-responsive", id: "js-app-logo" + - else + %i.entypo-browser +.application-authorizations + - if app.scopes.count > 0 + %h4 + = t("api.openid_connect.authorizations.new.access", name: user_application_name(app)).html_safe + %ul + - app.scopes.each do |scope| + %li + %strong= t("api.openid_connect.scopes.#{scope}.name") + %p= t("api.openid_connect.scopes.#{scope}.description") + - else + .well + = t("api.openid_connect.authorizations.new.no_requirement", name: user_application_name(app)).html_safe + + .small-horizontal-spacer + .application-tos-policy + - if app.terms_of_services? + %strong= link_to t("api.openid_connect.user_applications.tos"), app.terms_of_services + + - if app.policy? && app.terms_of_services? + | + + - if app.policy? + %strong= link_to t("api.openid_connect.user_applications.policy"), app.policy + + - if app.policy? || app.terms_of_services? + .small-horizontal-spacer diff --git a/app/views/api/openid_connect/authorizations/new.html.haml b/app/views/api/openid_connect/authorizations/new.html.haml new file mode 100644 index 000000000..e7bc33028 --- /dev/null +++ b/app/views/api/openid_connect/authorizations/new.html.haml @@ -0,0 +1,13 @@ +.user-consent.col-md-10.col-md-offset-1 + %ul.list-group + %li.list-group-item.authorized-application.clearfix + = render "grants_list", app: @app + + .clearfix.pull-right + = form_tag api_openid_connect_authorizations_path, class: "approval-button" do + = submit_tag t(".deny"), class: "btn btn-danger" + = hidden_field_tag :approve, false + + = form_tag api_openid_connect_authorizations_path, class: "approval-button"do + = submit_tag t(".approve"), class: "btn btn-primary" + = hidden_field_tag :approve, true diff --git a/app/views/api/openid_connect/error/_error.html.haml b/app/views/api/openid_connect/error/_error.html.haml new file mode 100644 index 000000000..de8d782b0 --- /dev/null +++ b/app/views/api/openid_connect/error/_error.html.haml @@ -0,0 +1,11 @@ +.container-fluid + .row + .api-error.col-sm-6.col-sm-offset-3 + %h4 + %b= t("api.openid_connect.error_page.title") + %div{id: "openid_connect_error_description"} + %p= @error_description + - unless @detailed_error.nil? + %p= t("api.openid_connect.error_page.contact_developer") + %pre= @detailed_error + diff --git a/app/views/api/openid_connect/error/error.html.haml b/app/views/api/openid_connect/error/error.html.haml new file mode 100644 index 000000000..a9b15dbd0 --- /dev/null +++ b/app/views/api/openid_connect/error/error.html.haml @@ -0,0 +1 @@ += render partial: "api/openid_connect/error/error" diff --git a/app/views/api/openid_connect/error/error.mobile.haml b/app/views/api/openid_connect/error/error.mobile.haml new file mode 100644 index 000000000..dcfd1f5f8 --- /dev/null +++ b/app/views/api/openid_connect/error/error.mobile.haml @@ -0,0 +1,8 @@ +.landing + %h1.session + = pod_name + += render partial: "api/openid_connect/error/error" + +%footer + = link_to t("layouts.application.toggle"), toggle_mobile_path diff --git a/app/views/api/openid_connect/user_applications/_add_remove_applications.haml b/app/views/api/openid_connect/user_applications/_add_remove_applications.haml new file mode 100644 index 000000000..ff467c3f2 --- /dev/null +++ b/app/views/api/openid_connect/user_applications/_add_remove_applications.haml @@ -0,0 +1,14 @@ +- if @user_apps.applications? + %ul.list-group + - @user_apps.user_applications.each do |app| + %li.list-group-item.authorized-application + = render "grants_list", app: app + = form_for "application", url: "#{api_openid_connect_authorization_path(app.id)}", + html: {method: :delete, class: "form-horizontal"} do |f| + .clearfix= f.submit t("api.openid_connect.user_applications.revoke_autorization"), + class: "btn btn-danger pull-right app-revoke" + +- else + .well + %h4 + = t("api.openid_connect.user_applications.no_applications") diff --git a/app/views/api/openid_connect/user_applications/_grants_list.haml b/app/views/api/openid_connect/user_applications/_grants_list.haml new file mode 100644 index 000000000..210a8a0fe --- /dev/null +++ b/app/views/api/openid_connect/user_applications/_grants_list.haml @@ -0,0 +1,30 @@ +.application-img + - if app.image + = image_tag app.image, class: "img-responsive", id: "js-app-logo" + - else + %i.entypo-browser +.application-authorizations + - if app.scopes.count > 0 + %h4= t("api.openid_connect.user_applications.index.access", name: user_application_name(app)).html_safe + %ul + - app.scopes.each do |scope| + %li + %b= t("api.openid_connect.scopes.#{scope}.name") + %p= t("api.openid_connect.scopes.#{scope}.description") + - else + .well + = t("api.openid_connect.user_applications.index.no_requirement", name: user_application_name(app)).html_safe + + .small-horizontal-spacer + .application-tos-policy + - if app.terms_of_services? + %b= link_to t("api.openid_connect.user_applications.tos"), app.terms_of_services + + - if app.policy? && app.terms_of_services? + | + + - if app.policy? + %b= link_to t("api.openid_connect.user_applications.policy"), app.policy + + - if app.policy? || app.terms_of_services? + .small-horizontal-spacer diff --git a/app/views/api/openid_connect/user_applications/index.html.haml b/app/views/api/openid_connect/user_applications/index.html.haml new file mode 100644 index 000000000..ea2241b74 --- /dev/null +++ b/app/views/api/openid_connect/user_applications/index.html.haml @@ -0,0 +1,13 @@ +- content_for :page_title do + = t(".edit_applications") + +.container-fluid.applications-page + .row + .col-lg-10.col-lg-offset-1 + = render "shared/settings_nav" + .row + .col-lg-8.col-lg-offset-2 + %h3= t(".title") + .row + .col-md-12 + = render "add_remove_applications" diff --git a/app/views/api/openid_connect/user_applications/index.mobile.haml b/app/views/api/openid_connect/user_applications/index.mobile.haml new file mode 100644 index 000000000..9e75a012d --- /dev/null +++ b/app/views/api/openid_connect/user_applications/index.mobile.haml @@ -0,0 +1,9 @@ +.container-fluid.settings_container.applications-page + .row + .col-lg-10.col-lg-offset-1 + - content_for :page_title do + = t(".edit_applications") + = render "shared/settings_nav" + .row + .col-md-12 + = render "add_remove_applications" diff --git a/app/views/shared/_settings_nav.haml b/app/views/shared/_settings_nav.haml index edaa2fcf5..ceba4baa0 100644 --- a/app/views/shared/_settings_nav.haml +++ b/app/views/shared/_settings_nav.haml @@ -6,3 +6,5 @@ %li{class: current_page?(edit_user_path) && "active"}= link_to t("account"), edit_user_path %li{class: current_page?(privacy_settings_path) && "active"}= link_to t("privacy"), privacy_settings_path %li{class: current_page?(services_path) && "active"}= link_to t("_services"), services_path + %li{class: current_page?(api_openid_connect_user_applications_path) && "active"} + = link_to t("_applications"), api_openid_connect_user_applications_path diff --git a/app/views/shared/_settings_nav.mobile.haml b/app/views/shared/_settings_nav.mobile.haml index e286c6b04..aaeb0b319 100644 --- a/app/views/shared/_settings_nav.mobile.haml +++ b/app/views/shared/_settings_nav.mobile.haml @@ -1,8 +1,9 @@ #settings_nav - %h2= t('settings') + %h2= t("settings") %nav %ul - %li= link_to_unless_current t('profile'), edit_profile_path - %li= link_to_unless_current t('account'), edit_user_path - %li= link_to_unless_current t('privacy'), privacy_settings_path - %li= link_to_unless_current t('_services'), services_path + %li= link_to_unless_current t("profile"), edit_profile_path + %li= link_to_unless_current t("account"), edit_user_path + %li= link_to_unless_current t("privacy"), privacy_settings_path + %li= link_to_unless_current t("_services"), services_path + %li= link_to_unless_current t("_applications"), api_openid_connect_user_applications_path diff --git a/config/application.rb b/config/application.rb index 47d39fcad..d623be188 100644 --- a/config/application.rb +++ b/config/application.rb @@ -107,5 +107,10 @@ module Diaspora host: AppConfig.pod_uri.authority } config.action_mailer.asset_host = AppConfig.pod_uri.to_s + + config.middleware.use Rack::OAuth2::Server::Resource::Bearer, "OpenID Connect" do |req| + Api::OpenidConnect::OAuthAccessToken + .valid(Time.zone.now.utc).find_by(token: req.access_token) || req.invalid_token! + end end end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 68e071e7c..a50aead3e 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -1,7 +1,11 @@ Rails.application.config.middleware.insert 0, Rack::Cors do allow do - origins '*' - resource '/.well-known/host-meta' - resource '/webfinger' + origins "*" + resource "/api/openid_connect/user_info", methods: %i(get post) + resource "/api/v0/*", methods: %i(delete get post) + resource "/.well-known/host-meta" + resource "/.well-known/webfinger" + resource "/.well-known/openid-configuration" + resource "/webfinger" end end diff --git a/config/locales/diaspora/en.yml b/config/locales/diaspora/en.yml index c37c4192d..26061892a 100644 --- a/config/locales/diaspora/en.yml +++ b/config/locales/diaspora/en.yml @@ -878,6 +878,48 @@ en: Hoping to see you again, The diaspora* email robot! + api: + openid_connect: + authorizations: + new: + redirection_message: "Are you sure you want to give access to %{redirect_uri}?" + access: "%{name} requires access to:" + no_requirement: "%{name} requires no permissions" + approve: "Approve" + deny: "Deny" + bad_request: "Missing client id or redirect URI" + client_id_not_found: "No client with client_id %{client_id} with redirect URI %{redirect_uri} found" + destroy: + fail: "The attempt to revoke the authorization with ID %{id} failed" + user_applications: + index: + edit_applications: "Applications" + title: "Authorized applications" + access: "%{name} has access to:" + no_requirement: "%{name} requires no permissions" + no_applications: "You have no authorized applications" + revoke_autorization: "Revoke" + tos: "See the application's terms of service" + policy: "See the application's privacy policy" + scopes: + openid: + name: "basic profile" + description: "This allows the application to read your basic profile" + extended: + name: "extended profile" + description: "This allows the application to read your extended profile" + read: + name: "read profile, stream and conversations" + description: "This allows the application to read your stream, your conversations and your complete profile" + write: + name: "send posts, conversations and reactions" + description: "This allows the application to send new posts, write conversations, and send reactions" + error_page: + title: "Oh! Something went wrong :(" + contact_developer: "You should contact the developer of the application and include the following detailed error message:" + login_required: "You must first login before you can authorize this application" + could_not_authorize: "The application could not be authorized" + people: zero: "No people" one: "1 person" diff --git a/config/routes.rb b/config/routes.rb index 4babaf630..218c48911 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -198,15 +198,7 @@ Diaspora::Application.routes.draw do end end - scope 'api/v0', :controller => :apis do - get :me - end - namespace :api do - namespace :v0 do - get "/users/:username" => 'users#show', :as => 'user' - get "/tags/:name" => 'tags#show', :as => 'tag' - end namespace :v1 do resources :tokens, :only => [:create, :destroy] end @@ -240,4 +232,28 @@ Diaspora::Application.routes.draw do # Startpage root :to => 'home#show' + + api_version(module: "Api::V0", path: {value: "api/v0"}, default: true) do + match "user", to: "users#show", via: %i(get post) + end + + namespace :api do + namespace :openid_connect do + resources :clients, only: :create + get "clients/find", to: "clients#find" + + post "access_tokens", to: "token_endpoint#create" + + # Authorization Servers MUST support the use of the HTTP GET and POST methods at the Authorization Endpoint + # See http://openid.net/specs/openid-connect-core-1_0.html#AuthResponseValidation + resources :authorizations, only: %i(new create destroy) + post "authorizations/new", to: "authorizations#new" + get "user_applications", to: "user_applications#index" + get "jwks.json", to: "id_tokens#jwks" + match "user_info", to: "user_info#show", via: %i(get post) + end + end + + get ".well-known/webfinger", to: "api/openid_connect/discovery#webfinger" + get ".well-known/openid-configuration", to: "api/openid_connect/discovery#configuration" end diff --git a/db/migrate/20150613202109_create_o_auth_applications.rb b/db/migrate/20150613202109_create_o_auth_applications.rb new file mode 100644 index 000000000..1170b5c9e --- /dev/null +++ b/db/migrate/20150613202109_create_o_auth_applications.rb @@ -0,0 +1,30 @@ +# Inspired by https://github.com/nov/openid_connect_sample/blob/master/db/migrate/20110829023826_create_clients.rb + +class CreateOAuthApplications < ActiveRecord::Migration + def change + create_table :o_auth_applications do |t| + t.belongs_to :user, index: true + t.string :client_id, index: {unique: true, length: 191} + t.string :client_secret + t.string :client_name + + t.text :redirect_uris + t.string :response_types + t.string :grant_types + t.string :application_type, default: "web" + t.string :contacts + t.string :logo_uri + t.string :client_uri + t.string :policy_uri + t.string :tos_uri + t.string :sector_identifier_uri + t.string :token_endpoint_auth_method + t.text :jwks + t.string :jwks_uri + t.boolean :ppid, default: false + + t.timestamps null: false + end + add_foreign_key :o_auth_applications, :users + end +end diff --git a/db/migrate/20150708153926_create_authorizations.rb b/db/migrate/20150708153926_create_authorizations.rb new file mode 100644 index 000000000..ee88ab017 --- /dev/null +++ b/db/migrate/20150708153926_create_authorizations.rb @@ -0,0 +1,18 @@ +class CreateAuthorizations < ActiveRecord::Migration + def change + create_table :authorizations do |t| + t.belongs_to :user, index: true + t.belongs_to :o_auth_application, index: true + t.string :refresh_token + t.string :code + t.string :redirect_uri + t.string :nonce + t.string :scopes + t.boolean :code_used, default: false + + t.timestamps null: false + end + add_foreign_key :authorizations, :users + add_foreign_key :authorizations, :o_auth_applications + end +end diff --git a/db/migrate/20150708153928_create_o_auth_access_tokens.rb b/db/migrate/20150708153928_create_o_auth_access_tokens.rb new file mode 100644 index 000000000..d833011c5 --- /dev/null +++ b/db/migrate/20150708153928_create_o_auth_access_tokens.rb @@ -0,0 +1,14 @@ +# Inspired by https://github.com/nov/openid_connect_sample/blob/master/db/migrate/20110829023837_create_access_tokens.rb + +class CreateOAuthAccessTokens < ActiveRecord::Migration + def change + create_table :o_auth_access_tokens do |t| + t.belongs_to :authorization, index: true + t.string :token, index: {unique: true, length: 191} + t.datetime :expires_at + + t.timestamps null: false + end + add_foreign_key :o_auth_access_tokens, :authorizations + end +end diff --git a/db/migrate/20150714055110_create_id_tokens.rb b/db/migrate/20150714055110_create_id_tokens.rb new file mode 100644 index 000000000..b1e3abdfa --- /dev/null +++ b/db/migrate/20150714055110_create_id_tokens.rb @@ -0,0 +1,14 @@ +# Inspired by https://github.com/nov/openid_connect_sample/blob/master/db/migrate/20110829024010_create_id_tokens.rb + +class CreateIdTokens < ActiveRecord::Migration + def change + create_table :id_tokens do |t| + t.belongs_to :authorization, index: true + t.datetime :expires_at + t.string :nonce + + t.timestamps null: false + end + add_foreign_key :id_tokens, :authorizations + end +end diff --git a/db/migrate/20150731123113_create_pairwise_pseudonymous_identifiers.rb b/db/migrate/20150731123113_create_pairwise_pseudonymous_identifiers.rb new file mode 100644 index 000000000..0c8690848 --- /dev/null +++ b/db/migrate/20150731123113_create_pairwise_pseudonymous_identifiers.rb @@ -0,0 +1,15 @@ +# Inspired by https://github.com/nov/openid_connect_sample/blob/master/db/migrate/20110829024140_create_pairwise_pseudonymous_identifiers.rb + +class CreatePairwisePseudonymousIdentifiers < ActiveRecord::Migration + def change + create_table :ppid do |t| + t.belongs_to :o_auth_application, index: true + t.belongs_to :user, index: true + + t.string :guid, :string, limit: 32 + t.string :identifier + end + add_foreign_key :ppid, :o_auth_applications + add_foreign_key :ppid, :users + end +end diff --git a/db/schema.rb b/db/schema.rb index 6a526fe46..ef4f3daf3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -55,6 +55,22 @@ ActiveRecord::Schema.define(version: 20151003142048) do add_index "aspects", ["user_id", "contacts_visible"], name: "index_aspects_on_user_id_and_contacts_visible", using: :btree add_index "aspects", ["user_id"], name: "index_aspects_on_user_id", using: :btree + create_table "authorizations", force: :cascade do |t| + t.integer "user_id", limit: 4 + t.integer "o_auth_application_id", limit: 4 + t.string "refresh_token", limit: 255 + t.string "code", limit: 255 + t.string "redirect_uri", limit: 255 + t.string "nonce", limit: 255 + t.string "scopes", limit: 255 + t.boolean "code_used", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "authorizations", ["o_auth_application_id"], name: "index_authorizations_on_o_auth_application_id", using: :btree + add_index "authorizations", ["user_id"], name: "index_authorizations_on_user_id", using: :btree + create_table "blocks", force: :cascade do |t| t.integer "user_id", limit: 4 t.integer "person_id", limit: 4 @@ -137,6 +153,16 @@ ActiveRecord::Schema.define(version: 20151003142048) do add_index "conversations", ["author_id"], name: "conversations_author_id_fk", using: :btree + create_table "id_tokens", force: :cascade do |t| + t.integer "authorization_id", limit: 4 + t.datetime "expires_at" + t.string "nonce", limit: 255 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "id_tokens", ["authorization_id"], name: "index_id_tokens_on_authorization_id", using: :btree + create_table "invitation_codes", force: :cascade do |t| t.string "token", limit: 255 t.integer "user_id", limit: 4 @@ -236,6 +262,43 @@ ActiveRecord::Schema.define(version: 20151003142048) do add_index "notifications", ["target_id"], name: "index_notifications_on_target_id", using: :btree add_index "notifications", ["target_type", "target_id"], name: "index_notifications_on_target_type_and_target_id", length: {"target_type"=>190, "target_id"=>nil}, using: :btree + create_table "o_auth_access_tokens", force: :cascade do |t| + t.integer "authorization_id", limit: 4 + t.string "token", limit: 255 + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "o_auth_access_tokens", ["authorization_id"], name: "index_o_auth_access_tokens_on_authorization_id", using: :btree + add_index "o_auth_access_tokens", ["token"], name: "index_o_auth_access_tokens_on_token", unique: true, length: {"token"=>191}, using: :btree + + create_table "o_auth_applications", force: :cascade do |t| + t.integer "user_id", limit: 4 + t.string "client_id", limit: 255 + t.string "client_secret", limit: 255 + t.string "client_name", limit: 255 + t.text "redirect_uris", limit: 65535 + t.string "response_types", limit: 255 + t.string "grant_types", limit: 255 + t.string "application_type", limit: 255, default: "web" + t.string "contacts", limit: 255 + t.string "logo_uri", limit: 255 + t.string "client_uri", limit: 255 + t.string "policy_uri", limit: 255 + t.string "tos_uri", limit: 255 + t.string "sector_identifier_uri", limit: 255 + t.string "token_endpoint_auth_method", limit: 255 + t.text "jwks", limit: 65535 + t.string "jwks_uri", limit: 255 + t.boolean "ppid", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "o_auth_applications", ["client_id"], name: "index_o_auth_applications_on_client_id", unique: true, length: {"client_id"=>191}, using: :btree + add_index "o_auth_applications", ["user_id"], name: "index_o_auth_applications_on_user_id", using: :btree + create_table "o_embed_caches", force: :cascade do |t| t.string "url", limit: 1024, null: false t.text "data", limit: 65535, null: false @@ -402,6 +465,17 @@ ActiveRecord::Schema.define(version: 20151003142048) do add_index "posts", ["tweet_id"], name: "index_posts_on_tweet_id", length: {"tweet_id"=>191}, using: :btree add_index "posts", ["type", "pending", "id"], name: "index_posts_on_type_and_pending_and_id", using: :btree + create_table "ppid", force: :cascade do |t| + t.integer "o_auth_application_id", limit: 4 + t.integer "user_id", limit: 4 + t.string "guid", limit: 32 + t.string "string", limit: 32 + t.string "identifier", limit: 255 + end + + add_index "ppid", ["o_auth_application_id"], name: "index_ppid_on_o_auth_application_id", using: :btree + add_index "ppid", ["user_id"], name: "index_ppid_on_user_id", using: :btree + create_table "profiles", force: :cascade do |t| t.string "diaspora_handle", limit: 255 t.string "first_name", limit: 127 @@ -419,7 +493,7 @@ ActiveRecord::Schema.define(version: 20151003142048) do t.string "location", limit: 255 t.string "full_name", limit: 70 t.boolean "nsfw", default: false - t.boolean "public_details", default: false + t.boolean "public_details", default: false end add_index "profiles", ["full_name", "searchable"], name: "index_profiles_on_full_name_and_searchable", using: :btree @@ -589,18 +663,25 @@ ActiveRecord::Schema.define(version: 20151003142048) do add_foreign_key "aspect_memberships", "aspects", name: "aspect_memberships_aspect_id_fk", on_delete: :cascade add_foreign_key "aspect_memberships", "contacts", name: "aspect_memberships_contact_id_fk", on_delete: :cascade add_foreign_key "aspect_visibilities", "aspects", name: "aspect_visibilities_aspect_id_fk", on_delete: :cascade + add_foreign_key "authorizations", "o_auth_applications" + add_foreign_key "authorizations", "users" add_foreign_key "comments", "people", column: "author_id", name: "comments_author_id_fk", on_delete: :cascade add_foreign_key "contacts", "people", name: "contacts_person_id_fk", on_delete: :cascade add_foreign_key "conversation_visibilities", "conversations", name: "conversation_visibilities_conversation_id_fk", on_delete: :cascade add_foreign_key "conversation_visibilities", "people", name: "conversation_visibilities_person_id_fk", on_delete: :cascade add_foreign_key "conversations", "people", column: "author_id", name: "conversations_author_id_fk", on_delete: :cascade + add_foreign_key "id_tokens", "authorizations" add_foreign_key "invitations", "users", column: "recipient_id", name: "invitations_recipient_id_fk", on_delete: :cascade add_foreign_key "invitations", "users", column: "sender_id", name: "invitations_sender_id_fk", on_delete: :cascade add_foreign_key "likes", "people", column: "author_id", name: "likes_author_id_fk", on_delete: :cascade add_foreign_key "messages", "conversations", name: "messages_conversation_id_fk", on_delete: :cascade add_foreign_key "messages", "people", column: "author_id", name: "messages_author_id_fk", on_delete: :cascade add_foreign_key "notification_actors", "notifications", name: "notification_actors_notification_id_fk", on_delete: :cascade + add_foreign_key "o_auth_access_tokens", "authorizations" + add_foreign_key "o_auth_applications", "users" add_foreign_key "posts", "people", column: "author_id", name: "posts_author_id_fk", on_delete: :cascade + add_foreign_key "ppid", "o_auth_applications" + add_foreign_key "ppid", "users" add_foreign_key "profiles", "people", name: "profiles_person_id_fk", on_delete: :cascade add_foreign_key "services", "users", name: "services_user_id_fk", on_delete: :cascade add_foreign_key "share_visibilities", "contacts", name: "post_visibilities_contact_id_fk", on_delete: :cascade diff --git a/features/desktop/oidc_auth_code_flow.feature b/features/desktop/oidc_auth_code_flow.feature new file mode 100644 index 000000000..22460b7d1 --- /dev/null +++ b/features/desktop/oidc_auth_code_flow.feature @@ -0,0 +1,26 @@ +@javascript +Feature: Access protected resources using auth code flow + Background: + Given a user with username "kent" + + Scenario: Invalid client id to auth endpoint + When I register a new client + And I send a post request from that client to the code flow authorization endpoint using a invalid client id + And I sign in as "kent@kent.kent" + Then I should see a message containing "Invalid client id or redirect uri" + + Scenario: Application is denied authorization + When I register a new client + And I send a post request from that client to the code flow authorization endpoint + And I sign in as "kent@kent.kent" + And I deny authorization to the client + Then I should not see any tokens in the redirect url + + Scenario: Application is authorized + When I register a new client + And I send a post request from that client to the code flow authorization endpoint + And I sign in as "kent@kent.kent" + And I give my consent and authorize the client + And I parse the auth code and create a request to the token endpoint + And I parse the tokens and use it obtain user info + Then I should receive "kent"'s id, username, and email diff --git a/features/desktop/oidc_implicit_flow.feature b/features/desktop/oidc_implicit_flow.feature new file mode 100644 index 000000000..3e090a303 --- /dev/null +++ b/features/desktop/oidc_implicit_flow.feature @@ -0,0 +1,35 @@ +@javascript +Feature: Access protected resources using implicit flow + Background: + Given a user with username "kent" + + Scenario: Invalid client id to auth endpoint + When I register a new client + And I send a post request from that client to the authorization endpoint using a invalid client id + And I sign in as "kent@kent.kent" + Then I should see a message containing "Invalid client id or redirect uri" + + Scenario: Application is denied authorization + When I register a new client + And I send a post request from that client to the authorization endpoint + And I sign in as "kent@kent.kent" + And I deny authorization to the client + Then I should not see any tokens in the redirect url + + Scenario: Application is authorized + When I register a new client + And I send a post request from that client to the authorization endpoint + And I sign in as "kent@kent.kent" + And I give my consent and authorize the client + And I parse the bearer tokens and use it to access user info + Then I should receive "kent"'s id, username, and email + + Scenario: Application is authorized and uses small value for the max_age parameter + When I register a new client + And I sign in as "kent@kent.kent" + And I have signed in 5 minutes ago + And I send a post request from that client to the authorization endpoint with max age + And I sign in as "kent@kent.kent" + And I give my consent and authorize the client + And I parse the bearer tokens and use it to access user info + Then I should receive "kent"'s id, username, and email diff --git a/features/desktop/user_applications.feature b/features/desktop/user_applications.feature new file mode 100644 index 000000000..b1147ae1a --- /dev/null +++ b/features/desktop/user_applications.feature @@ -0,0 +1,23 @@ +@javascript +Feature: managing authorized applications + Background: + Given following users exist: + | username | email | + | Augier | augier@example.org | + And a client with a provided picture exists for user "augier@example.org" + And a client exists for user "augier@example.org" + + Scenario: displaying authorizations + When I sign in as "augier@example.org" + And I go to the user applications page + Then I should see 2 authorized applications + And I should see 1 authorized applications with no provided image + And I should see 1 authorized applications with an image + + Scenario: revoke an authorization + When I sign in as "augier@example.org" + And I go to the user applications page + And I revoke the first authorization + Then I should see 1 authorized applications + And I revoke the first authorization + Then I should see 0 authorized applications diff --git a/features/mobile/user_applications.feature b/features/mobile/user_applications.feature new file mode 100644 index 000000000..9e7ecbe2f --- /dev/null +++ b/features/mobile/user_applications.feature @@ -0,0 +1,24 @@ +@mobile +@javascript +Feature: managing authorized applications + Background: + Given following users exist: + | username | email | + | Augier | augier@example.org | + And a client with a provided picture exists for user "augier@example.org" + And a client exists for user "augier@example.org" + + Scenario: displaying authorizations + When I sign in as "augier@example.org" + And I go to the user applications page + Then I should see 2 authorized applications + And I should see 1 authorized applications with no provided image + And I should see 1 authorized applications with an image + + Scenario: revoke an authorization + When I sign in as "augier@example.org" + And I go to the user applications page + And I revoke the first authorization + Then I should see 1 authorized applications + And I revoke the first authorization + Then I should see 0 authorized applications diff --git a/features/step_definitions/auth_code_steps.rb b/features/step_definitions/auth_code_steps.rb new file mode 100644 index 000000000..8b19f7f52 --- /dev/null +++ b/features/step_definitions/auth_code_steps.rb @@ -0,0 +1,37 @@ +O_AUTH_QUERY_PARAMS_WITH_CODE = { + redirect_uri: "http://localhost:3000", + response_type: "code", + scope: "openid profile read", + nonce: "hello", + state: "hi" +} + +Given /^I send a post request from that client to the code flow authorization endpoint$/ do + client_json = JSON.parse(last_response.body) + @client_id = client_json["client_id"] + @client_secret = client_json["client_secret"] + params = O_AUTH_QUERY_PARAMS_WITH_CODE.merge(client_id: @client_id) + visit new_api_openid_connect_authorization_path(params) +end + +Given /^I send a post request from that client to the code flow authorization endpoint using a invalid client id/ do + params = O_AUTH_QUERY_PARAMS_WITH_CODE.merge(client_id: "randomid") + visit new_api_openid_connect_authorization_path(params) +end + +When /^I parse the auth code and create a request to the token endpoint$/ do + code = current_url[/(?<=code=)[^&]+/] + post api_openid_connect_access_tokens_path, code: code, + redirect_uri: "http://localhost:3000", grant_type: "authorization_code", + client_id: @client_id, client_secret: @client_secret +end + +When /^I parse the tokens and use it obtain user info$/ do + client_json = JSON.parse(last_response.body) + access_token = client_json["access_token"] + encoded_id_token = client_json["id_token"] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expect(decoded_token.sub).to eq(@me.diaspora_handle) + get api_openid_connect_user_info_path, access_token: access_token +end diff --git a/features/step_definitions/implicit_flow_steps.rb b/features/step_definitions/implicit_flow_steps.rb new file mode 100644 index 000000000..f1603ec1a --- /dev/null +++ b/features/step_definitions/implicit_flow_steps.rb @@ -0,0 +1,61 @@ +O_AUTH_QUERY_PARAMS = { + redirect_uri: "http://localhost:3000", + response_type: "id_token token", + scope: "openid profile read", + nonce: "hello", + state: "hi", + prompt: "login" +} + +O_AUTH_QUERY_PARAMS_WITH_MAX_AGE = { + redirect_uri: "http://localhost:3000", + response_type: "id_token token", + scope: "openid profile read", + nonce: "hello", + state: "hi", + prompt: "login", + max_age: 30 +} + +Given /^I send a post request from that client to the authorization endpoint$/ do + client_json = JSON.parse(last_response.body) + visit new_api_openid_connect_authorization_path(O_AUTH_QUERY_PARAMS.merge(client_id: client_json["client_id"])) +end + +Given /^I have signed in (\d+) minutes ago$/ do |minutes| + @me.update_attribute(:current_sign_in_at, Time.zone.now - minutes.to_i.minute) +end + +Given /^I send a post request from that client to the authorization endpoint with max age$/ do + client_json = JSON.parse(last_response.body) + visit new_api_openid_connect_authorization_path( + O_AUTH_QUERY_PARAMS_WITH_MAX_AGE.merge(client_id: client_json["client_id"])) +end + +Given /^I send a post request from that client to the authorization endpoint using a invalid client id$/ do + visit new_api_openid_connect_authorization_path(O_AUTH_QUERY_PARAMS.merge(client_id: "randomid")) +end + +When /^I give my consent and authorize the client$/ do + click_button "Approve" +end + +When /^I deny authorization to the client$/ do + click_button "Deny" +end + +Then /^I should not see any tokens in the redirect url$/ do + access_token = current_url[/(?<=access_token=)[^&]+/] + id_token = current_url[/(?<=access_token=)[^&]+/] + expect(access_token).to eq(nil) + expect(id_token).to eq(nil) +end + +When /^I parse the bearer tokens and use it to access user info$/ do + access_token = current_url[/(?<=access_token=)[^&]+/] + get api_openid_connect_user_info_path, access_token: access_token +end + +Then /^I should see an "([^\"]*)" error$/ do |error_message| + expect(page).to have_content(error_message) +end diff --git a/features/step_definitions/oidc_common_steps.rb b/features/step_definitions/oidc_common_steps.rb new file mode 100644 index 000000000..4e28cd5f2 --- /dev/null +++ b/features/step_definitions/oidc_common_steps.rb @@ -0,0 +1,39 @@ +Given /^a client with a provided picture exists for user "([^\"]*)"$/ do |email| + app = FactoryGirl.create(:o_auth_application_with_image) + user = User.find_by(email: email) + FactoryGirl.create(:auth_with_read, user: user, o_auth_application: app) +end + +Given /^a client exists for user "([^\"]*)"$/ do |email| + user = User.find_by(email: email) + FactoryGirl.create(:auth_with_read, user: user) +end + +When /^I register a new client$/ do + post api_openid_connect_clients_path, redirect_uris: ["http://localhost:3000"], client_name: "diaspora client" +end + +When /^I use received valid bearer tokens to access user info$/ do + access_token_json = JSON.parse(last_response.body) + get api_openid_connect_user_info_path, access_token: access_token_json["access_token"] +end + +When /^I use invalid bearer tokens to access user info$/ do + get api_openid_connect_user_info_path, access_token: SecureRandom.hex(32) +end + +Then /^I should receive "([^\"]*)"'s id, username, and email$/ do |username| + user_info_json = JSON.parse(last_response.body) + user = User.find_by_username(username) + user_profile_url = File.join(AppConfig.environment.url, "people", user.guid).to_s + expect(user_info_json["profile"]).to have_content(user_profile_url) +end + +Then /^I should receive an "([^\"]*)" error$/ do |error_message| + user_info_json = JSON.parse(last_response.body) + expect(user_info_json["error"]).to have_content(error_message) +end + +Then(/^I should see a message containing "(.*?)"$/) do |message| + expect(find("#openid_connect_error_description").text).to include(message) +end diff --git a/features/step_definitions/user_applications_steps.rb b/features/step_definitions/user_applications_steps.rb new file mode 100644 index 000000000..7cef79050 --- /dev/null +++ b/features/step_definitions/user_applications_steps.rb @@ -0,0 +1,16 @@ +Then /^I should see (\d+) authorized applications$/ do |num| + expect(page).to have_selector(".applications-page", count: 1) + expect(page).to have_selector(".authorized-application", count: num.to_i) +end + +Then /^I should see (\d+) authorized applications with no provided image$/ do |num| + expect(page).to have_selector(".application-img > .entypo-browser", count: num.to_i) +end + +Then /^I should see (\d+) authorized applications with an image$/ do |num| + expect(page).to have_selector(".application-img > .img-responsive", count: num.to_i) +end + +When /^I revoke the first authorization$/ do + find(".app-revoke", match: :first).click +end diff --git a/features/support/env.rb b/features/support/env.rb index 888ef0191..07dc02d0d 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -12,6 +12,9 @@ require "capybara/cucumber" require "capybara/session" require "selenium/webdriver" +require "cucumber/api_steps" +require "json_spec/cucumber" + # Ensure we know the appservers port Capybara.server_port = AppConfig.pod_uri.port Rails.application.routes.default_url_options[:host] = AppConfig.pod_uri.host diff --git a/features/support/paths.rb b/features/support/paths.rb index 1d512dc43..223915aed 100644 --- a/features/support/paths.rb +++ b/features/support/paths.rb @@ -7,6 +7,8 @@ module NavigationHelpers stream_path when /^the mobile path$/ force_mobile_path + when /^the user applications page$/ + api_openid_connect_user_applications_path when /^the tag page for "([^\"]*)"$/ tag_path(Regexp.last_match(1)) when /^its ([\w ]+) page$/ diff --git a/lib/account_deleter.rb b/lib/account_deleter.rb index 7aaf40937..4d0335100 100644 --- a/lib/account_deleter.rb +++ b/lib/account_deleter.rb @@ -46,15 +46,17 @@ class AccountDeleter #user deletions def normal_ar_user_associates_to_delete - [:tag_followings, :invitations_to_me, :services, :aspects, :user_preferences, :notifications, :blocks] + %i(tag_followings invitations_to_me services aspects user_preferences + notifications blocks authorizations o_auth_applications pairwise_pseudonymous_identifiers) end def special_ar_user_associations - [:invitations_from_me, :person, :profile, :contacts, :auto_follow_back_aspect] + %i(invitations_from_me person profile contacts auto_follow_back_aspect) end def ignored_ar_user_associations - [:followed_tags, :invited_by, :contact_people, :aspect_memberships, :ignored_people, :conversation_visibilities, :conversations, :reports] + %i(followed_tags invited_by contact_people aspect_memberships + ignored_people conversation_visibilities conversations reports) end def delete_standard_user_associations diff --git a/lib/api/openid_connect/authorization_point/endpoint.rb b/lib/api/openid_connect/authorization_point/endpoint.rb new file mode 100644 index 000000000..8f3392bd9 --- /dev/null +++ b/lib/api/openid_connect/authorization_point/endpoint.rb @@ -0,0 +1,62 @@ +module Api + module OpenidConnect + module AuthorizationPoint + class Endpoint + attr_accessor :app, :user, :o_auth_application, :redirect_uri, :response_type, + :scopes, :request_uri, :request_object, :nonce + delegate :call, to: :app + + def initialize(user) + @user = user + @app = Rack::OAuth2::Server::Authorize.new do |req, res| + build_from_request_object(req) + build_attributes(req, res) + if OAuthApplication.available_response_types.include? Array(req.response_type).join(" ") + handle_response_type(req, res) + else + req.unsupported_response_type! + end + end + end + + def build_attributes(req, res) + build_client(req) + build_redirect_uri(req, res) + verify_nonce(req, res) + build_scopes(req) + end + + def handle_response_type(_req, _res) + raise NotImplementedError # Implemented by subclass + end + + private + + def build_client(req) + @o_auth_application = OAuthApplication.find_by_client_id(req.client_id) || req.bad_request! + end + + def build_redirect_uri(req, res) + res.redirect_uri = @redirect_uri = req.verify_redirect_uri!(@o_auth_application.redirect_uris) + end + + def verify_nonce(req, res) + req.invalid_request! "nonce required" if res.protocol_params_location == :fragment && req.nonce.blank? + end + + def build_scopes(req) + replace_profile_scope_with_specific_claims(req) + @scopes = req.scope.map {|scope| + scope.tap do |scope_name| + req.invalid_scope! "Unknown scope: #{scope_name}" unless auth_scopes.include? scope_name + end + } + end + + def auth_scopes + Api::OpenidConnect::Authorization::SCOPES + end + end + end + end +end diff --git a/lib/api/openid_connect/authorization_point/endpoint_confirmation_point.rb b/lib/api/openid_connect/authorization_point/endpoint_confirmation_point.rb new file mode 100644 index 000000000..08e99b25d --- /dev/null +++ b/lib/api/openid_connect/authorization_point/endpoint_confirmation_point.rb @@ -0,0 +1,72 @@ +module Api + module OpenidConnect + module AuthorizationPoint + class EndpointConfirmationPoint < Endpoint + def initialize(current_user, approved=false) + super(current_user) + @approved = approved + end + + def handle_response_type(req, res) + handle_approval(@approved, req, res) + end + + def handle_approval(approved, req, res) + if approved + approved!(req, res) + else + req.access_denied! + end + end + + def replace_profile_scope_with_specific_claims(_req) + # Empty + end + + def build_from_request_object(_req) + # Empty + end + + private + + def approved!(req, res) + auth = find_or_build_auth(req) + handle_approved_response_type(auth, req, res) + res.approve! + end + + def find_or_build_auth(req) + OpenidConnect::Authorization.find_or_create_by!( + o_auth_application: @o_auth_application, user: @user, redirect_uri: @redirect_uri).tap do |auth| + auth.nonce = req.nonce + auth.scopes = @scopes + auth.save + end + end + + def handle_approved_response_type(auth, req, res) + response_types = Array(req.response_type) + handle_approved_auth_code(auth, res, response_types) + handle_approved_access_token(auth, res, response_types) + handle_approved_id_token(auth, res, response_types) + end + + def handle_approved_auth_code(auth, res, response_types) + return unless response_types.include?(:code) + res.code = auth.create_code + end + + def handle_approved_access_token(auth, res, response_types) + return unless response_types.include?(:token) + res.access_token = auth.create_access_token + end + + def handle_approved_id_token(auth, res, response_types) + return unless response_types.include?(:id_token) + id_token = auth.create_id_token + res.id_token = id_token.to_jwt(code: res.try(:code), access_token: res.try(:access_token)) + end + end + end + end +end diff --git a/lib/api/openid_connect/authorization_point/endpoint_start_point.rb b/lib/api/openid_connect/authorization_point/endpoint_start_point.rb new file mode 100644 index 000000000..007a2c592 --- /dev/null +++ b/lib/api/openid_connect/authorization_point/endpoint_start_point.rb @@ -0,0 +1,35 @@ +module Api + module OpenidConnect + module AuthorizationPoint + class EndpointStartPoint < Endpoint + def build_from_request_object(req) + request_object = build_request_object(req) + return unless request_object + claims = request_object.raw_attributes.with_indifferent_access[:claims].try(:[], :userinfo).try(:keys) + return unless claims + req.update_param("scope", req.scope + claims) + end + + def handle_response_type(req, _res) + @response_type = req.response_type + end + + def replace_profile_scope_with_specific_claims(req) + profile_claims = %w(sub aud name nickname profile picture) + scopes_as_claims = req.scope.flat_map {|scope| scope == "profile" ? profile_claims : [scope] }.uniq + req.update_param("scope", scopes_as_claims) + end + + private + + def build_request_object(req) + if req.request_uri.present? + OpenIDConnect::RequestObject.fetch req.request_uri + elsif req.request.present? + OpenIDConnect::RequestObject.decode req.request + end + end + end + end + end +end diff --git a/lib/api/openid_connect/error.rb b/lib/api/openid_connect/error.rb new file mode 100644 index 000000000..56059ad59 --- /dev/null +++ b/lib/api/openid_connect/error.rb @@ -0,0 +1,16 @@ +module Api + module OpenidConnect + module Error + class InvalidRedirectUri < ::ArgumentError + def initialize + super "Redirect uri contains fragment" + end + end + class InvalidSectorIdentifierUri < ::ArgumentError + def initialize + super "Invalid sector identifier uri" + end + end + end + end +end diff --git a/lib/api/openid_connect/id_token_config.rb b/lib/api/openid_connect/id_token_config.rb new file mode 100644 index 000000000..7592e105f --- /dev/null +++ b/lib/api/openid_connect/id_token_config.rb @@ -0,0 +1,16 @@ +module Api + module OpenidConnect + class IdTokenConfig + key_file_path = File.join(Rails.root, "config", "oidc_key.pem") + if File.exist?(key_file_path) + private_key = OpenSSL::PKey::RSA.new(File.read(key_file_path)) + else + private_key = OpenSSL::PKey::RSA.new(2048) + File.write key_file_path, private_key.to_pem + File.chmod(0600, key_file_path) + end + PRIVATE_KEY = private_key + PUBLIC_KEY = private_key.public_key + end + end +end diff --git a/lib/api/openid_connect/protected_resource_endpoint.rb b/lib/api/openid_connect/protected_resource_endpoint.rb new file mode 100644 index 000000000..540b69d1d --- /dev/null +++ b/lib/api/openid_connect/protected_resource_endpoint.rb @@ -0,0 +1,38 @@ +# Copyright (c) 2011 nov matake +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# See https://github.com/nov/openid_connect_sample/blob/master/lib/authentication.rb#L56 + +module Api + module OpenidConnect + module ProtectedResourceEndpoint + attr_reader :current_token + + def require_access_token(required_scopes) + @current_token = request.env[Rack::OAuth2::Server::Resource::ACCESS_TOKEN] + raise Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new("Unauthorized user") unless + @current_token && @current_token.authorization + raise Rack::OAuth2::Server::Resource::Bearer::Forbidden.new(:insufficient_scope) unless + @current_token.authorization.try(:accessible?, required_scopes) + end + end + end +end diff --git a/lib/api/openid_connect/subject_identifier_creator.rb b/lib/api/openid_connect/subject_identifier_creator.rb new file mode 100644 index 000000000..b6a771fa0 --- /dev/null +++ b/lib/api/openid_connect/subject_identifier_creator.rb @@ -0,0 +1,17 @@ +module Api + module OpenidConnect + module SubjectIdentifierCreator + def self.create(auth) + if auth.o_auth_application.ppid? + identifier = auth.o_auth_application.sector_identifier_uri || + URI.parse(auth.o_auth_application.redirect_uris[0]).host + pairwise_pseudonymous_identifier = + auth.user.pairwise_pseudonymous_identifiers.find_or_create_by(identifier: identifier) + pairwise_pseudonymous_identifier.guid + else + auth.user.diaspora_handle + end + end + end + end +end diff --git a/lib/api/openid_connect/token_endpoint.rb b/lib/api/openid_connect/token_endpoint.rb new file mode 100644 index 000000000..a2e8c8ac2 --- /dev/null +++ b/lib/api/openid_connect/token_endpoint.rb @@ -0,0 +1,57 @@ +# Inspired by https://github.com/nov/openid_connect_sample/blob/master/lib/token_endpoint.rb + +module Api + module OpenidConnect + class TokenEndpoint + attr_accessor :app + delegate :call, to: :app + + def initialize + @app = Rack::OAuth2::Server::Token.new do |req, res| + o_auth_app = retrieve_client(req) + if app_valid?(o_auth_app, req) + handle_flows(req, res) + else + req.invalid_client! + end + end + end + + def handle_flows(req, res) + case req.grant_type + when :refresh_token + handle_refresh_flow(req, res) + when :authorization_code + auth = Api::OpenidConnect::Authorization.with_redirect_uri(req.redirect_uri).use_code(req.code) + req.invalid_grant! if auth.blank? + res.access_token = auth.create_access_token + if auth.accessible? "openid" + id_token = auth.create_id_token + res.id_token = id_token.to_jwt(access_token: res.access_token) + end + else + req.unsupported_grant_type! + end + end + + def handle_refresh_flow(req, res) + # Handle as if scope request was omitted even if provided. + # See https://tools.ietf.org/html/rfc6749#section-6 for handling + auth = Api::OpenidConnect::Authorization.find_by_refresh_token req.client_id, req.refresh_token + if auth + res.access_token = auth.create_access_token + else + req.invalid_grant! + end + end + + def retrieve_client(req) + Api::OpenidConnect::OAuthApplication.find_by client_id: req.client_id + end + + def app_valid?(o_auth_app, req) + o_auth_app.client_secret == req.client_secret + end + end + end +end diff --git a/spec/controllers/api/openid_connect/authorizations_controller_spec.rb b/spec/controllers/api/openid_connect/authorizations_controller_spec.rb new file mode 100644 index 000000000..09b7bc626 --- /dev/null +++ b/spec/controllers/api/openid_connect/authorizations_controller_spec.rb @@ -0,0 +1,363 @@ +require "spec_helper" + +describe Api::OpenidConnect::AuthorizationsController, type: :controller do + let!(:client) { FactoryGirl.create(:o_auth_application) } + let!(:client_with_xss) { FactoryGirl.create(:o_auth_application_with_xss) } + let!(:client_with_multiple_redirects) { FactoryGirl.create(:o_auth_application_with_multiple_redirects) } + let!(:auth_with_read) { FactoryGirl.create(:auth_with_read) } + + before do + sign_in :user, alice + end + + describe "#new" do + context "when not yet authorized" do + context "when valid parameters are passed" do + render_views + context "as GET request" do + it "should return a form page" do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to match("Diaspora Test Client") + end + end + + context "using claims" do + it "should return a form page" do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", claims: "{\"userinfo\": {\"name\": {\"essential\": true}}}", + nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to match("Diaspora Test Client") + end + end + + context "as a request object" do + it "should return a form page" do + header = JWT.encoded_header("none") + payload_hash = {client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", nonce: "hello", state: "hello", + claims: {userinfo: {name: {essential: true}}}} + payload = JWT.encoded_payload(JSON.parse(payload_hash.to_json)) + request_object = header + "." + payload + "." + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: "hello", state: "hello", request: request_object + expect(response.body).to match("Diaspora Test Client") + end + end + + context "as a request object with no claims" do + it "should return a form page" do + header = JWT.encoded_header("none") + payload_hash = {client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", nonce: "hello", state: "hello"} + payload = JWT.encoded_payload(JSON.parse(payload_hash.to_json)) + request_object = header + "." + payload + "." + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: "hello", state: "hello", request: request_object + expect(response.body).to match("Diaspora Test Client") + end + end + + context "as POST request" do + it "should return a form page" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to match("Diaspora Test Client") + end + end + end + + context "when client id is missing" do + it "should return an bad request error" do + post :new, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to include("The request was malformed") + end + end + + context "when redirect uri is missing" do + context "when only one redirect URL is pre-registered" do + it "should return a form page" do + # Note this intentionally behavior diverts from OIDC spec http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + # When client has only one redirect uri registered, only that redirect uri can be used. Hence, + # we should implicitly assume the client wants to use that registered URI. + # See https://github.com/nov/rack-oauth2/blob/master/lib/rack/oauth2/server/authorize.rb#L63 + post :new, client_id: client.client_id, response_type: "id_token", + scope: "openid", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to match("Diaspora Test Client") + end + end + end + + context "when multiple redirect URLs are pre-registered" do + it "should return an invalid request error" do + post :new, client_id: client_with_multiple_redirects.client_id, response_type: "id_token", + scope: "openid", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to include("The request was malformed") + end + end + + context "when redirect URI does not match pre-registered URIs" do + it "should return an invalid request error", focus: true do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:2000/", + response_type: "id_token", scope: "openid", nonce: SecureRandom.hex(16) + expect(response.body).to include("Invalid client id or redirect uri") + end + end + + context "when an unsupported scope is passed in" do + it "should return an invalid scope error" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "random", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to match("error=invalid_scope") + end + end + + context "when nonce is missing" do + it "should return an invalid request error" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", state: SecureRandom.hex(16) + expect(response.location).to match("error=invalid_request") + end + end + + context "when prompt is none" do + it "should return an interaction required error" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", state: 1234, display: "page", prompt: "none" + expect(response.body).to include("User must already be authorized when `prompt` is `none`") + end + end + + context "when prompt is none and user not signed in" do + before do + sign_out :user + end + + it "should return an interaction required error" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", state: 1234, display: "page", prompt: "none" + expect(response.body).to include("User must already be logged in when `prompt` is `none`") + end + end + + context "when prompt is none and consent" do + it "should return an interaction required error" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", state: 1234, display: "page", prompt: "none consent" + expect(response.location).to match("error=invalid_request") + end + end + + context "when prompt is select_account" do + it "should return an account_selection_required error" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", state: 1234, display: "page", prompt: "select_account" + expect(response.location).to match("error=account_selection_required") + expect(response.location).to match("state=1234") + end + end + + context "when prompt is none and client ID is invalid" do + it "should return an account_selection_required error" do + post :new, client_id: "random", redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", state: 1234, display: "page", prompt: "none" + expect(response.body).to include("Invalid client id or redirect uri") + end + end + + context "when prompt is none and redirect URI does not match pre-registered URIs" do + it "should return an account_selection_required error" do + post :new, client_id: client.client_id, redirect_uri: "http://randomuri:3000/", + response_type: "id_token", scope: "openid", state: 1234, display: "page", prompt: "none" + expect(response.body).to include("Invalid client id or redirect uri") + end + end + + context "when XSS script is passed as name" do + it "should escape html" do + post :new, client_id: client_with_xss.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", nonce: SecureRandom.hex(16), state: SecureRandom.hex(16) + expect(response.body).to_not include("") + end + end + end + + context "when already authorized" do + let!(:auth) { + Api::OpenidConnect::Authorization.find_or_create_by(o_auth_application: client, user: alice, + redirect_uri: "http://localhost:3000/", scopes: ["openid"]) + } + + context "when valid parameters are passed" do + before do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: 413_093_098_3, state: 413_093_098_3 + end + + it "should return the id token in a fragment" do + expect(response.location).to have_content("id_token=") + encoded_id_token = response.location[/(?<=id_token=)[^&]+/] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expect(decoded_token.nonce).to eq("4130930983") + expect(decoded_token.exp).to be > Time.zone.now.utc.to_i + end + + it "should return the passed in state" do + expect(response.location).to have_content("state=4130930983") + end + end + + context "when prompt is none" do + it "should return the id token in a fragment" do + post :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", nonce: 413_093_098_3, state: 413_093_098_3, + display: "page", prompt: "none" + expect(response.location).to have_content("id_token=") + encoded_id_token = response.location[/(?<=id_token=)[^&]+/] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expect(decoded_token.nonce).to eq("4130930983") + expect(decoded_token.exp).to be > Time.zone.now.utc.to_i + end + end + + context "when prompt contains consent" do + it "should return a consent form page" do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", + response_type: "id_token", scope: "openid", nonce: 413_093_098_3, state: 413_093_098_3, + display: "page", prompt: "consent" + expect(response.body).to match("Diaspora Test Client") + end + end + end + end + + describe "#create" do + context "when id_token token" do + before do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token token", + scope: "openid", nonce: 418_093_098_3, state: 418_093_098_3 + end + + context "when authorization is approved" do + before do + post :create, approve: "true" + end + + it "should return the id token in a fragment" do + encoded_id_token = response.location[/(?<=id_token=)[^&]+/] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expect(decoded_token.nonce).to eq("4180930983") + expect(decoded_token.exp).to be > Time.zone.now.utc.to_i + end + + it "should return a valid access token in a fragment" do + encoded_id_token = response.location[/(?<=id_token=)[^&]+/] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + access_token = response.location[/(?<=access_token=)[^&]+/] + access_token_check_num = UrlSafeBase64.encode64(OpenSSL::Digest::SHA256.digest(access_token)[0, 128 / 8]) + expect(decoded_token.at_hash).to eq(access_token_check_num) + end + end + end + + context "when id_token" do + before do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "id_token", + scope: "openid", nonce: 418_093_098_3, state: 418_093_098_3 + end + + context "when authorization is approved" do + before do + post :create, approve: "true" + end + + it "should return the id token in a fragment" do + expect(response.location).to have_content("id_token=") + encoded_id_token = response.location[/(?<=id_token=)[^&]+/] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expect(decoded_token.nonce).to eq("4180930983") + expect(decoded_token.exp).to be > Time.zone.now.utc.to_i + end + + it "should return the passed in state" do + expect(response.location).to have_content("state=4180930983") + end + end + + context "when authorization is denied" do + before do + post :create, approve: "false" + end + + it "should return an error in the fragment" do + expect(response.location).to have_content("error=") + end + + it "should NOT contain a id token in the fragment" do + expect(response.location).to_not have_content("id_token=") + end + end + end + + context "when code" do + before do + get :new, client_id: client.client_id, redirect_uri: "http://localhost:3000/", response_type: "code", + scope: "openid", nonce: 418_093_098_3, state: 418_093_098_3 + end + + context "when authorization is approved" do + before do + post :create, approve: "true" + end + + it "should return the code" do + expect(response.location).to have_content("code") + end + + it "should return the passed in state" do + expect(response.location).to have_content("state=4180930983") + end + end + + context "when authorization is denied" do + before do + post :create, approve: "false" + end + + it "should return an error" do + expect(response.location).to have_content("error") + end + + it "should NOT contain code" do + expect(response.location).to_not have_content("code") + end + end + end + end + + describe "#destroy" do + context "with existent authorization" do + before do + delete :destroy, id: auth_with_read.id + end + + it "removes the authorization" do + expect(Api::OpenidConnect::Authorization.find_by(id: auth_with_read.id)).to be_nil + end + end + + context "with non-existent authorization" do + it "raises an error" do + delete :destroy, id: 123_456_789 + expect(response).to redirect_to(api_openid_connect_user_applications_url) + expect(flash[:error]).to eq("The attempt to revoke the authorization with ID 123456789 failed") + end + end + end +end diff --git a/spec/controllers/api/openid_connect/clients_controller_spec.rb b/spec/controllers/api/openid_connect/clients_controller_spec.rb new file mode 100644 index 000000000..164c82bce --- /dev/null +++ b/spec/controllers/api/openid_connect/clients_controller_spec.rb @@ -0,0 +1,141 @@ +require "spec_helper" + +describe Api::OpenidConnect::ClientsController, type: :controller do + describe "#create" do + context "when valid parameters are passed" do + it "should return a client id" do + stub_request(:get, "http://example.com/uris") + .with(headers: { + "Accept" => "*/*", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "Faraday v0.9.2" + }) + .to_return(status: 200, body: "[\"http://localhost\"]", headers: {}) + post :create, redirect_uris: ["http://localhost"], client_name: "diaspora client", + response_types: [], grant_types: [], application_type: "web", contacts: [], + logo_uri: "http://example.com/logo.png", client_uri: "http://example.com/client", + policy_uri: "http://example.com/policy", tos_uri: "http://example.com/tos", + sector_identifier_uri: "http://example.com/uris", subject_type: "pairwise" + client_json = JSON.parse(response.body) + expect(client_json["client_id"].length).to eq(32) + expect(client_json["ppid"]).to eq(true) + end + end + + context "when valid parameters with jwks is passed" do + it "should return a client id" do + stub_request(:get, "http://example.com/uris") + .with(headers: { + "Accept" => "*/*", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "Faraday v0.9.2"}) + .to_return(status: 200, body: "[\"http://localhost\"]", headers: {}) + post :create, redirect_uris: ["http://localhost"], client_name: "diaspora client", + response_types: [], grant_types: [], application_type: "web", contacts: [], + logo_uri: "http://example.com/logo.png", client_uri: "http://example.com/client", + policy_uri: "http://example.com/policy", tos_uri: "http://example.com/tos", + sector_identifier_uri: "http://example.com/uris", subject_type: "pairwise", + token_endpoint_auth_method: "private_key_jwt", + jwks: { + keys: + [ + { + use: "enc", + e: "AQAB", + d: "-lTBWkI-----lvCO6tuiDsR4qgJnUwnndQFwEI_4mLmD3iNWXrc8N--5Cjq55eLtuJjtvuQ", + n: "--zYRQNDvIVsBDLQQIgrbctuGqj6lrXb31Jj3JIEYqH_4h5X9d0Q", + q: "1q-r----pFtyTz_JksYYaotc_Z3Zy-Szw6a39IDbuYGy1qL-15oQuc", + p: "-BfRjdgYouy4c6xAnGDgSMTip1YnPRyvbMaoYT9E_tEcBW5wOeoc", + kid: "a0", + kty: "RSA" + }, + { + use: "sig", + e: "AQAB", + d: "--x-gW---LRPowKrdvTuTo2p--HMI0pIEeFs7H_u5OW3jihjvoFClGPynHQhgWmQzlQRvWRXh6FhDVqFeGQ", + n: "---TyeadDqQPWgbqX69UzcGq5irhzN8cpZ_JaTk3Y_uV6owanTZLVvCgdjaAnMYeZhb0KFw", + q: "5E5XKK5njT--Hx3nF5sne5fleVfU-sZy6Za4B2U75PcE62oZgCPauOTAEm9Xuvrt5aMMovyzR8ecJZhm9bw7naU", + p: "-BUGA-", + kid: "a1", + kty: "RSA"}, + { + use: "sig", + crv: "P-256", + kty: "EC", + y: "Yg4IRzHBMIsuQK2Oz0Uukp1aNDnpdoyk6QBMtmfGHQQ", + x: "L0WUeVlc9r6YJd6ie9duvOU1RHwxSkJKA37IK9B4Bpc", + kid: "a2" + }, + { + use: "enc", + crv: "P-256", + kty: "EC", + y: "E6E6g5_ziIZvfdAoACctnwOhuQYMvQzA259aftPn59M", + x: "Yu8_BQE2L0f1MqnK0GumZOaj_77Tx70-LoudyRUnLM4", + kid: "a3" + } + ] + } + client_json = JSON.parse(response.body) + expect(client_json["client_id"].length).to eq(32) + expect(client_json["ppid"]).to eq(true) + end + end + + context "when valid parameters with jwks_uri is passed" do + it "should return a client id" do + stub_request(:get, "http://example.com/uris") + .with(headers: {"Accept" => "*/*", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "Faraday v0.9.2"}) + .to_return(status: 200, body: "[\"http://localhost\"]", headers: {}) + stub_request(:get, "https://kentshikama.com/api/openid_connect/jwks.json") + .with(headers: {"Accept" => "*/*", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "Faraday v0.9.2"}) + .to_return(status: 200, + body: "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"n\":\"qpW\",\"use\":\"sig\"}]}", headers: {}) + post :create, redirect_uris: ["http://localhost"], client_name: "diaspora client", + response_types: [], grant_types: [], application_type: "web", contacts: [], + logo_uri: "http://example.com/logo.png", client_uri: "http://example.com/client", + policy_uri: "http://example.com/policy", tos_uri: "http://example.com/tos", + sector_identifier_uri: "http://example.com/uris", subject_type: "pairwise", + token_endpoint_auth_method: "private_key_jwt", + jwks_uri: "https://kentshikama.com/api/openid_connect/jwks.json" + client_json = JSON.parse(response.body) + expect(client_json["client_id"].length).to eq(32) + expect(client_json["ppid"]).to eq(true) + end + end + + context "when redirect uri is missing" do + it "should return a invalid_client_metadata error" do + post :create, response_types: [], grant_types: [], application_type: "web", contacts: [], + logo_uri: "http://example.com/logo.png", client_uri: "http://example.com/client", + policy_uri: "http://example.com/policy", tos_uri: "http://example.com/tos" + client_json = JSON.parse(response.body) + expect(client_json["error"]).to have_content("invalid_client_metadata") + end + end + end + + describe "#find" do + let!(:client) { FactoryGirl.create(:o_auth_application) } + + context "when an OIDC client already exists" do + it "should return a client id" do + get :find, client_name: client.client_name + client_id_json = JSON.parse(response.body) + expect(client_id_json["client_id"]).to eq(client.client_id) + end + end + + context "when an OIDC client doesn't already exist" do + it "should return the appropriate error" do + get :find, client_name: "random_name" + client_id_json = JSON.parse(response.body) + expect(client_id_json["error"]).to eq("Client with name random_name does not exist") + end + end + end +end diff --git a/spec/controllers/api/openid_connect/discovery_controller_spec.rb b/spec/controllers/api/openid_connect/discovery_controller_spec.rb new file mode 100644 index 000000000..19b90d6c5 --- /dev/null +++ b/spec/controllers/api/openid_connect/discovery_controller_spec.rb @@ -0,0 +1,35 @@ +require "spec_helper" + +describe Api::OpenidConnect::DiscoveryController, type: :controller do + describe "#webfinger" do + before do + get :webfinger, resource: "http://example.com/bob" + end + + it "should return a url to the openid-configuration" do + json_body = JSON.parse(response.body) + expect(json_body["links"].first["href"]).to eq(root_url) + end + + it "should return the resource in the subject" do + json_body = JSON.parse(response.body) + expect(json_body["subject"]).to eq("http://example.com/bob") + end + end + + describe "#configuration" do + before do + get :configuration + end + + it "should have the issuer as the root url" do + json_body = JSON.parse(response.body) + expect(json_body["issuer"]).to eq(root_url) + end + + it "should have the appropriate user info endpoint" do + json_body = JSON.parse(response.body) + expect(json_body["userinfo_endpoint"]).to eq(api_openid_connect_user_info_url) + end + end +end diff --git a/spec/controllers/api/openid_connect/id_tokens_controller_spec.rb b/spec/controllers/api/openid_connect/id_tokens_controller_spec.rb new file mode 100644 index 000000000..ec8ab8643 --- /dev/null +++ b/spec/controllers/api/openid_connect/id_tokens_controller_spec.rb @@ -0,0 +1,19 @@ +require "spec_helper" + +describe Api::OpenidConnect::IdTokensController, type: :controller do + describe "#jwks" do + before do + get :jwks + end + + it "should contain a public key that matches the internal private key" do + json = JSON.parse(response.body).with_indifferent_access + jwks = JSON::JWK::Set.new json[:keys] + public_keys = jwks.map do |jwk| + JSON::JWK.new(jwk).to_key + end + public_key = public_keys.first + expect(Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY.to_s).to eq(public_key.to_s) + end + end +end diff --git a/spec/controllers/api/openid_connect/user_applications_spec.rb b/spec/controllers/api/openid_connect/user_applications_spec.rb new file mode 100644 index 000000000..71c7dc7e3 --- /dev/null +++ b/spec/controllers/api/openid_connect/user_applications_spec.rb @@ -0,0 +1,17 @@ +require "spec_helper" + +describe Api::OpenidConnect::UserApplicationsController, type: :controller do + before do + @app = FactoryGirl.create(:o_auth_application_with_xss) + @user = FactoryGirl.create :user + FactoryGirl.create :auth_with_read, user: @user, o_auth_application: @app + sign_in :user, @user + end + + context "when try to XSS" do + it "should not include XSS script" do + get :index + expect(response.body).to_not include("") + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index 9adf5534b..a7a5a8dca 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -310,6 +310,59 @@ FactoryGirl.define do factory(:status, :parent => :status_message) + factory :o_auth_application, class: Api::OpenidConnect::OAuthApplication do + client_name "Diaspora Test Client" + redirect_uris %w(http://localhost:3000/) + end + + factory :o_auth_application_with_image, class: Api::OpenidConnect::OAuthApplication do + client_name "Diaspora Test Client" + redirect_uris %w(http://localhost:3000/) + logo_uri "/assets/user/default.png" + end + + factory :o_auth_application_with_ppid, class: Api::OpenidConnect::OAuthApplication do + client_name "Diaspora Test Client" + redirect_uris %w(http://localhost:3000/) + ppid true + sector_identifier_uri "https://example.com/uri" + end + + factory :o_auth_application_with_ppid_with_specific_id, class: Api::OpenidConnect::OAuthApplication do + client_name "Diaspora Test Client" + redirect_uris %w(http://localhost:3000/) + ppid true + sector_identifier_uri "https://example.com/uri" + end + + factory :o_auth_application_with_multiple_redirects, class: Api::OpenidConnect::OAuthApplication do + client_name "Diaspora Test Client" + redirect_uris %w(http://localhost:3000/ http://localhost/) + end + + factory :o_auth_application_with_xss, class: Api::OpenidConnect::OAuthApplication do + client_name "" + redirect_uris %w(http://localhost:3000/) + end + + factory :auth_with_read, class: Api::OpenidConnect::Authorization do + o_auth_application + user + scopes %w(openid sub aud profile picture nickname name read) + end + + factory :auth_with_read_and_ppid, class: Api::OpenidConnect::Authorization do + association :o_auth_application, factory: :o_auth_application_with_ppid + user + scopes %w(openid sub aud profile picture nickname name read) + end + + factory :auth_with_read_and_write, class: Api::OpenidConnect::Authorization do + o_auth_application + user + scopes %w(openid sub aud profile picture nickname name read write) + end + # Factories for the DiasporaFederation-gem factory(:federation_person_from_webfinger, class: DiasporaFederation::Entities::Person) do diff --git a/spec/fixtures/client_assertion_with_nonexistent_client_id.txt b/spec/fixtures/client_assertion_with_nonexistent_client_id.txt new file mode 100644 index 000000000..3bcabb078 --- /dev/null +++ b/spec/fixtures/client_assertion_with_nonexistent_client_id.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6ImExIn0.ewogIGF1ZDogWwogICAgaHR0cHM6Ly9rZW50c2hpa2FtYS5jb20vYXBpL29wZW5pZF9jb25uZWN0L2FjY2Vzc190b2tlbnMKICBdLAogIGlzczogMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2QsCiAganRpOiAwbWNycmVZSCwKICBleHA6IDE0NDMxNzA4OTEuMzk3NDU2LAogIGlhdDogMTQ0MzE3MDI5MS4zOTc0NTYsCiAgc3ViOiAxNGQ2OTJjZDUzZDljMWE5ZjQ2ZmQ2OWUwZTU3NDQzZAp9Cg.QJUR3SYFrEIlbfOKjO0NYInddklytbJ2LSWNpkQ1aNThgneDCVCjIYGCaL2C9Sw-GR8j7QSUsKOwBbjZMUmVPFTjsfB4wdgObbxVt1QAXwDjAXc5w1smOerRsoahZ4yKI1an6PTaFxMwnoXUQcBZTsOS6RgXOCPPPoxibxohxoehPLieM0l7LYcF5DQKg7fTxZYOpmtiP--nibJxomXdVQNLSnZuQwnyWtlp_gYmqrYMMN1LPSmNCgZMZZZIYttaaAIA96SylglqubowJRShtDO9rSvUz_sgeCo7qo5Bfb0B5n9_PtIlr1CZSVoHyYj2lVqQldx7fnGuqqQJCfDQog diff --git a/spec/fixtures/client_assertion_with_nonexistent_kid.txt b/spec/fixtures/client_assertion_with_nonexistent_kid.txt new file mode 100644 index 000000000..3419d02c1 --- /dev/null +++ b/spec/fixtures/client_assertion_with_nonexistent_kid.txt @@ -0,0 +1 @@ +ewogIGFsZzogUlMyNTYsCiAga2lkOiBpbnZhbGlkX2tpZAp9Cg.eyJhdWQiOiBbImh0dHBzOi8va2VudHNoaWthbWEuY29tL2FwaS9vcGVuaWRfY29ubmVjdC9hY2Nlc3NfdG9rZW5zIl0sICJpc3MiOiAiMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2UiLCAianRpIjogIjBtY3JyZVlIIiwgImV4cCI6IDE0NDMxNzA4OTEuMzk3NDU2LCAiaWF0IjogMTQ0MzE3MDI5MS4zOTc0NTYsICJzdWIiOiAiMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2UifQ. \ No newline at end of file diff --git a/spec/fixtures/client_assertion_with_tampered_sig.txt b/spec/fixtures/client_assertion_with_tampered_sig.txt new file mode 100644 index 000000000..ff225126e --- /dev/null +++ b/spec/fixtures/client_assertion_with_tampered_sig.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6ImExIn0.eyJhdWQiOiBbImh0dHBzOi8va2VudHNoaWthbWEuY29tL2FwaS9vcGVuaWRfY29ubmVjdC9hY2Nlc3NfdG9rZW5zIl0sICJpc3MiOiAiMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2UiLCAianRpIjogIjBtY3JyZVlIIiwgImV4cCI6IDE0NDMxNzA4OTEuMzk3NDU2LCAiaWF0IjogMTQ0MzE3MDI5MS4zOTc0NTYsICJzdWIiOiAiMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2UifQ.QJUR3SYFrEIlbfOKjO0NYInddklytbJ2LSWNpkQ1aNThgneDCVCjIYGCaL2C9Sw-GR8j7QSUsKOwBbjZMUmVPFTjsfB4wdgObbxVt1QAXwDjAXc5w1smOerRsoahZ4yKI1an6PTaFxMwnoXUQcBZTsOS6RgXOCPPPoxibxohxoehPLieM0l7LYcF5DQKg7fTxZYOpmtiP--nibJxomXdVQNLSnZuQwnyWtlp_gYmqrYMMN1LPSmNCgZMZZZIYttaaAIA96SylglqubowJRShtDO9rSvUz_sgeCo7qo5Bfb0B5n9_PtIlr1CZSVoHyYj2lVqQldx7fnGuqqQJCfDQoe \ No newline at end of file diff --git a/spec/fixtures/jwks.json b/spec/fixtures/jwks.json new file mode 100644 index 000000000..be157aeec --- /dev/null +++ b/spec/fixtures/jwks.json @@ -0,0 +1 @@ +{"keys": [{"use": "enc", "e": "AQAB", "d": "lZQv0_81euRLeUYU84Aodh0ar7ymDlzWP5NMra4Jklkb-lTBWkI-u4RMsPqGYyW3KHRoL_pgzZXSzQx8RLQfER6timRWb--NxMMKllZubByU3RqH2ooNuocJurspYiXkznPW1Mg9DaNXL0C2hwWPQHTeUVISpjgi5TCOV1ccWVyksFruya_VNL1CIByB-L0GL1rqbKv32cDwi2A3_jJa61cpzfLSIBe-lvCO6tuiDsR4qgJnUwnndQFwEI_4mLmD3iNWXrc8N-poleV8mBfMqBB5fWwy_ZTFCpmQ5AywGmctaik_wNhMoWuA4tUfY6_1LdKld-5Cjq55eLtuJjtvuQ", "n": "tx3Hjdbc19lkTiohbJrNj4jf2_90MEE122CRrwtFu6saDywKcG7Bi7w2FMAK2oTkuWfqhWRb5BEGmnSXdiCEPO5d-ytqP3nwlZXHaCDYscpP8bB4YLhvCn7R8Efw6gwQle24QPRP3lYoFeuUbDUq7GKA5SfaZUvWoeWjqyLIaBspKQsC26_Umx1E4IXLrMSL6nkRnrYcVZBAXrYCeTP1XtsV38_lZVJfHSaJaUy4PKaj3yvgm93EV2CXybPti7CCMXZ34VqqWiF64pQjZsPu3ZTr7ha_TTQq499-zYRQNDvIVsBDLQQIgrbctuGqj6lrXb31Jj3JIEYqH_4h5X9d0Q", "q": "1q-r-bmMFbIzrLK2U3elksZq8CqUqZxlSfkGMZuVkxgYMS-e4FPzEp2iirG-eO11aa0cpMMoBdTnVdGJ_ZUR93w0lGf9XnQAJqxP7eOsrUoiW4VWlWH4WfOiLgpO-pFtyTz_JksYYaotc_Z3Zy-Szw6a39IDbuYGy1qL-15oQuc", "p": "2lrYPppRbcQWu4LtWN6tOVUrtCOPv1eLTKTc7q8vCMcem1Ox5QFB7KnUtNZ5Ni7wnZUeVDfimNebtjNsGvDSrpgIlo9dEnFBQsQIkzZ2SkoYfgmF8hNdi6P-BfRjdgYouy4c6xAnGDgSMTip1YnPRyvbMaoYT9E_tEcBW5wOeoc", "kid": "a0", "kty": "RSA"}, {"use": "sig", "e": "AQAB", "d": "DodXDEtkovWWGsMEXYy_nEEMCWyROMOebCnCv0ey3i4M4bh2dmwqgz0e-IKQAFlGiMkidGL1lNbq0uFS04FbuRAR06dYw1cbrNbDdhrWFxKTd1L5D9p-x-gW-YDWhpI8rUGRa76JXkOSxZUbg09_QyUd99CXAHh-FXi_ZkIKD8hK6FrAs68qhLf8MNkUv63DTduw7QgeFfQivdopePxyGuMk5n8veqwsUZsklQkhNlTYQqeM1xb2698ZQcNYkl0OssEsSJKRjXt-LRPowKrdvTuTo2p--HMI0pIEeFs7H_u5OW3jihjvoFClGPynHQhgWmQzlQRvWRXh6FhDVqFeGQ", "n": "zfZzttF7HmnTYwSMPdxKs5AoczbNS2mOPz-tN1g4ljqI_F1DG8cgQDcN_VDufxoFGRERo2FK6WEN41LhbGEyP6uL6wW6Cy29qE9QZcvY5mXrncndRSOkNcMizvuEJes_fMYrmP_lPiC6kWiqItTk9QBWqJfiYKhCx9cSDXsBmJXn3KWQCVHvj1ANFWW0CWLMKlWN-_NMNLIWJN_pEAocTZMzxSFBK1b5_5J8ZS7hfWRF6MQmjsJcz2jzA21SQZNpre3kwnTGRSwo05sAS-TyeadDqQPWgbqX69UzcGq5irhzN8cpZ_JaTk3Y_uV6owanTZLVvCgdjaAnMYeZhb0KFw", "q": "5E5XKK5njT-zzRqqTeY2tgP9PJBACeaH_xQRHZ_1ydE7tVd7HdgdaEHfQ1jvKIHFkknWWOBAY1mlBc4YDirLShB_voShD8C-Hx3nF5sne5fleVfU-sZy6Za4B2U75PcE62oZgCPauOTAEm9Xuvrt5aMMovyzR8ecJZhm9bw7naU", "p": "5vJHCSM3H3q4RltYzENC9RyZZV8EUmpkv9moyguT5t-BUGA-T4W_FGIxzOPXRWOckIplKkoDKhavUeNmTZMCUcue0nkICSJpvNE4Nb2p5PZk_QqSdQNvCasQtdojEG0AmfVD85SU551CYxJdLdDFOqyK2entpMr8lhokem189As", "kid": "a1", "kty": "RSA"}, {"use": "sig", "crv": "P-256", "kty": "EC", "y": "Yg4IRzHBMIsuQK2Oz0Uukp1aNDnpdoyk6QBMtmfGHQQ", "x": "L0WUeVlc9r6YJd6ie9duvOU1RHwxSkJKA37IK9B4Bpc", "kid": "a2"}, {"use": "enc", "crv": "P-256", "kty": "EC", "y": "E6E6g5_ziIZvfdAoACctnwOhuQYMvQzA259aftPn59M", "x": "Yu8_BQE2L0f1MqnK0GumZOaj_77Tx70-LoudyRUnLM4", "kid": "a3"}]} \ No newline at end of file diff --git a/spec/fixtures/valid_client_assertion.txt b/spec/fixtures/valid_client_assertion.txt new file mode 100644 index 000000000..4a0ac9441 --- /dev/null +++ b/spec/fixtures/valid_client_assertion.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6ImExIn0.eyJhdWQiOiBbImh0dHBzOi8va2VudHNoaWthbWEuY29tL2FwaS9vcGVuaWRfY29ubmVjdC9hY2Nlc3NfdG9rZW5zIl0sICJpc3MiOiAiMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2UiLCAianRpIjogIjBtY3JyZVlIIiwgImV4cCI6IDE0NDMxNzA4OTEuMzk3NDU2LCAiaWF0IjogMTQ0MzE3MDI5MS4zOTc0NTYsICJzdWIiOiAiMTRkNjkyY2Q1M2Q5YzFhOWY0NmZkNjllMGU1NzQ0M2UifQ.QJUR3SYFrEIlbfOKjO0NYInddklytbJ2LSWNpkQ1aNThgneDCVCjIYGCaL2C9Sw-GR8j7QSUsKOwBbjZMUmVPFTjsfB4wdgObbxVt1QAXwDjAXc5w1smOerRsoahZ4yKI1an6PTaFxMwnoXUQcBZTsOS6RgXOCPPPoxibxohxoehPLieM0l7LYcF5DQKg7fTxZYOpmtiP--nibJxomXdVQNLSnZuQwnyWtlp_gYmqrYMMN1LPSmNCgZMZZZIYttaaAIA96SylglqubowJRShtDO9rSvUz_sgeCo7qo5Bfb0B5n9_PtIlr1CZSVoHyYj2lVqQldx7fnGuqqQJCfDQog \ No newline at end of file diff --git a/spec/integration/api/user_info_controller_spec.rb b/spec/integration/api/user_info_controller_spec.rb new file mode 100644 index 000000000..1f237a172 --- /dev/null +++ b/spec/integration/api/user_info_controller_spec.rb @@ -0,0 +1,21 @@ +require "spec_helper" +describe Api::OpenidConnect::UserInfoController do + let!(:auth_with_read_and_ppid) { FactoryGirl.create(:auth_with_read_and_ppid) } + let!(:access_token_with_read) { auth_with_read_and_ppid.create_access_token.to_s } + + describe "#show" do + before do + @user = auth_with_read_and_ppid.user + get api_openid_connect_user_info_path, access_token: access_token_with_read + end + + it "shows the info" do + json_body = JSON.parse(response.body) + expected_sub = + @user.pairwise_pseudonymous_identifiers.find_or_create_by(identifier: "https://example.com/uri").guid + expect(json_body["sub"]).to eq(expected_sub) + expect(json_body["nickname"]).to eq(@user.name) + expect(json_body["profile"]).to eq(File.join(AppConfig.environment.url, "people", @user.guid).to_s) + end + end +end diff --git a/spec/lib/api/openid_connect/protected_resource_endpoint_spec.rb b/spec/lib/api/openid_connect/protected_resource_endpoint_spec.rb new file mode 100644 index 000000000..4c9035ece --- /dev/null +++ b/spec/lib/api/openid_connect/protected_resource_endpoint_spec.rb @@ -0,0 +1,86 @@ +require "spec_helper" + +describe Api::OpenidConnect::ProtectedResourceEndpoint, type: :request do + let(:auth_with_read) { FactoryGirl.create(:auth_with_read) } + let!(:access_token_with_read) { auth_with_read.create_access_token.to_s } + let!(:expired_access_token) do + access_token = auth_with_read.o_auth_access_tokens.create! + access_token.expires_at = Time.zone.now - 100 + access_token.save + access_token.bearer_token.to_s + end + let(:invalid_token) { SecureRandom.hex(32).to_s } + + context "when valid access token is provided" do + before do + get api_openid_connect_user_info_path, access_token: access_token_with_read + end + + it "includes private in the cache-control header" do + expect(response.headers["Cache-Control"]).to include("private") + end + end + + context "when access token is expired" do + before do + get api_openid_connect_user_info_path, access_token: expired_access_token + end + + it "should respond with a 401 Unauthorized response" do + expect(response.status).to be(401) + end + it "should have an auth-scheme value of Bearer" do + expect(response.headers["WWW-Authenticate"]).to include("Bearer") + end + end + + context "when no access token is provided" do + before do + get api_openid_connect_user_info_path + end + + it "should respond with a 401 Unauthorized response" do + expect(response.status).to be(401) + end + it "should have an auth-scheme value of Bearer" do + expect(response.headers["WWW-Authenticate"]).to include("Bearer") + end + end + + context "when an invalid access token is provided" do + before do + get api_openid_connect_user_info_path, access_token: invalid_token + end + + it "should respond with a 401 Unauthorized response" do + expect(response.status).to be(401) + end + + it "should have an auth-scheme value of Bearer" do + expect(response.headers["WWW-Authenticate"]).to include("Bearer") + end + + it "should contain an invalid_token error" do + expect(response.body).to include("invalid_token") + end + end + + context "when authorization has been destroyed" do + before do + auth_with_read.destroy + get api_openid_connect_user_info_path, access_token: access_token_with_read + end + + it "should respond with a 401 Unauthorized response" do + expect(response.status).to be(401) + end + + it "should have an auth-scheme value of Bearer" do + expect(response.headers["WWW-Authenticate"]).to include("Bearer") + end + + it "should contain an invalid_token error" do + expect(response.body).to include("invalid_token") + end + end +end diff --git a/spec/lib/api/openid_connect/token_endpoint_spec.rb b/spec/lib/api/openid_connect/token_endpoint_spec.rb new file mode 100644 index 000000000..37eba5380 --- /dev/null +++ b/spec/lib/api/openid_connect/token_endpoint_spec.rb @@ -0,0 +1,246 @@ +require "spec_helper" + +describe Api::OpenidConnect::TokenEndpoint, type: :request do + let!(:client) { FactoryGirl.create(:o_auth_application_with_ppid) } + let!(:auth) { + Api::OpenidConnect::Authorization.find_or_create_by( + o_auth_application: client, user: bob, redirect_uri: "http://localhost:3000/", scopes: ["openid"]) + } + let!(:code) { auth.create_code } + let!(:client_with_specific_id) { FactoryGirl.create(:o_auth_application_with_ppid_with_specific_id) } + let!(:auth_with_specific_id) do + client_with_specific_id.client_id = "14d692cd53d9c1a9f46fd69e0e57443e" + client_with_specific_id.jwks = File.read(jwks_file_path) + client_with_specific_id.save! + Api::OpenidConnect::Authorization.find_or_create_by( + o_auth_application: client_with_specific_id, + user: bob, redirect_uri: "http://localhost:3000/", scopes: ["openid"]) + end + let!(:code_with_specific_id) { auth_with_specific_id.create_code } + + describe "the authorization code grant type" do + context "when the authorization code is valid" do + before do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + client_id: client.client_id, client_secret: client.client_secret, + redirect_uri: "http://localhost:3000/", code: code + end + + it "should return a valid id token" do + json = JSON.parse(response.body) + encoded_id_token = json["id_token"] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expected_guid = bob.pairwise_pseudonymous_identifiers.find_by(identifier: "https://example.com/uri").guid + expect(decoded_token.sub).to eq(expected_guid) + expect(decoded_token.exp).to be > Time.zone.now.utc.to_i + end + + it "should return an id token with a kid" do + json = JSON.parse(response.body) + encoded_id_token = json["id_token"] + kid = JSON::JWT.decode(encoded_id_token, :skip_verification).header[:kid] + expect(kid).to eq("default") + end + + it "should return a valid access token" do + json = JSON.parse(response.body) + encoded_id_token = json["id_token"] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + access_token = json["access_token"] + access_token_check_num = UrlSafeBase64.encode64(OpenSSL::Digest::SHA256.digest(access_token)[0, 128 / 8]) + expect(decoded_token.at_hash).to eq(access_token_check_num) + end + + it "should not allow code to be reused" do + auth.reload + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + client_id: client.client_id, client_secret: client.client_secret, + redirect_uri: "http://localhost:3000/", code: code + expect(JSON.parse(response.body)["error"]).to eq("invalid_grant") + end + + it "should not allow a nil code" do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + client_id: client.client_id, client_secret: client.client_secret, + redirect_uri: "http://localhost:3000/", code: nil + expect(JSON.parse(response.body)["error"]).to eq("invalid_request") + end + end + + context "when the authorization code is valid with jwt bearer" do + before do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/", code: code_with_specific_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: File.read(valid_client_assertion_path) + end + + it "should return a valid id token" do + json = JSON.parse(response.body) + encoded_id_token = json["id_token"] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + expected_guid = bob.pairwise_pseudonymous_identifiers.find_by(identifier: "https://example.com/uri").guid + expect(decoded_token.sub).to eq(expected_guid) + expect(decoded_token.exp).to be > Time.zone.now.utc.to_i + end + + it "should return a valid access token" do + json = JSON.parse(response.body) + encoded_id_token = json["id_token"] + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode encoded_id_token, + Api::OpenidConnect::IdTokenConfig::PUBLIC_KEY + access_token = json["access_token"] + access_token_check_num = UrlSafeBase64.encode64(OpenSSL::Digest::SHA256.digest(access_token)[0, 128 / 8]) + expect(decoded_token.at_hash).to eq(access_token_check_num) + end + + it "should not allow code to be reused" do + auth_with_specific_id.reload + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + client_id: client.client_id, client_secret: client.client_secret, + redirect_uri: "http://localhost:3000/", code: code_with_specific_id + expect(JSON.parse(response.body)["error"]).to eq("invalid_grant") + end + end + + context "when the authorization code is not valid" do + it "should return an invalid grant error" do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + client_id: client.client_id, client_secret: client.client_secret, code: "123456" + expect(response.body).to include "invalid_grant" + end + end + + context "when the client assertion is in an invalid format" do + before do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/", code: code_with_specific_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: "invalid_client_assertion.random" + end + + it "should return an error" do + expect(response.body).to include "invalid_request" + end + end + + context "when the client assertion is not matching with jwks keys" do + before do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/", code: code_with_specific_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: File.read(client_assertion_with_tampered_sig_path) + end + + it "should return an error" do + expect(response.body).to include "invalid_grant" + end + end + + context "when kid doesn't exist in jwks keys" do + before do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/", code: code_with_specific_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: File.read(client_assertion_with_nonexistent_kid_path) + end + + it "should return an error" do + expect(response.body).to include "invalid_request" + end + end + + context "when the client is unregistered" do + it "should return an error" do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", code: auth.refresh_token, + client_id: SecureRandom.hex(16).to_s, client_secret: client.client_secret + expect(response.body).to include "invalid_client" + end + end + + context "when the client is unregistered with jwks keys" do + before do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/", code: code_with_specific_id, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: File.read(client_assertion_with_nonexistent_client_id_path) + end + + it "should return an error" do + expect(response.body).to include "invalid_request" + end + end + + context "when the code field is missing" do + it "should return an invalid request error" do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", + client_id: client.client_id, client_secret: client.client_secret + expect(response.body).to include "invalid_request" + end + end + + context "when the client_secret doesn't match" do + it "should return an invalid client error" do + post api_openid_connect_access_tokens_path, grant_type: "authorization_code", code: auth.refresh_token, + client_id: client.client_id, client_secret: "client.client_secret" + expect(response.body).to include "invalid_client" + end + end + end + + describe "an unsupported grant type" do + it "should return an unsupported grant type error" do + post api_openid_connect_access_tokens_path, grant_type: "noexistgrant", username: "bob", + password: "bluepin7", client_id: client.client_id, client_secret: client.client_secret, scope: "read" + expect(response.body).to include "unsupported_grant_type" + end + end + + describe "the refresh token grant type" do + context "when the refresh token is valid" do + it "should return an access token" do + post api_openid_connect_access_tokens_path, grant_type: "refresh_token", + client_id: client.client_id, client_secret: client.client_secret, refresh_token: auth.refresh_token + json = JSON.parse(response.body) + expect(response.body).to include "expires_in" + expect(json["access_token"].length).to eq(64) + expect(json["token_type"]).to eq("bearer") + end + end + + context "when the refresh token is not valid" do + it "should return an invalid grant error" do + post api_openid_connect_access_tokens_path, grant_type: "refresh_token", + client_id: client.client_id, client_secret: client.client_secret, refresh_token: "123456" + expect(response.body).to include "invalid_grant" + end + end + + context "when the client is unregistered" do + it "should return an error" do + post api_openid_connect_access_tokens_path, grant_type: "refresh_token", refresh_token: auth.refresh_token, + client_id: SecureRandom.hex(16).to_s, client_secret: client.client_secret + expect(response.body).to include "invalid_client" + end + end + + context "when the refresh_token field is missing" do + it "should return an invalid request error" do + post api_openid_connect_access_tokens_path, grant_type: "refresh_token", + client_id: client.client_id, client_secret: client.client_secret + expect(response.body).to include "'refresh_token' required" + end + end + + context "when the client_secret doesn't match" do + it "should return an invalid client error" do + post api_openid_connect_access_tokens_path, grant_type: "refresh_token", refresh_token: auth.refresh_token, + client_id: client.client_id, client_secret: "client.client_secret" + expect(response.body).to include "invalid_client" + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 586ec2824..201f98826 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -59,6 +59,29 @@ def photo_fixture_name @photo_fixture_name = File.join(File.dirname(__FILE__), "fixtures", "button.png") end +def jwks_file_path + @jwks_file = File.join(File.dirname(__FILE__), "fixtures", "jwks.json") +end + +def valid_client_assertion_path + @valid_client_assertion = File.join(File.dirname(__FILE__), "fixtures", "valid_client_assertion.txt") +end + +def client_assertion_with_tampered_sig_path + @client_assertion_with_tampered_sig = File.join(File.dirname(__FILE__), "fixtures", + "client_assertion_with_tampered_sig.txt") +end + +def client_assertion_with_nonexistent_kid_path + @client_assertion_with_nonexistent_kid = File.join(File.dirname(__FILE__), "fixtures", + "client_assertion_with_nonexistent_kid.txt") +end + +def client_assertion_with_nonexistent_client_id_path + @client_assertion_with_nonexistent_client_id = File.join(File.dirname(__FILE__), "fixtures", + "client_assertion_with_nonexistent_client_id.txt") +end + # Force fixture rebuild FileUtils.rm_f(Rails.root.join("tmp", "fixture_builder.yml"))