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"))