diff --git a/app/adapters/abstract_adapter.rb b/app/adapters/abstract_adapter.rb index b851f6c8..628f7e8e 100644 --- a/app/adapters/abstract_adapter.rb +++ b/app/adapters/abstract_adapter.rb @@ -203,7 +203,7 @@ def oauth_client def fetch_oidc_discovery response = http_client.get(well_known_url) - config = AbstractAdapter.parse_response(response) + config = response.ok? && AbstractAdapter.parse_response(response) case config when ->(obj) { obj.respond_to?(:[]) } then config diff --git a/app/adapters/rest_adapter.rb b/app/adapters/rest_adapter.rb index 22ac20e2..eacfbdfb 100644 --- a/app/adapters/rest_adapter.rb +++ b/app/adapters/rest_adapter.rb @@ -31,6 +31,12 @@ def test parse http_client.get(oidc.well_known_url, header: headers) end + def authentication + super + rescue OIDC::AuthenticationError + nil + end + # The Client entity. Mapping the OpenID Connect Client Metadata representation. # https://tools.ietf.org/html/rfc7591#section-2 class Client diff --git a/examples/rest-api/.gitignore b/examples/rest-api/.gitignore new file mode 100644 index 00000000..79f33806 --- /dev/null +++ b/examples/rest-api/.gitignore @@ -0,0 +1 @@ +clients.yml diff --git a/examples/rest-api/Gemfile b/examples/rest-api/Gemfile new file mode 100644 index 00000000..c7a4cfbb --- /dev/null +++ b/examples/rest-api/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true +source 'https://rubygems.org' + +gem 'sinatra' + +gem 'pry-byebug' diff --git a/examples/rest-api/Gemfile.lock b/examples/rest-api/Gemfile.lock new file mode 100644 index 00000000..62eff627 --- /dev/null +++ b/examples/rest-api/Gemfile.lock @@ -0,0 +1,32 @@ +GEM + remote: https://rubygems.org/ + specs: + byebug (11.0.1) + coderay (1.1.2) + method_source (0.9.2) + mustermann (1.0.3) + pry (0.12.2) + coderay (~> 1.1.0) + method_source (~> 0.9.0) + pry-byebug (3.7.0) + byebug (~> 11.0) + pry (~> 0.10) + rack (2.0.7) + rack-protection (2.0.5) + rack + sinatra (2.0.5) + mustermann (~> 1.0) + rack (~> 2.0) + rack-protection (= 2.0.5) + tilt (~> 2.0) + tilt (2.0.9) + +PLATFORMS + ruby + +DEPENDENCIES + pry-byebug + sinatra + +BUNDLED WITH + 2.0.1 diff --git a/examples/rest-api/README.md b/examples/rest-api/README.md new file mode 100644 index 00000000..a85f2fe9 --- /dev/null +++ b/examples/rest-api/README.md @@ -0,0 +1,58 @@ +# Zync REST API example + +This example project implements Zync's REST API protocol to synchronize OAuth2 clients. + +## Prerequisites + +Given 3scale API is configured to use: + * OpenID Connect as the Authentication, + * "REST API" as a OpenID Connect Issuer Type and + * "http://id:secret@example.com/api" as OpenID Connect Issuer. + +When a 3scale application is created/updated/deleted zync will try to replay that change to "http://example.com/api". + +## Creating, updating and deleting Clients + +Zync will make following requests to create/update/delete clients: + +* `PUT /clients/:client_id` (create, update) +* `DELETE /clients/:client_id` (delete) + +All endpoints must reply 2xx status code. Otherwise the request will be retried. + +### Payload + +The request payload in case of create and update is `application/json`: + +```json +{ + "client_id": "ee305610", + "client_secret": "ac0e42db426b4377096c6590e2b06aed", + "client_name": "oidc-app", + "redirect_uris": ["http://example.com"], + "grant_types": ["client_credentials", "password"] +} +``` + +The request to delete a client has no payload. + +## Using OAuth2 authentication + +Zync will make GET request to `/.well-known/openid-configuration` endpoint and expect an `application/json` response. +The response payload should contain following: + +```json +{ + "token_endpoint": "http://idp.example.com/auth/realm/token" +} +``` + +Zync will use that `token_endpoint` URL to exchange the client_id and client_secret provided in the OpenID Connect Issuer URL +for an access token using the OAuth2 protocol. + +If the API responds with not successful response, Zync will fallback to HTTP Basic/Digest authentication using provided credentials. + +## References + +* OpenAPI Specification document [openapi.yml](openapi.yml) +* Sinatra application [app.rb](app.rb) diff --git a/examples/rest-api/app.rb b/examples/rest-api/app.rb new file mode 100644 index 00000000..8299d005 --- /dev/null +++ b/examples/rest-api/app.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'sinatra' +require 'json' +require 'yaml/store' + +$store = YAML::Store.new('clients.yml') +$basic_auth = Rack::Auth::Basic.new(->(_) { [] }, 'REST API') do |username, password| + username.length > 0 && password.length > 0 +end + +def json(object) + headers 'Content-Type' => 'application/json' + body JSON(object) +end + +get '/.well-known/openid-configuration' do + # point zync where to exchange the OAuth2 access token + json({ token_endpoint: 'https://example.com/auth/realms/master/protocol/openid-connect/token' }) +end + +put '/clients/:client_id' do |client_id| + # {"client_id"=>"ee305610", + # "client_secret"=>"ac0e42db426b4377096c6590e2b06aed", + # "client_name"=>"oidc-app", + # "redirect_uris"=>["http://example.com"], + # "grant_types"=>["client_credentials", "password"]} + client = JSON.parse(request.body.read) + + # store the client + $store.transaction do + $store[client_id] = client + end + + json(client) +end + +delete '/clients/:client_id' do |client_id| + # Request HTTP Basic authentication + if (status, headers, body = $basic_auth.call(env)) + self.headers headers + error status, body + end + + client = nil + + # remove the client + $store.transaction do + client = $store[client_id] + $store.delete(client_id) + end + + json(client) +end diff --git a/examples/rest-api/config.ru b/examples/rest-api/config.ru new file mode 100644 index 00000000..221725af --- /dev/null +++ b/examples/rest-api/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative 'app' + +run Sinatra::Application diff --git a/examples/rest-api/openapi.yml b/examples/rest-api/openapi.yml new file mode 100644 index 00000000..40f1c3b1 --- /dev/null +++ b/examples/rest-api/openapi.yml @@ -0,0 +1,146 @@ +--- +openapi: 3.0.2 +info: + title: Zync REST API + version: 1.0.0 +paths: + /clients/{clientId}: + get: + summary: Get a Client + operationId: readClient + parameters: + - name: clientId + in: path + description: client_id + required: true + schema: + type: string + responses: + 200: + description: Client resource was found. + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + security: + - OIDC: [] + Basic: [] + Digest: [] + put: + summary: Create or update the Client + operationId: saveClient + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + examples: + Client: + value: + client_id: foo-bar + client_secret: some-secret + required: true + responses: + 200: + description: When the Client was updated. + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + 201: + description: When the Client was created on the IDP. + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + delete: + summary: Delete the Client + operationId: deleteClient + responses: + 200: + description: When the client was deleted. + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + 204: + description: When the Client was already gone from the IDP. + content: + application/json: {} + parameters: + - name: clientId + in: path + required: true + /.well-known/openid-configuration: + get: + responses: + 200: + description: Enable OAuth2.0 authentication by responding with a token endpoint + of an IDP. + content: + application/json: + schema: + $ref: '#/components/schemas/OIDC' + examples: + Enable OAuth2.0 authentication: + value: + token_endpoint: https://idp.example.com/auth/realms/myrealm + security: + - {} +components: + schemas: + Client: + title: Root Type for Client + description: A Client representation. + required: [] + type: object + properties: + client_id: + type: string + client_secret: + type: string + client_name: + type: string + redirect_uris: + description: A list of allowed redirect uris. + type: array + items: + type: string + grant_types: + description: A list of allowed grant types. + type: array + items: + type: string + example: |- + { + "client_id": "foo-bar", + "client_secret": "some-secret" + } + OIDC: + title: Root Type for OIDC + description: OpenID Connect Configuration to define where to get access token. + type: object + properties: + token_endpoint: + type: string + example: |- + { + "token_endpoint": "https://idp.example.com/auth/realms/myrealm" + } + securitySchemes: + OIDC: + type: openIdConnect + description: |- + Use OpenID Connect for authentication. + Zync will try to access `/.well-known/openid-configuration` and use "token_endpoint" property from the JSON response. + Then it will exchange its' credentials for an access token and will use that access token to access this API. + Basic: + type: http + description: Zync will try to send provided credentials as HTTP Basic authentication + in case it gets a 401 response with proper WWW-Authenticate header. + scheme: basic + Digest: + type: http + description: Zync will try to send provided credentials as HTTP Basic authentication + in case it gets a 401 response with proper WWW-Authenticate header. + scheme: digest diff --git a/test/adapters/abstract_adapter_test.rb b/test/adapters/abstract_adapter_test.rb index 66b8e7db..e00332a0 100644 --- a/test/adapters/abstract_adapter_test.rb +++ b/test/adapters/abstract_adapter_test.rb @@ -29,4 +29,13 @@ class AbstractAdapterTest < ActiveSupport::TestCase assert_kind_of subject, subject.new('http://id:secret@example.com') end end + + test 'oidc discovery' do + stub_request(:get, "https://example.com/.well-known/openid-configuration"). + to_return(status: 404, body: '', headers: {}) + + assert_raises AbstractAdapter::OIDC::AuthenticationError do + assert_nil subject.new('https://example.com').authentication + end + end end diff --git a/test/adapters/rest_adapter_test.rb b/test/adapters/rest_adapter_test.rb new file mode 100644 index 00000000..fa91acfc --- /dev/null +++ b/test/adapters/rest_adapter_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'test_helper' + +class RESTAdapterTest < ActiveSupport::TestCase + class_attribute :subject, default: RESTAdapter + + test 'oidc discovery' do + stub_request(:get, "https://example.com/.well-known/openid-configuration"). + to_return(status: 404, body: '', headers: {}) + + assert_nil subject.new('https://example.com').authentication + end + + test 'create client without auth' do + client = RESTAdapter::Client.new(id: 'foo') + + stub_request(:get, "https://example.com/.well-known/openid-configuration"). + to_return(status: 404, body: '', headers: {}) + + stub_request(:put, "https://example.com/clients/foo"). + with( + body: client.to_json, + headers: { 'Content-Type'=>'application/json' }). + to_return(status: 200, body: { status: 'ok' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + assert subject.new('https://example.com').create_client(client) + end + + test 'create client with basic auth' do + client = RESTAdapter::Client.new(id: 'foo') + adapter = subject.new('https://user:pass@example.com') + # WebMock does not support request retries on 401 status + adapter.send(:http_client).force_basic_auth = true + + stub_request(:get, "https://example.com/.well-known/openid-configuration"). + to_return(status: 404, body: '', headers: {}) + + stub_request(:put, 'https://example.com/clients/foo'). + with( + basic_auth: %w[user pass], + body: client.to_json, + headers: { 'Content-Type'=>'application/json' }). + to_return(status: 200, body: { status: 'ok' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + assert adapter.create_client(client) + end +end