talis 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +2 -0
  5. data/.travis.yml +24 -0
  6. data/.yardopts +1 -0
  7. data/CONTRIBUTING.md +28 -0
  8. data/Gemfile +4 -0
  9. data/Guardfile +5 -0
  10. data/README.md +76 -0
  11. data/Rakefile +8 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/lib/talis.rb +25 -0
  15. data/lib/talis/analytics.rb +31 -0
  16. data/lib/talis/analytics/event.rb +67 -0
  17. data/lib/talis/authentication.rb +14 -0
  18. data/lib/talis/authentication/client.rb +82 -0
  19. data/lib/talis/authentication/login.rb +169 -0
  20. data/lib/talis/authentication/public_key.rb +53 -0
  21. data/lib/talis/authentication/token.rb +172 -0
  22. data/lib/talis/bibliography.rb +52 -0
  23. data/lib/talis/bibliography/ebook.rb +50 -0
  24. data/lib/talis/bibliography/manifestation.rb +141 -0
  25. data/lib/talis/bibliography/result_set.rb +34 -0
  26. data/lib/talis/bibliography/work.rb +164 -0
  27. data/lib/talis/constants.rb +9 -0
  28. data/lib/talis/errors.rb +10 -0
  29. data/lib/talis/errors/authentication_failed_error.rb +4 -0
  30. data/lib/talis/errors/client_errors.rb +19 -0
  31. data/lib/talis/errors/server_communication_error.rb +4 -0
  32. data/lib/talis/errors/server_error.rb +4 -0
  33. data/lib/talis/extensions/object.rb +11 -0
  34. data/lib/talis/feeds.rb +8 -0
  35. data/lib/talis/feeds/annotation.rb +129 -0
  36. data/lib/talis/feeds/feed.rb +58 -0
  37. data/lib/talis/hierarchy.rb +9 -0
  38. data/lib/talis/hierarchy/asset.rb +265 -0
  39. data/lib/talis/hierarchy/node.rb +200 -0
  40. data/lib/talis/hierarchy/resource.rb +159 -0
  41. data/lib/talis/oauth_service.rb +18 -0
  42. data/lib/talis/resource.rb +68 -0
  43. data/lib/talis/user.rb +112 -0
  44. data/lib/talis/version.rb +3 -0
  45. data/talis.gemspec +39 -0
  46. metadata +327 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e8926e08a42d050d4b27917867150635361762ac
4
+ data.tar.gz: 46819d589a371999e95e6a39a8747162244751ae
5
+ SHA512:
6
+ metadata.gz: 437793795a8103843222cef1ca78ac7b70381315e4a179059b98edf80bf4706e9e7ab30778a7c38c42c3cb1fe76e4461f16c82c44d5c976661438e0596785b11
7
+ data.tar.gz: 016d3734dbd71a3b643c4041e65edd683a7c3a40d0b4ef3e5826c737695f3f9e01577e02bd63d1b654a30046cd820f53c7203d13557930bf08ffdc8631ca8b6b
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .env
11
+ .idea/
12
+ *.gem
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,2 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.2
data/.travis.yml ADDED
@@ -0,0 +1,24 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.2.5
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.11.2
6
+ notifications:
7
+ hipchat:
8
+ on_pull_requests: false
9
+ rooms:
10
+ secure: KPz9FpSupvzP2ts8BOeAp0rZe+E5VyqbfzPX5Ho46+ctj41tppO2ufYFg2eBdKpBtA1d3KqgAGZaXWtH03rq7lp9NdgTjIzt2Y1qpv3lKTKLs3BPm4JUEBLJZ9Udzatp3W+sDlF3Or8cLgsgvZ4Jx9tehI4TlI4bQn3u7Iyy38Ssgzdw13U5p7SgFnOMU3i+/L6Ki1uzB5wVi0Nvf/5f8/MVRjqXLO0HyLxUzM1g7RtWxqd3B4SzpQS1jndfXuXD0bndlOSQxODz1D6umCk0H0tGtESzMQeMZi0EiKTTTlYyJhvw9U9xZrIdz+Ecd+Sgf3N5dIzKzi6xNZm/B+YONrhvMOClgyUFk0kzOScGASlGxIlgz6uMEc9L1kGB8Y72ezEPkr5RtCjC7+MCnnJN+J2ChWiqDnTRkjshStpKRbQxRx6f3Rj7iItJcfTx1iKOxGw53xoh5OaqPVkqvbVmEMsnObg9kVFpHeTKDXzbqEuOgPmNC/9y1b32DASCU3VIISr9nN1parEthgkYuVE4s50FU2IGHBIu+1ch7J2uKg8wkYGiLxAvZlKxRl/zUCjD/XndfTWa73mY0ykN0y18TJEHgvUrFcQfveetNmMLcYF1cqXhbcCRRG2b92+2ZD9wJf2RfmSys+p33Jvu7FGtnPs1ol+a/JWg/7WVGcgoEXU=
11
+ template:
12
+ - "%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}"
13
+ notify: true
14
+ env:
15
+ global:
16
+ - PERSONA_TEST_HOST: https://staging-users.talis.com
17
+ - BLUEPRINT_TEST_HOST: https://staging-hierarchy.talis.com
18
+ - METATRON_OAUTH_HOST: https://users.talis.com
19
+ - ECHO_TEST_HOST: https://staging-analytics.talis.com
20
+ - BABEL_TEST_HOST: https://staging-feeds.talis.com
21
+ - secure: BE2l30U/CLy50vFGV33JCu8t5sr3S0tjCVHcyvby9ChLj7SbceiL7oFhdInCfUFP5uOn9JoyAeULq0JSJK+j5tPsWVGXW0j1hJGvOjcDPA9gsvA4IpgH0WNs6eX5GZkYJIMioJB1MrRP8Hq6teUIC8fb51DQOkL8IRydD1qJsLOgebKfJHLB1hEXHUO0O4Mn3lZrJNjthZrNJG4bhBLvTnki4dmBu+vzEVBb8QMxpnNbBVUI8P3Uo4WlygOnirnW6naxBizHK7EAV6Hk221eTyarepe9u0CpWPXVG5XiHtedUDUU6SdT9XW0lgk26jC78qjf4+VbKIq7Xnmet9bO0K25A8A9ggwEbYv60KPhSeVbTlFt8a1cvthxmfhcgTBdX1JuZ+QbTt6Ex1Pq5efDM1XTKM9wDa1fEz2/+JTIAD6t6nOP8I45M7bRY/WyBXD1TNEU3XWDuJHRf9RlHgfALbc+GD4PHa6/QvWv6gmAAfeGMWbTnl/bDIk6zghfLcM9NhiZErz9V+AX7uQPlKSzqsz/WtjCthz4nFltNVisKftFLZieOR0QOJNDo10KPAbMnRk5R/5znlMd/zyH7C2oGlMCAjVK5GETJnRyw26/y0S5PWwxovf3p+IjXeNrRiHmsiSiVUK/e9EPiSya5n/GPRR9x9VoYEmo9fS88g8+Buo=
22
+ - secure: ndyBRplFd1IKVlabpPm/YxLKcD18MPflgtIjmhsEHzEQdvBlviwL7zXjpcISlrSLD5y7s4VMookrSXSWMMve4pFonjKUEOXS8yH8ARKe40i2PcdKuiGvD5DWKZXJvNfmOKoWFgG0uK/v8PJcniW/NyLjAJ915MAEvI0fBUjq4B6ZZYHd3oEIpDctDxN3jF/xK9058LCgT68IDAun16F+UnqdNdWj6I4BDM2ljhIZpsCYjVQAgPvYE85AYdJpPJUlO28MMj+iJzS2ZlubDngligtu4OWyDzTua8aez3V9OobPLNEfoCIW+a+9IiBM8QrUfomIUlccQ9j2B2u5i7n6w6KqfKi3PZd3jtkoG+nScWHnCCovTi8w9Dw5umiQrAr4y80XgQclWhi3HpTuzb/jKinec6Q7kXpdwIem+LEhX0Gp54azuJna51nw1Pf0sym3Ic096/SLgvxgtMi3JDUFu/VCHrHYnCAkjy42c7SKoMWzQ69dyTTK3+X32xasP8FT+F7CSEKbPS0D1zhpR9xpNDFmQkQklccBa9nY9CzFPe7YsvtaP7fW739lH8NfrD8faD7/BPoVIgX8/7ZKRKj1mXGDukvg4pizrd+U4O6iDkqQUfPkQE6+PLvdb2FZS+WjeR7xtXReH3dAk8D2sqZVoGl6nbDAKcxJUABKozPNl6s=
23
+ - secure: kfPQchN+t2fsbOPuxTYfpAwamguynuebLHwq/qD7dglz/aBhF8kPtBOQxtaMBfth1FQ+Xx0d73G9nW1TkIsCj1fYVZkNC2JhRojIUiKaKQoTj0anw04YOvvnX6AUnyx6+Sov8M5rf6EAprKKLh2lPXxXmrDtGxXVACA/EDyQNDYNeIy+TVJ1kzI8KQ//lbnc9EhISPgnYLe/HETi1v7abKm18pmIw5fATOszou2a/ziH1TW1Io8iX3Z1wUQ60WaXgL7+y/pEs6JHy3UXl3zNSYW5EeeloRmob71HlZ01xAQSLyqW8UUFnnCuxln23TmQXOEULMjy35kino+3wMDsx8GZCOA0YqZG4k+OXFsV5/gjgIEE7NzzYwbOzTx/us3B1Z9QIHe0S7k8HbFwnhD64Sma/Id++HhWUz+lBtJ/tbk5TyrEZwRxWC2vhWoyfAcepCTZYdRo76PAQlgjxywmdasRmTLUCPzrGBhwg2IHoHkmBFvMMVN6+mWwSJ8u5QC5lTuh4pXvHY4xMOZ/jw5wjQUucMGK2VTkRd6rveQcro7GkJMe6rvF3JTYqa6mjDt+CbZHX+17GQVmnX5D9a1/RhyRbYOsHtFv5ojiKIzBmVNxf3Nfmy3MtPu99NFhJNPostbaOSECCPfE7NJ1H/+cc2LdEaz5rghTpjdoE8bAIIM=
24
+ - secure: foSVBXRpbEdgD3Xvm8hy1KcsRLlqlAiH3Vwq6ZwvwWmvt/la8/iaDKOym0YNIL1hB+tMfRhHeKtD6ruPuag+MVbj8veE89pbzztHTinY/3J9TXoYNuC98xvEtWm3Vqgvb9XMvo1O0mN2jIlTRAdLmHC6So6/3tXWnfZLJrU+wfX5BuCHAuileUX08LX1GQGs1lCIh4r9G3PL7QzITzacI7PPw3tPkgTwAqwvsyS2g9E8nc+lGBLL6Y8VXo9F/63phRJf1fpK0obXJqwJluoiVO38QxlWa188aLBOCZjBoAA3p8gOcYlYoFT86RI6ZINdqqqbCKTqgCGtLEUIeVXNG0c4EPecoD2W7MUHNEwCl3Ee1o/QD2+4drPMQwWyQAKtdQTVKS+kwwW23DJDMiCWD882IL7LJ7DCZcx+5FgIEWdXJBr+ZGsQ0K6AIZRdiNe93GAr7cuZGJgF6u3vNLMD9g5VLA5WirxIJBoVF3WyEp7fLop0OqaCpwsjPo6t8tzhft9OXts3rmMHOFeJnOK2S3zwxGPb5YFDO7TEWqX3oDuI1Pzt7Rky0T0wEFQgyb+WMoijg+ic+H3yXOfKoOhymr2+UHO5vzTdfVbdXF2gR+atU5yv3mhZlLSc2tJdKErLQRQM5kSMIpJHFd8EEeC7XjJ+6zGho+EH3f4jwsLHNiM=
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --no-private
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,28 @@
1
+ Firstly, thank you for contributing! Please read our [Engineering Handbook]
2
+ (http://talis.github.io/) for some guidelines.
3
+
4
+ # Local Development
5
+
6
+ ## Setup
7
+
8
+ bundle install
9
+
10
+ Create a `.env` file and add the following:
11
+
12
+ PERSONA_TEST_HOST=<your local persona host>
13
+ PERSONA_OAUTH_CLIENT=<your local persona client ID (with su scope)>
14
+ PERSONA_OAUTH_SECRET=<your local persona client secret (with su scope)>
15
+ BLUEPRINT_TEST_HOST=<your local blueprint host>
16
+ METATRON_TEST_HOST=<your local metatron host>
17
+ METATRON_BASE_PATH=<if not a production env, use /development>
18
+
19
+ If these variables are not set, the production primitives are used.
20
+
21
+ ## Running Tests
22
+
23
+ bundle exec rspec
24
+
25
+ ## Versioning
26
+
27
+ Please use [semantic versioning](http://semver.org/) and bump
28
+ `lib/talis/version.rb` when submitting a pull request.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in talis.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,5 @@
1
+ guard :rspec, cmd: 'bundle exec rspec' do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch(%r{^lib/.+\.rb$}) { 'spec' }
4
+ watch('spec/spec_helper.rb') { 'spec' }
5
+ end
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Talis Ruby Client
2
+
3
+ [![Build Status](https://travis-ci.org/talis/talis_rb.svg?branch=master)](https://travis-ci.org/talis/talis_rb)
4
+ [![Dependency Status](https://dependencyci.com/github/talis/talis_rb/badge)](https://dependencyci.com/github/talis/talis_rb)
5
+
6
+ A ruby gem that provides interactions with Talis primitives.
7
+
8
+ ## Ubuntu Prerequisites
9
+
10
+ sudo apt-get install libgmp-dev
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile:
15
+
16
+ ```ruby
17
+ gem 'talis', github: talis/talis_rb
18
+ ```
19
+
20
+ And then execute:
21
+
22
+ $ bundle
23
+
24
+ Or install it yourself as:
25
+
26
+ $ gem install talis
27
+
28
+ ## Usage
29
+
30
+ ### Security Configuration
31
+
32
+ Many client operations require an OAuth token.
33
+ In order to carry out these operations, configure the OAuth client:
34
+
35
+ require 'talis/authentication'
36
+
37
+ Talis::Authentication.client_id = 'client_id'
38
+ Talis::Authentication.client_secret = 'client_secret'
39
+
40
+ See the code for each class for specific usage:
41
+ * `lib/talis/authentication/login.rb` For server-side login workflow.
42
+ * `lib/talis/authentication/token.rb` For OAuth token generation and validation.
43
+ * `lib/talis/hierarchy/node.rb` For managing hierarchies.
44
+ * `lib/talis/hierarchy/asset.rb` For managing hierarchy assets.
45
+ * `lib/talis/user.rb` For managing Talis users.
46
+ * `lib/talis/bibliography/work.rb` For querying works.
47
+ * `lib/talis/analytics.rb` For sending analytical data.
48
+
49
+ ## Development
50
+
51
+ After checking out the repo, run `bin/setup` to install dependencies.
52
+
53
+ Create a `.env` file in the project root and configure the following variables:
54
+
55
+ PERSONA_TEST_HOST=http://persona
56
+ PERSONA_OAUTH_CLIENT=<client ID>
57
+ PERSONA_OAUTH_SECRET=<client secret>
58
+ BLUEPRINT_TEST_HOST=http://blueprint
59
+ ECHO_TEST_HOST=http://echo
60
+
61
+ METATRON_TEST_HOST=<Metatron host>
62
+ METATRON_BASE_PATH=<set this to /env_name/2 if not using production>
63
+ METATRON_OAUTH_HOST=<Persona host the Metatron host uses>
64
+ METATRON_OAUTH_CLIENT=<client ID associated with oauth host above>
65
+ METATRON_OAUTH_SECRET=<client secret associated with oauth host above>
66
+ TEST_USER_GUID=<The GUID of the Talis test user (test.tn@talis.com)>
67
+
68
+
69
+ Adjust the values according to the primitives you want to test against (local or remote).
70
+ If testing locally then make sure the host names are in `/etc/hosts`.
71
+
72
+ Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
73
+
74
+ This project enforces style and lint rules via [Rubocop](https://github.com/bbatsov/rubocop). Run `rake rubocop` to check for violations.
75
+
76
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+ require 'rubocop/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: [:rubocop, :spec]
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'talis'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require 'pry'
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/talis.rb ADDED
@@ -0,0 +1,25 @@
1
+ require 'talis/version'
2
+ require 'talis/constants'
3
+ require 'talis/errors'
4
+ require 'talis/extensions/object'
5
+ require 'talis/resource'
6
+ require 'talis/authentication'
7
+ require 'talis/oauth_service'
8
+ require 'talis/analytics'
9
+ require 'talis/hierarchy'
10
+ require 'talis/feeds'
11
+ require 'talis/user'
12
+ require 'talis/bibliography'
13
+ require 'bundler/setup'
14
+ require 'httparty'
15
+ require 'json'
16
+
17
+ # Main entry point
18
+ module Talis
19
+ class << self
20
+ def new(opts = {})
21
+ token = Talis::Authentication::Token.generate(opts)
22
+ Talis::Authentication::Client.new(token, opts)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ require_relative 'analytics/event'
2
+
3
+ module Talis
4
+ # Use this as a mixin within your classes to be able to send analytics events.
5
+ module Analytics
6
+ # Create a single analytics event.
7
+ # In order to send events, the client must be configured with a
8
+ # valid OAuth client that is allowed to search for users:
9
+ #
10
+ # Talis::Authentication.client_id = 'client_id'
11
+ # Talis::Authentication.client_secret = 'client_secret'
12
+ #
13
+ # @param event [Hash] The event to send. It must contain the
14
+ # minimum keys:
15
+ # {
16
+ # class: 'my.class.name',
17
+ # source: 'my.source.name'
18
+ # }
19
+ # Other valid keys include: timestamp, user and props. Props can
20
+ # contain any key-value pair of custom data. All other keys will be
21
+ # ignored.
22
+ # @param request_id [String] ('uuid') unique ID for the remote request.
23
+ # @raise [Talis::ClientError] if the request was invalid.
24
+ # @raise [Talis::ServerError] if the search failed on the
25
+ # server.
26
+ # @raise [Talis::ServerCommunicationError] for network issues.
27
+ def send_analytics_event(event, request_id: nil)
28
+ Event.create(request_id: request_id, event: event)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,67 @@
1
+ module Talis
2
+ module Analytics
3
+ # Represents an event for analytical purposes.
4
+ class Event < Talis::Resource
5
+ base_uri Talis::ECHO_HOST
6
+
7
+ class << self
8
+ # Create a single analytics event.
9
+ # In order to send events, the client must be configured with a
10
+ # valid OAuth client that is allowed to search for users:
11
+ #
12
+ # Talis::Authentication.client_id = 'client_id'
13
+ # Talis::Authentication.client_secret = 'client_secret'
14
+ #
15
+ # @param request_id [String] ('uuid') unique ID for the remote request.
16
+ # @param event [Hash] The event to send. It must contain the
17
+ # minimum keys:
18
+ # {
19
+ # class: 'my.class.name',
20
+ # source: 'my.source.name'
21
+ # }
22
+ # Other valid keys include: timestamp, user and props. Props can
23
+ # contain any key-value pair of custom data. All other keys will be
24
+ # ignored.
25
+ # @raise [Talis::ClientError] if the request was invalid.
26
+ # @raise [Talis::ServerError] if the search failed on the
27
+ # server.
28
+ # @raise [Talis::ServerCommunicationError] for network issues.
29
+ def create(request_id: new_req_id, event:)
30
+ request_id = new_req_id unless request_id
31
+ validate_event event
32
+ payload = whitelist_event event
33
+ begin
34
+ response = post_event(request_id, payload)
35
+ handle_response(response, 204)
36
+ rescue SocketError
37
+ raise Talis::ServerCommunicationError
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def post_event(request_id, payload)
44
+ post('/1/events',
45
+ headers: {
46
+ 'Content-Type' => 'application/json',
47
+ 'X-Request-Id' => request_id,
48
+ 'Authorization' => "Bearer #{token}"
49
+ },
50
+ body: [payload].to_json)
51
+ end
52
+
53
+ def validate_event(event)
54
+ error_message = 'event must contain class and source'
55
+ required = [:class, :source]
56
+ provided = event.select { |attr| required.include? attr }.keys
57
+ raise ArgumentError, error_message unless required == provided
58
+ end
59
+
60
+ def whitelist_event(event)
61
+ valid = [:class, :source, :timestamp, :user, :props]
62
+ event.select { |attribute| valid.include? attribute }
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,14 @@
1
+ require_relative 'authentication/client'
2
+ require_relative 'authentication/public_key'
3
+ require_relative 'authentication/token'
4
+ require_relative 'authentication/login'
5
+
6
+ module Talis
7
+ # Encompasses all classes associated with user authentication
8
+ module Authentication
9
+ # The ID of the OAuth client to allow requests for asset resources.
10
+ cattr_accessor :client_id
11
+ # The secret of the OAuth client to allow requests for asset resources.
12
+ cattr_accessor :client_secret
13
+ end
14
+ end
@@ -0,0 +1,82 @@
1
+ require 'httparty'
2
+
3
+ module Talis
4
+ module Authentication
5
+ # Represents an OAuth client
6
+ class Client
7
+ include HTTParty
8
+
9
+ attr_reader :host, :client_id, :client_secret, :token, :scopes
10
+ def initialize(token, opts = {})
11
+ @token = token
12
+ acquire_host!(opts)
13
+ acquire_credentials!
14
+
15
+ response = authenticate!
16
+ body = JSON.parse response.body
17
+ @scopes = body['scope']
18
+ raise Talis::AuthenticationFailedError unless
19
+ response.code == 200
20
+ end
21
+
22
+ def add_scope(scope)
23
+ if scope.is_a? String
24
+ response = modify_scope(:add, scope)
25
+ @scopes << scope if response.code == 204
26
+ end
27
+ end
28
+
29
+ def remove_scope(scope)
30
+ if scope.is_a? String
31
+ response = modify_scope(:remove, scope)
32
+ @scopes.delete(scope) if response.code == 204
33
+ end
34
+ end
35
+
36
+ def modify_scope(action, scope)
37
+ action = case action
38
+ when :add
39
+ '$add'
40
+ when :remove
41
+ '$remove'
42
+ else
43
+ raise 'Unknown action'
44
+ end
45
+ patch_client_scope(action, scope)
46
+ end
47
+
48
+ private
49
+
50
+ def authenticate!
51
+ self.class.get("/clients/#{client_id}",
52
+ headers: { 'Authorization' => bearer_token })
53
+ end
54
+
55
+ def bearer_token
56
+ "Bearer #{token}"
57
+ end
58
+
59
+ def patch_client_scope(action, scope)
60
+ self.class.patch("/clients/#{client_id}",
61
+ headers: {
62
+ 'Content-Type' => 'application/json',
63
+ 'Authorization' => bearer_token
64
+ },
65
+ body: { scope: { action => scope } }.to_json)
66
+ end
67
+
68
+ def acquire_host!(opts)
69
+ if opts[:host].present?
70
+ self.class.base_uri(opts[:host])
71
+ else
72
+ self.class.base_uri(PERSONA_HOST)
73
+ end
74
+ end
75
+
76
+ def acquire_credentials!
77
+ @client_id = ENV['PERSONA_OAUTH_CLIENT']
78
+ @client_secret = ENV['PERSONA_OAUTH_SECRET']
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,169 @@
1
+ require 'base64'
2
+ require 'date'
3
+ require 'digest'
4
+ require 'multi_json'
5
+ require 'uuid'
6
+
7
+ module Talis
8
+ module Authentication
9
+ # Represents the user login flow for server-side applications.
10
+ # A prerequisite to using this class is having an application registered
11
+ # with Persona in order to obtain an app ID and secret. Application
12
+ # registration also provides Persona a callback URL to POST the login
13
+ # response back to the application.
14
+ # @example Redirect user to authentication provider.
15
+ # # First create a login object and redirect the user, storing the state:
16
+ # options = {
17
+ # app_id: 'my_app',
18
+ # app_secret: 'my_secret',
19
+ # provider: 'google',
20
+ # redirect_uri: 'https://my_app/secret_area'
21
+ # }
22
+ # login = Talis::Authentication::Login.new(options)
23
+ # url = login.generate_url
24
+ # session[:state] = login.state
25
+ # redirect_to url
26
+ # @example Handle data after user has logged in.
27
+ # # After the user has logged in, handle the POST callback:
28
+ # state = session.delete(:state)
29
+ # login.validate!(payload: params, state: state)
30
+ # if login.valid?
31
+ # session[:current_user_id] = login.user.guid
32
+ # redirect_to login.redirect_uri
33
+ # else
34
+ # # handle invalid login
35
+ # puts login.error
36
+ # end
37
+ # @example Logging out a user.
38
+ # # When a user logs out, make sure to clean up the session:
39
+ # session.delete(:current_user_id)
40
+ # redirect_to login.logout_url('some/logout/path')
41
+ class Login
42
+ include HTTParty
43
+ # @return [String] a non-guessable alphanumeric string used to prevent
44
+ # CSRF attacks. Store this in the user session after generating a
45
+ # login URL.
46
+ attr_reader :state
47
+ # @return [Talis::User] the logged-in user. This will be nil unless
48
+ # validation has passed.
49
+ attr_reader :user
50
+ # @return [String] if present, this will be the reason why the login
51
+ # failed.
52
+ attr_reader :error
53
+
54
+ base_uri Talis::PERSONA_HOST
55
+
56
+ # Creates a new login object to manage the login flow.
57
+ # @param app_id [String] ID of the application registered to Persona.
58
+ # @param secret [String] secret of the application registered to Persona.
59
+ # @param provider [String] name of the auth provider to use for login.
60
+ # @param redirect_uri [String] where to redirect back to after login.
61
+ def initialize(app_id:, secret:, provider:, redirect_uri:)
62
+ @uuid = UUID.new
63
+
64
+ @app = app_id
65
+ @secret = secret
66
+ @provider = provider
67
+ @redirect_uri = redirect_uri
68
+ end
69
+
70
+ # Use this URL to redirect the user wishing to login to their auth
71
+ # provider. After generating the URL the state will be available to
72
+ # store in a session.
73
+ # @param require [Symbol] (nil) If :profile, upon successful
74
+ # login, the user will be redirected to a profile form if they do not
75
+ # have one.
76
+ # @return [String] the generated URL.
77
+ def generate_url(require: nil)
78
+ @state = Digest::MD5.hexdigest("#{@app}::#{@uuid.generate}")
79
+ params = {
80
+ app: @app,
81
+ state: @state,
82
+ redirectUri: @redirect_uri
83
+ }
84
+ params = params.merge(require: 'profile') if require == :profile
85
+ params = params.to_query
86
+ "#{self.class.base_uri}/auth/providers/#{@provider}/login?#{params}"
87
+ end
88
+
89
+ # Validate a login request after the user has logged in to their auth
90
+ # provider. Validation will fail if the provided payload:
91
+ # - Is not a hash.
92
+ # - When decoded, contains invalid JSON.
93
+ # - Has no state or the state it contains does not match the param state.
94
+ # - Has an invalid signature.
95
+ # If validation succeeds, the #user attribute will return the
96
+ # logged-in user. If it fails, check the #error attribute for the
97
+ # reason why.
98
+ # @param payload [Hash] the payload POSTed to the application server.
99
+ # @param state [String] use the value stored at the start of the session.
100
+ def validate!(payload:, state:)
101
+ return @error = 'payload is not a hash' unless payload.is_a? Hash
102
+
103
+ key = 'persona:payload'
104
+ return @error = "payload missing key #{key}" unless payload.key? key
105
+
106
+ @payload = decode_and_parse_payload(payload)
107
+ return @error = 'payload is not valid JSON' if @payload.nil?
108
+
109
+ state_error = 'payload state does not match provided'
110
+ return @error = state_error if @payload['state'] != state
111
+
112
+ signature_error = 'payload signature does not match expected'
113
+ return @error = signature_error unless signature_valid?(@payload)
114
+
115
+ @user = build_user(@payload)
116
+ end
117
+
118
+ # Indicate whether or not the login succeeded.
119
+ # @return [Boolean] true if the login is valid, otherwise false.
120
+ def valid?
121
+ @error.nil?
122
+ end
123
+
124
+ # The redirect to follow once login has successfully completed.
125
+ # @return [String] the URL to redirect to.
126
+ def redirect_uri
127
+ @payload.present? ? @payload['redirect'] : @redirect_uri
128
+ end
129
+
130
+ # Logs a user out by terminating the session with Persona.
131
+ # @param redirect_url [String] where to return the user on logout.
132
+ # @return [String] the URL to redirect the user to when logging out.
133
+ def logout_url(redirect_url)
134
+ "#{self.class.base_uri}/auth/logout?redirectUri=#{redirect_url}"
135
+ end
136
+
137
+ private
138
+
139
+ def build_user(data)
140
+ profile = data['profile'] || {}
141
+ Talis::User.build(guid: data['guid'],
142
+ first_name: profile['first_name'],
143
+ surname: profile['surname'],
144
+ email: profile['email'],
145
+ access_token: data.fetch('token', {})['access_token'])
146
+ end
147
+
148
+ def decode_and_parse_payload(payload)
149
+ begin
150
+ json = MultiJson.load(Base64.decode64(payload['persona:payload']))
151
+ rescue MultiJson::LoadError
152
+ return nil
153
+ end
154
+ json
155
+ end
156
+
157
+ def signature_valid?(payload)
158
+ received_signature = payload.delete('signature')
159
+
160
+ # Persona PHP code escapes forward slashes when encoding JSON
161
+ json_payload = payload.to_json.gsub('/', '\/')
162
+
163
+ digest = OpenSSL::Digest.new('sha256')
164
+ signature = OpenSSL::HMAC.hexdigest(digest, @secret, json_payload)
165
+ received_signature == signature
166
+ end
167
+ end
168
+ end
169
+ end