Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

REST API integration example #212

Merged
merged 3 commits into from
Jul 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/adapters/abstract_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/adapters/rest_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/rest-api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
clients.yml
6 changes: 6 additions & 0 deletions examples/rest-api/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
source 'https://rubygems.org'

gem 'sinatra'

gem 'pry-byebug'
32 changes: 32 additions & 0 deletions examples/rest-api/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions examples/rest-api/README.md
Original file line number Diff line number Diff line change
@@ -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:
mikz marked this conversation as resolved.
Show resolved Hide resolved
* OpenID Connect as the Authentication,
mikz marked this conversation as resolved.
Show resolved Hide resolved
* "REST API" as a OpenID Connect Issuer Type and
mikz marked this conversation as resolved.
Show resolved Hide resolved
* "http://id:secret@example.com/api" as OpenID Connect Issuer.
mikz marked this conversation as resolved.
Show resolved Hide resolved

When a 3scale application is created/updated/deleted zync will try to replay that change to "http://example.com/api".
mikz marked this conversation as resolved.
Show resolved Hide resolved

## Creating, updating and deleting Clients

Zync will make following requests to create/update/delete clients:
mikz marked this conversation as resolved.
Show resolved Hide resolved

* `PUT /clients/:client_id` (create, update)
* `DELETE /clients/:client_id` (delete)

All endpoints must reply 2xx status code. Otherwise the request will be retried.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikz is there any limit on number of period of retries?

What if the DELETE responds with 404? I believe it can cause an infinite loop of trying to delete the non-existing client, e.g. https://issues.jboss.org/browse/THREESCALE-2206

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then it should respond with 200 that the client is gone. It has exponential backoff and yes, there is some limit. It is the same for every object.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikz Can you please point me to the place where this is configured/set?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that for most tasks we are using the default https://github.com/chanks/que/blob/master/docs/error_handling.md#error-handling

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikz thanks for the info!


### Payload
mikz marked this conversation as resolved.
Show resolved Hide resolved

The request payload in case of create and update is `application/json`:
mikz marked this conversation as resolved.
Show resolved Hide resolved

```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.
mikz marked this conversation as resolved.
Show resolved Hide resolved
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
mikz marked this conversation as resolved.
Show resolved Hide resolved
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.
mikz marked this conversation as resolved.
Show resolved Hide resolved

## References

* OpenAPI Specification document [openapi.yml](openapi.yml)
* Sinatra application [app.rb](app.rb)
54 changes: 54 additions & 0 deletions examples/rest-api/app.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions examples/rest-api/config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

require_relative 'app'

run Sinatra::Application
146 changes: 146 additions & 0 deletions examples/rest-api/openapi.yml
Original file line number Diff line number Diff line change
@@ -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.
mikz marked this conversation as resolved.
Show resolved Hide resolved
content:
application/json:
schema:
$ref: '#/components/schemas/Client'
201:
description: When the Client was created on the IDP.
mikz marked this conversation as resolved.
Show resolved Hide resolved
content:
application/json:
schema:
$ref: '#/components/schemas/Client'
delete:
summary: Delete the Client
operationId: deleteClient
responses:
200:
description: When the client was deleted.
mikz marked this conversation as resolved.
Show resolved Hide resolved
content:
application/json:
schema:
$ref: '#/components/schemas/Client'
204:
description: When the Client was already gone from the IDP.
mikz marked this conversation as resolved.
Show resolved Hide resolved
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.
mikz marked this conversation as resolved.
Show resolved Hide resolved
Then it will exchange its' credentials for an access token and will use that access token to access this API.
mikz marked this conversation as resolved.
Show resolved Hide resolved
Basic:
type: http
description: Zync will try to send provided credentials as HTTP Basic authentication
mikz marked this conversation as resolved.
Show resolved Hide resolved
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
mikz marked this conversation as resolved.
Show resolved Hide resolved
in case it gets a 401 response with proper WWW-Authenticate header.
scheme: digest
9 changes: 9 additions & 0 deletions test/adapters/abstract_adapter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions test/adapters/rest_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -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