From e5932968fddc4db07ffd7aae92673df0e0e98a76 Mon Sep 17 00:00:00 2001 From: theworldbright Date: Fri, 31 Jul 2015 01:35:50 +0900 Subject: [PATCH] Add support for authorization code flow --- .../api/openid_connect/authorization.rb | 15 +++- .../api/openid_connect/o_auth_application.rb | 2 +- .../20150708153926_create_authorizations.rb | 2 + db/schema.rb | 2 + features/desktop/oidc_auth_code_flow.feature | 27 +++++++ features/step_definitions/auth_code_steps.rb | 32 +++++++++ .../endpoint_confirmation_point.rb | 14 +++- lib/api/openid_connect/token_endpoint.rb | 8 +++ .../authorizations_controller_spec.rb | 40 ++++++++++- .../api/openid_connect/token_endpoint_spec.rb | 70 ++++++++++++++++++- 10 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 features/desktop/oidc_auth_code_flow.feature create mode 100644 features/step_definitions/auth_code_steps.rb diff --git a/app/models/api/openid_connect/authorization.rb b/app/models/api/openid_connect/authorization.rb index 88a511fbe..90a225845 100644 --- a/app/models/api/openid_connect/authorization.rb +++ b/app/models/api/openid_connect/authorization.rb @@ -15,6 +15,8 @@ module Api before_validation :setup, on: :create + scope :with_redirect_uri, ->(given_uri) { where redirect_uri: given_uri } + def setup self.refresh_token = SecureRandom.hex(32) end @@ -25,12 +27,18 @@ module Api end end + def create_code + self.code = SecureRandom.hex(32) + save + self.code + end + def create_access_token o_auth_access_tokens.create!.bearer_token # TODO: Add support for request object end - def create_id_token(nonce) + def create_id_token(nonce=nil) id_tokens.create!(nonce: nonce) end @@ -44,6 +52,11 @@ module Api o_auth_applications: {client_id: client_id}, refresh_token: refresh_token) end + def self.use_code(code) + auth = find_by(code: code) + auth.code = nil if auth # Remove auth code if found so it can't be reused + auth + end # TODO: Consider splitting into subclasses by flow type end end diff --git a/app/models/api/openid_connect/o_auth_application.rb b/app/models/api/openid_connect/o_auth_application.rb index 2a97665a2..3cc2dadbc 100644 --- a/app/models/api/openid_connect/o_auth_application.rb +++ b/app/models/api/openid_connect/o_auth_application.rb @@ -30,7 +30,7 @@ module Api class << self def available_response_types - ["id_token", "id_token token"] + ["id_token", "id_token token", "code"] end def register!(registrar) diff --git a/db/migrate/20150708153926_create_authorizations.rb b/db/migrate/20150708153926_create_authorizations.rb index 24acdc081..c99fa0e85 100644 --- a/db/migrate/20150708153926_create_authorizations.rb +++ b/db/migrate/20150708153926_create_authorizations.rb @@ -4,6 +4,8 @@ class CreateAuthorizations < ActiveRecord::Migration 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.timestamps null: false end diff --git a/db/schema.rb b/db/schema.rb index dfaf0ed3b..21bb99e43 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -67,6 +67,8 @@ ActiveRecord::Schema.define(version: 20150724152052) do 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.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/features/desktop/oidc_auth_code_flow.feature b/features/desktop/oidc_auth_code_flow.feature new file mode 100644 index 000000000..49dd10fe6 --- /dev/null +++ b/features/desktop/oidc_auth_code_flow.feature @@ -0,0 +1,27 @@ +@javascript +Feature: Access protected resources using auth code flow + Background: + Given a user with username "kent" + And all scopes exist + + 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 an "bad_request" error + + 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/step_definitions/auth_code_steps.rb b/features/step_definitions/auth_code_steps.rb new file mode 100644 index 000000000..8f690a23d --- /dev/null +++ b/features/step_definitions/auth_code_steps.rb @@ -0,0 +1,32 @@ +o_auth_query_params = %i( + redirect_uri=http://localhost:3000 + response_type=code + scope=openid%20read + nonce=hello + state=hi +).join("&") + +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['o_auth_application']['client_id'] + @client_secret = client_json['o_auth_application']['client_secret'] + visit new_api_openid_connect_authorization_path + + "?client_id=#{@client_id}&#{o_auth_query_params}" +end + +Given /^I send a post request from that client to the code flow authorization endpoint using a invalid client id/ do + visit new_api_openid_connect_authorization_path + "?client_id=randomid&#{o_auth_query_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"] + get api_v0_user_path, access_token: access_token +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 index a500b9dcc..e15053e0e 100644 --- a/lib/api/openid_connect/authorization_point/endpoint_confirmation_point.rb +++ b/lib/api/openid_connect/authorization_point/endpoint_confirmation_point.rb @@ -19,9 +19,10 @@ module Api end end - # TODO: Add support for request object and auth code + # TODO: Add support for request object def approved!(req, res) - auth = OpenidConnect::Authorization.find_or_create_by(o_auth_application: @o_auth_application, user: @user) + auth = OpenidConnect::Authorization.find_or_create_by( + o_auth_application: @o_auth_application, user: @user, redirect_uri: @redirect_uri) auth.scopes << @scopes handle_approved_response_type(auth, req, res) res.approve! @@ -29,10 +30,16 @@ module Api 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, req, 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 @@ -41,8 +48,9 @@ module Api def handle_approved_id_token(auth, req, res, response_types) return unless response_types.include?(:id_token) id_token = auth.create_id_token(req.nonce) + auth_code_value = res.respond_to?(:code) ? res.code : nil access_token_value = res.respond_to?(:access_token) ? res.access_token : nil - res.id_token = id_token.to_jwt(code: nil, access_token: access_token_value) + res.id_token = id_token.to_jwt(code: auth_code_value, access_token: access_token_value) end end end diff --git a/lib/api/openid_connect/token_endpoint.rb b/lib/api/openid_connect/token_endpoint.rb index eb8937436..7ae0e7dbd 100644 --- a/lib/api/openid_connect/token_endpoint.rb +++ b/lib/api/openid_connect/token_endpoint.rb @@ -21,6 +21,14 @@ module Api handle_password_flow(o_auth_app, req, res) 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?(Api::OpenidConnect::Scope.find_by(name: "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 diff --git a/spec/controllers/api/openid_connect/authorizations_controller_spec.rb b/spec/controllers/api/openid_connect/authorizations_controller_spec.rb index 9719f9015..a8d5e3405 100644 --- a/spec/controllers/api/openid_connect/authorizations_controller_spec.rb +++ b/spec/controllers/api/openid_connect/authorizations_controller_spec.rb @@ -49,7 +49,7 @@ describe Api::OpenidConnect::AuthorizationsController, type: :controller do context "when redirect uri is missing" do context "when only one redirect URL is pre-registered" do - it "should return a form pager" 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. @@ -94,7 +94,8 @@ describe Api::OpenidConnect::AuthorizationsController, type: :controller do end end context "when already authorized" do - let!(:auth) { Api::OpenidConnect::Authorization.find_or_create_by(o_auth_application: client, user: alice) } + let!(:auth) { Api::OpenidConnect::Authorization.find_or_create_by(o_auth_application: client, user: alice, + redirect_uri: "http://localhost:3000/") } context "when valid parameters are passed" do before do @@ -188,5 +189,40 @@ describe Api::OpenidConnect::AuthorizationsController, type: :controller do 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 end diff --git a/spec/lib/api/openid_connect/token_endpoint_spec.rb b/spec/lib/api/openid_connect/token_endpoint_spec.rb index 21eda5e87..85219cb00 100644 --- a/spec/lib/api/openid_connect/token_endpoint_spec.rb +++ b/spec/lib/api/openid_connect/token_endpoint_spec.rb @@ -3,14 +3,78 @@ require "spec_helper" describe Api::OpenidConnect::TokenEndpoint, type: :request do let!(:client) do Api::OpenidConnect::OAuthApplication.create!( - redirect_uris: ["http://localhost"], client_name: "diaspora client") + redirect_uris: ["http://localhost:3000/"], client_name: "diaspora client") end - let(:auth) { Api::OpenidConnect::Authorization.find_or_create_by(o_auth_application: client, user: bob) } + let!(:auth) { + Api::OpenidConnect::Authorization.find_or_create_by( + o_auth_application: client, user: bob, redirect_uri: "http://localhost:3000/") + } + let!(:code) { auth.create_code } before do Api::OpenidConnect::Scope.find_or_create_by(name: "read") end + 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 + 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 + 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 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 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 "the password grant type" do context "when the username field is missing" do it "should return an invalid request error" do @@ -92,7 +156,7 @@ describe Api::OpenidConnect::TokenEndpoint, type: :request do end end - describe "the refresh token flow" do + 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",