authorio 0.8.3 → 0.8.7

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/authorio/auth.css +1 -1
  3. data/app/controllers/authorio/auth_controller.rb +63 -136
  4. data/app/controllers/authorio/authorio_controller.rb +23 -11
  5. data/app/controllers/authorio/sessions_controller.rb +6 -5
  6. data/app/controllers/authorio/users_controller.rb +13 -3
  7. data/app/helpers/authorio/tag_helper.rb +5 -5
  8. data/app/jobs/authorio/application_job.rb +2 -0
  9. data/app/models/authorio/application_record.rb +2 -0
  10. data/app/models/authorio/request.rb +46 -4
  11. data/app/models/authorio/session.rb +13 -10
  12. data/app/models/authorio/token.rb +16 -2
  13. data/app/models/authorio/user.rb +12 -3
  14. data/app/views/authorio/auth/authorization_interface.html.erb +2 -2
  15. data/app/views/authorio/auth/issue_token.json.jbuilder +7 -0
  16. data/app/views/authorio/auth/send_profile.json.jbuilder +3 -0
  17. data/app/views/authorio/auth/verify_token.json.jbuilder +5 -0
  18. data/app/views/authorio/sessions/new.html.erb +1 -2
  19. data/app/views/authorio/users/_profile.json.jbuilder +10 -0
  20. data/app/views/authorio/users/show.html.erb +18 -0
  21. data/app/views/authorio/users/verify.html.erb +1 -0
  22. data/app/views/shared/_login_form.html.erb +19 -14
  23. data/config/routes.rb +16 -11
  24. data/db/migrate/20210817010101_change_path_to_username_in_users.rb +7 -0
  25. data/db/migrate/20210831155106_add_code_challenge_to_requests.rb +5 -0
  26. data/lib/authorio/configuration.rb +14 -12
  27. data/lib/authorio/engine.rb +13 -11
  28. data/lib/authorio/exceptions.rb +20 -12
  29. data/lib/authorio/routes.rb +10 -7
  30. data/lib/authorio/version.rb +3 -1
  31. data/lib/authorio.rb +15 -21
  32. data/lib/generators/authorio/install/install_generator.rb +3 -3
  33. data/lib/generators/authorio/install/templates/authorio.rb +20 -15
  34. data/lib/tasks/authorio_tasks.rake +15 -14
  35. metadata +34 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5ecd8cf849002e116b21a3c4f0073fc17988e62791f356ab57a703397edc77c
4
- data.tar.gz: 4c0c4908c722b65ccd1559b5fea382231933e2840e5d0b6e08271ed5efd43f15
3
+ metadata.gz: f44cb9e20ad1a6c77dcb24da16623276d876ef4ebd0f3e630e3f27d72587646c
4
+ data.tar.gz: '08ca2eff8c2cb5a77801af0705981220499f58acfefa6f0b103ebda5c5f2eb62'
5
5
  SHA512:
6
- metadata.gz: 8bb01ec581f584fe9eadc7d77d477fa2f57e8883101ba51b5f8cb8729bf7486f061bc996a17cab023d476119dda8f37d7676a7f04e281180ad8dda8e649eb16c
7
- data.tar.gz: 7d8e0e19113cd7748a64212ee98f514ba953027409adf20a71b47f14c3e1c5ef0db28924bca9afa4fd498ea56a686b1169f2f8ceb7f53297472c6b9cd34d86cf
6
+ metadata.gz: f9d5a642af5a2a76f7d3ff12bafcd345bdfb8ab92f3792aa0f24d1ff8db29790e3cc37aab8474f3342660181cee08cf4af25f3c45fa3c8d2fef041697ed60b8f
7
+ data.tar.gz: 363031d389f74620cff7ae761935bcaa225d1c3c96ebc24122b9d7f45a746aeec46315dee1ff78da4f31329e5210c71707cfe387308f2f8b72483ce0b57ffc7c
@@ -50,7 +50,7 @@ div.authorio-auth {
50
50
  margin: 0.1em;
51
51
  }
52
52
 
53
- .authorio-auth .auth-btn-row .auth-btn:last-child {
53
+ .authorio-auth .auth-btn-row .btn-success {
54
54
  float: right;
55
55
  }
56
56
 
@@ -1,181 +1,108 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class AuthController < AuthorioController
3
- require 'uri'
4
- require 'digest'
5
-
6
5
  # These API-only endpoints are protected by code challenge and do not need CSRF protextion
7
- protect_from_forgery with: :exception, except: [:send_profile, :issue_token]
6
+ protect_from_forgery with: :exception, except: %i[send_profile issue_token]
8
7
 
9
8
  rescue_from 'Authorio::Exceptions::SessionReplayAttack' do |exception|
10
- redirect_back_with_error "Session Replay attack detected. This has been logged."
11
- logger.info "Session replay attack detected!"
12
- Authorio::Session.where(user: exception.session.user).delete_all
9
+ redirect_back_with_error 'Session Replay attack detected. This has been logged.'
10
+ logger.info 'Session replay attack detected!'
11
+ Session.where(user: exception.session.user).delete_all
12
+ end
13
+ rescue_from 'Authorio::Exceptions::UserNotFound' do
14
+ redirect_back_with_error 'User not found'
15
+ end
16
+ rescue_from 'Authorio::Exceptions::InvalidPassword' do
17
+ redirect_back_with_error 'Incorrect password. Try again.'
13
18
  end
14
-
15
- helper_method :user_scope_description
16
19
 
17
20
  # GET /auth
18
21
  def authorization_interface
19
- %w(client_id redirect_uri state code_challenge).each do |param|
20
- raise ::ActionController::ParameterMissing, param unless params[param].present?
21
- end
22
- @user = User.find_by_url! params[:me]
23
-
24
- # If there are any old requests from this (client, user), delete them now
25
- Request.where(authorio_user: @user, client: params[:client_id]).delete_all
26
-
27
- auth_request = Request.create(
28
- code: SecureRandom.hex(20),
29
- redirect_uri: params[:redirect_uri],
30
- client: params[:client_id], # IndieAuth client_id conflicts with Rails' _id foreign key convention
31
- scope: params[:scope],
32
- authorio_user: @user
33
- )
34
- session.update request.parameters.slice(*%w(state client_id code_challenge))
35
- @rememberable = Authorio.configuration.local_session_lifetime && !@user_logged_in_locally
36
- @scope = params[:scope]&.split
37
- rescue ActiveRecord::RecordNotFound
38
- redirect_back_with_error "Invalid user"
39
- rescue ActionController::ParameterMissing => error
40
- render oauth_error "invalid_request", "missing parameter #{error}"
22
+ session.update auth_interface_params.slice(:state, :client_id, :code_challenge, :redirect_uri)
23
+ rescue ActionController::ParameterMissing, ActionController::UnpermittedParameters => e
24
+ render oauth_error 'invalid_request', e
41
25
  end
42
26
 
43
27
  # POST /user/:id/authorize
44
28
  def authorize_user
45
- redirect_to session[:client_id] and return if params[:commit] == "Cancel"
46
-
47
- user = authenticate_user_from_session_or_password
48
- set_session_cookie(user) if auth_user_params[:remember_me]
29
+ redirect_to(session[:client_id], allow_other_host: true) and return if params[:commit] == 'Cancel'
49
30
 
50
- auth_req = Request.find_by! client: session[:client_id], authorio_user: user
51
- auth_req.update_scope(scope_params[:scope]) if params.has_key? :scope
52
- redirect_params = { code: auth_req.code, state: session[:state] }
53
- redirect_to "#{auth_req.redirect_uri}?#{redirect_params.to_query}"
54
- rescue ActiveRecord::RecordNotFound
55
- redirect_back_with_error "Invalid user"
56
- rescue Authorio::Exceptions::InvalidPassword
57
- redirect_back_with_error "Incorrect password. Try again."
31
+ @user = authenticate_user_from_session_or_password
32
+ write_session_cookie(@user) if auth_user_params[:remember_me]
33
+ create_auth_request
34
+ redirect_to_client
58
35
  end
59
36
 
60
37
  def send_profile
61
- request = validate_request
62
- render json: profile(request)
63
- rescue Authorio::Exceptions::InvalidGrant => error
64
- render oauth_error 'invalid_grant', error.message
38
+ @auth_request = find_auth_request or (render validation_failed and return)
65
39
  end
66
40
 
67
41
  def issue_token
68
- req = validate_request
69
- raise Authorio::Exceptions::InvalidGrant, 'missing scope' if req.scope.blank?
70
- token = Token.create(authorio_user: req.authorio_user, scope: req.scope, client: req.client)
71
- render json: {
72
- 'access_token': token.auth_token,
73
- 'scope': req.scope,
74
- 'expires_in': Authorio.configuration.token_expiration,
75
- 'token_type': 'Bearer'
76
- }.merge(profile(req))
77
- rescue Authorio::Exceptions::InvalidGrant => error
78
- render oauth_error, 'invalid_grant', error.message
42
+ @auth_request = find_auth_request or (render validation_failed and return)
43
+ @token = Token.create_from_request(@auth_request)
79
44
  end
80
45
 
81
46
  def verify_token
82
- token = Token.find_by! auth_token: bearer_token
83
- if token.expired?
84
- token.delete
85
- render token_expired
86
- else
87
- render json: {
88
- 'me': user_url(token.authorio_user),
89
- 'client_id': token.client,
90
- 'scope': 'token.scope'
91
- }
92
- end
93
- rescue ActiveRecord::RecordNotFound
94
- head :bad_request
47
+ @token = Token.find_by_auth_token(bearer_token) or (head :bad_request and return)
48
+ return unless @token.expired?
49
+
50
+ @token.delete
51
+ render token_expired
95
52
  end
96
53
 
97
54
  private
98
55
 
56
+ def auth_interface_params
57
+ @auth_interface_params ||= begin
58
+ required = %w[client_id redirect_uri state]
59
+ permitted = %w[me scope code_challenge_method response_type code_challenge dummy]
60
+ params.require(required)
61
+ params.permit(permitted + required)
62
+ params.permit!
63
+ end
64
+ end
65
+
99
66
  def scope_params
100
67
  params.require(:scope).permit(scope: [])
101
68
  end
102
69
 
103
- def oauth_error(error, message=nil)
104
- resp = { json: {'error': error} }
105
- resp[:json]['error_message'] = message unless message.nil?
106
- { json: resp, status: :bad_request }
70
+ def oauth_error(error, message = nil, status = :bad_request)
71
+ { json: { json: { error:, error_message: message }.compact },
72
+ status: }
107
73
  end
108
74
 
109
75
  def token_expired
110
- { json: {'error': 'invalid_token', 'error_message': 'The access token has expired' }, status: :unauthorized }
111
- end
112
-
113
- def code_challenge_failed?
114
- # For now, if original request did not have code challenge, then we pass by default
115
- return false if session[:code_challenge].nil?
116
- sha256 = Digest::SHA256.hexdigest params[:code_verifier]
117
- base64 = Base64.urlsafe_encode64 sha256
118
- return base64 != session[:code_challenge]
119
- end
120
-
121
- def invalid_request?(req)
122
- req.redirect_uri != params[:redirect_uri] \
123
- || req.client != params[:client_id] \
124
- || req.created_at < Time.now - 10.minutes
125
- end
126
-
127
- def validate_request
128
- req = Request.find_by code: params[:code]
129
- raise Authorio::Exceptions::InvalidGrant, "code not found" if req.nil?
130
- req.delete
131
- raise Authorio::Exceptions::InvalidGrant, "validation failed" if invalid_request?(req) || code_challenge_failed?
132
- req
133
- end
134
-
135
- def profile(request)
136
- profile = { me: user_url(request.authorio_user) }
137
- if request.scope
138
- scopes = request.scope.split
139
- if scopes.include? 'profile'
140
- profile['profile'] = {
141
- name: request.authorio_user.full_name,
142
- url: request.authorio_user.url,
143
- photo: request.authorio_user.photo
144
- }.compact
145
- if scopes.include? 'email'
146
- profile['profile']['email'] = request.authorio_user.email
147
- end
148
- end
149
- end
150
- profile
76
+ oauth_error('invalid_token', 'The access token has expired', :unauthorized)
151
77
  end
152
78
 
153
- def bearer_token
154
- bearer = /^Bearer /
155
- header = request.headers['Authorization']
156
- header.gsub(bearer, '') if header && header.match(bearer)
79
+ def validation_failed
80
+ oauth_error('invalid_grant', 'validation failed')
157
81
  end
158
82
 
159
- def authenticate_user_from_session_or_password
160
- session = user_session
161
- if session
162
- return session.authorio_user
163
- else
164
- user = User.find_by! profile_path: URI(auth_user_params[:url]).path
165
- raise Authorio::Exceptions::InvalidPassword unless user.authenticate(auth_user_params[:password])
166
- return user
167
- end
83
+ def find_auth_request
84
+ auth_request = Request.find_by code: params[:code]
85
+ auth_request&.validate_oauth params
168
86
  end
169
87
 
170
- ScopeDescriptions = {
171
- 'profile': 'View basic profile information',
172
- 'email': 'View your email address',
173
- 'offline_access': 'Keep you logged in permanently (until revoked)'
174
- }
88
+ def create_auth_request
89
+ @auth_req = Request.create(client: session[:client_id],
90
+ redirect_uri: session[:redirect_uri],
91
+ code_challenge: (session[:code_challenge] if session.key? :code_challenge),
92
+ scope: (scope_params[:scope].join(' ') if params.key? :scope),
93
+ authorio_user: @user)
94
+ end
175
95
 
176
- def user_scope_description(scope)
177
- ScopeDescriptions.dig(scope.to_sym) || scope
96
+ def redirect_to_client
97
+ redirect_params = { code: @auth_req.code, state: session[:state] }
98
+ redirect_to "#{@auth_req.redirect_uri}?#{redirect_params.to_query}", allow_other_host: true
178
99
  end
179
100
 
101
+ def authenticate_user_from_session_or_password
102
+ user_session&.authorio_user or
103
+ User.find_by_username!(auth_user_params[:username])
104
+ .authenticate(auth_user_params[:password]) or
105
+ raise Exceptions::InvalidPassword
106
+ end
180
107
  end
181
108
  end
@@ -1,12 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class AuthorioController < ActionController::Base
3
5
  layout 'authorio/main'
4
6
 
5
- helper_method :logged_in?, :rememberable?, :user_url, :current_user
7
+ helper_method :logged_in?, :rememberable?, :current_user,
8
+ :user_scope_description, :profile_url
6
9
 
7
10
  def index
8
11
  if logged_in?
9
- redirect_to edit_user_path(1)
12
+ redirect_to edit_user_path(current_user)
10
13
  else
11
14
  redirect_to new_session_path
12
15
  end
@@ -33,20 +36,28 @@ module Authorio
33
36
  end
34
37
 
35
38
  def current_user
36
- user_session&.authorio_user.id
39
+ user_session&.authorio_user&.id
40
+ end
41
+
42
+ def user_scope_description(scope)
43
+ Authorio::Request.user_scope_description(scope)
37
44
  end
38
45
 
39
- def user_url(user)
40
- "#{host_with_protocol}#{user.profile_path}"
46
+ def profile_url(user)
47
+ if Authorio.configuration.multiuser
48
+ verify_user_url(user)
49
+ else
50
+ "#{request.scheme}://#{request.host}"
51
+ end
41
52
  end
42
53
 
43
54
  protected
44
55
 
45
56
  def auth_user_params
46
- params.require(:user).permit(:password, :url, :remember_me)
57
+ params.require(:user).permit(:username, :password, :remember_me)
47
58
  end
48
59
 
49
- def set_session_cookie(user)
60
+ def write_session_cookie(user)
50
61
  cookies.encrypted[:user] = {
51
62
  value: Authorio::Session.create(authorio_user: user).as_cookie,
52
63
  expires: Authorio.configuration.local_session_lifetime
@@ -55,12 +66,13 @@ module Authorio
55
66
 
56
67
  def redirect_back_with_error(error)
57
68
  flash[:alert] = error
58
- redirect_back fallback_location: Authorio.authorization_path, allow_other_host: false
69
+ redirect_back fallback_location: Authorio.authorization_path.prepend('/'), allow_other_host: false
59
70
  end
60
71
 
61
- def host_with_protocol
62
- "#{request.scheme}://#{request.host}"
72
+ def bearer_token
73
+ bearer = /^Bearer /
74
+ header = request.headers['Authorization']
75
+ header.gsub(bearer, '') if header&.match(bearer)
63
76
  end
64
-
65
77
  end
66
78
  end
@@ -1,6 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class SessionsController < AuthorioController
3
-
4
5
  # GET /session/new
5
6
  def new
6
7
  @session = Session.new(authorio_user: User.first)
@@ -8,21 +9,21 @@ module Authorio
8
9
 
9
10
  # POST /session
10
11
  def create
11
- user = User.find_by! profile_path: URI(auth_user_params[:url]).path
12
+ user = User.find_by_username! auth_user_params[:username]
12
13
  raise Exceptions::InvalidPassword unless user.authenticate(auth_user_params[:password])
13
- set_session_cookie(user) if auth_user_params[:remember_me]
14
14
 
15
+ write_session_cookie(user) if auth_user_params[:remember_me]
15
16
  # Even if we don't have a permanent remember-me session, we make a temporary session
16
17
  session[:user_id] = user.id
17
18
  redirect_to edit_user_path(user)
18
19
  rescue Exceptions::InvalidPassword
19
- redirect_back_with_error "Incorrect password. Try again."
20
+ redirect_back_with_error 'Incorrect password. Try again.'
20
21
  end
21
22
 
22
23
  # DELETE /session
23
24
  def destroy
24
25
  reset_session
25
- if (cookie = cookies.encrypted[:user]) && session = Session.find_by_cookie(cookie)
26
+ if (cookie = cookies.encrypted[:user]) && (session = Session.find_by_cookie(cookie))
26
27
  cookies.delete :user
27
28
  session.destroy
28
29
  end
@@ -1,9 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class UsersController < AuthorioController
5
+ before_action :authorized?, except: :verify
3
6
 
4
- before_action :authorized?
7
+ # GET /users/:id
8
+ def show
9
+ @user = User.find(params[:id])
10
+ end
5
11
 
6
- # GET /users/:id/edit
12
+ # GET /users/:id/edit
7
13
  def edit
8
14
  @user = User.find(params[:id])
9
15
  end
@@ -11,10 +17,14 @@ module Authorio
11
17
  # PATCH /users/:id
12
18
  def update
13
19
  User.find(params[:id]).update(user_params)
14
- flash[:info] = "Profile Saved"
20
+ flash[:info] = 'Profile Saved'
15
21
  redirect_to edit_user_path
16
22
  end
17
23
 
24
+ # This is only called by IndieAuth clients who wish to verify that a
25
+ # user profile URL we generated is in fact ours.
26
+ def verify; end
27
+
18
28
  private
19
29
 
20
30
  def user_params
@@ -1,17 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  # These helpers are provided to the main application
3
5
  module TagHelper
4
6
  extend ActiveSupport::Concern
5
7
 
6
8
  included do
7
- if respond_to?(:helper_method)
8
- helper_method :indieauth_tag
9
- end
9
+ helper_method :indieauth_tag if respond_to?(:helper_method)
10
10
  end
11
11
 
12
12
  def indieauth_tag
13
- tag(:link, rel: 'authorization_endpoint', href: URI.join(root_url, Authorio.authorization_path)) <<
14
- tag(:link, rel: 'token_endpoint', href: URI.join(root_url, Authorio.token_path))
13
+ tag(:link, rel: 'authorization_endpoint', href: URI.join(main_app.root_url, Authorio.authorization_path)) <<
14
+ tag(:link, rel: 'token_endpoint', href: URI.join(main_app.root_url, Authorio.token_path))
15
15
  end
16
16
  end
17
17
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class ApplicationJob < ActiveJob::Base
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class ApplicationRecord < ActiveRecord::Base
3
5
  self.abstract_class = true
@@ -1,12 +1,54 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Request < ApplicationRecord
3
- belongs_to :authorio_user, class_name: "::Authorio::User"
5
+ belongs_to :authorio_user, class_name: '::Authorio::User'
4
6
 
5
7
  validates_presence_of :code, :redirect_uri, :client
6
8
 
7
- # User has the right to modify requested scope
8
- def update_scope(scope)
9
- update(scope: scope.join(' '))
9
+ before_validation :set_code, on: :create
10
+ before_create :sweep_requests
11
+
12
+ # The IndieAuth spec uses 'client_id' to specify the client in the address, as a URL (eg "https://example.com")
13
+ # But Rails uses '_id' to tag associations (foreign keys). So we save that as 'client' here, but map
14
+ # client_id as an alias since that is what the HTTP parameter will be
15
+ def client_id=(value)
16
+ self.client = value
17
+ end
18
+
19
+ def validate_oauth(params)
20
+ redirect_uri == params[:redirect_uri] &&
21
+ client == params[:client_id] &&
22
+ created_at > 10.minutes.ago &&
23
+ code_challenge_matches(params[:code_verifier]) &&
24
+ self
10
25
  end
26
+
27
+ def code_challenge_matches(verifier)
28
+ # For now, if original request did not have code challenge, then we pass by default
29
+ return true if code_challenge.blank?
30
+
31
+ sha256 = Digest::SHA256.digest verifier
32
+ Base64.urlsafe_encode64(sha256).sub(/=*$/, '') == code_challenge
33
+ end
34
+
35
+ def self.user_scope_description(scope)
36
+ USER_SCOPE_DESCRIPTION[scope.to_sym] || scope
37
+ end
38
+
39
+ private
40
+
41
+ def set_code
42
+ self.code = SecureRandom.hex(20)
43
+ end
44
+
45
+ def sweep_requests
46
+ Request.where(client:, authorio_user:).destroy_all
47
+ end
48
+
49
+ USER_SCOPE_DESCRIPTION = {
50
+ profile: 'View basic profile information',
51
+ email: 'View your email address'
52
+ }.freeze
11
53
  end
12
54
  end
@@ -1,34 +1,37 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Session < ApplicationRecord
3
5
  # Implement a session cookie store based on best security practices
4
6
  # See: https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence
5
- belongs_to :authorio_user, class_name: "::Authorio::User"
7
+ belongs_to :authorio_user, class_name: '::Authorio::User'
6
8
 
7
9
  # 1. Protect against having database stolen by only storing token hashes
8
- attribute :token # This will not be persisted in the DB
10
+ attribute :token # This will not be persisted in the DB
9
11
  has_secure_token
10
12
 
11
13
  before_create do
12
14
  self.expires_at = Time.now + Authorio.configuration.token_expiration
13
15
  self.selector = SecureRandom.hex(12)
14
- self.hashed_token = Digest::SHA256.hexdigest self.token
16
+ self.hashed_token = Digest::SHA256.hexdigest token
15
17
  end
16
18
 
17
19
  # 2. To guard against timing attacks, we lookup tokens based on a separate selector attribute
18
20
  # and compare them using a secure time-constant comparison method
19
21
  def self.find_by_cookie(cookie)
20
- _selector, _token = cookie.split(':')
21
- session = find_by selector: _selector
22
- raise Authorio::Exceptions::SessionReplayAttack.new(session) unless session.matches_cookie?(cookie)
22
+ selector, _token = cookie.split(':')
23
+ session = find_by selector: selector
24
+ raise Authorio::Exceptions::SessionReplayAttack.new, session unless session.matches_cookie?(cookie)
25
+
23
26
  session
24
27
  end
25
28
 
26
29
  def matches_cookie?(cookie)
27
- _selector, _token = cookie.split(':')
28
- _hashed_token = Digest::SHA256.hexdigest _token
29
- !expired? && ActiveSupport::SecurityUtils.secure_compare(_hashed_token, hashed_token)
30
+ _selector, token = cookie.split(':')
31
+ cookie_hashed_token = Digest::SHA256.hexdigest token
32
+ !expired? && ActiveSupport::SecurityUtils.secure_compare(cookie_hashed_token, hashed_token)
30
33
  end
31
-
34
+
32
35
  def expired?
33
36
  expires_at < Time.now
34
37
  end
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class Token < ApplicationRecord
3
- belongs_to :authorio_user, class_name: "::Authorio::User"
5
+ belongs_to :authorio_user, class_name: '::Authorio::User'
4
6
  has_secure_token :auth_token
5
7
 
6
8
  validates_presence_of :scope, :client
@@ -9,8 +11,20 @@ module Authorio
9
11
  self.expires_at = Time.now + Authorio.configuration.token_expiration
10
12
  end
11
13
 
14
+ # The token endpoint can get hit by bots, so short-circut the find if they
15
+ # don't send a bearer token
16
+ def self.find_by_auth_token(token)
17
+ token and find_by auth_token: token
18
+ end
19
+
12
20
  def expired?
13
- return expires_at < Time.now
21
+ expires_at < Time.now
22
+ end
23
+
24
+ def self.create_from_request(req)
25
+ raise Exceptions::InvalidGrant, 'missing scope' if req.scope.blank?
26
+
27
+ Token.create(authorio_user: req.authorio_user, scope: req.scope, client: req.client)
14
28
  end
15
29
  end
16
30
  end
@@ -1,10 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class User < ApplicationRecord
3
5
  has_secure_password
4
6
 
5
- def self.find_by_url!(url)
6
- find_by! profile_path: URI(url || "/").path
7
- end
7
+ class << self
8
+ def find_by_url!(url)
9
+ find_by_username!(URI(url).path)
10
+ end
8
11
 
12
+ def find_by_username!(name)
13
+ return first unless Authorio.configuration.multiuser
14
+
15
+ find_by(username: name) or raise Exceptions::UserNotFound
16
+ end
17
+ end
9
18
  end
10
19
  end
@@ -7,9 +7,9 @@
7
7
  <h3>Authorio</h3>
8
8
  <div class="client-row">
9
9
  <span class="client"><%= params[:client_id] %></span> wants to authenticate
10
- <% if @scope %>and also<% end %>
10
+ <% if params[:scope] %>and also<% end %>
11
11
  </div>
12
- <%= render 'shared/login_form', target: authorize_user_path(@user), user: @user, scopes: @scope, cancel: true %>
12
+ <%= render 'shared/login_form', target: authorize_user_path, cancel: true %>
13
13
  </div>
14
14
  <div class="col-md-4"></div>
15
15
  </div>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.access_token @token.auth_token
4
+ json.expires_in Authorio.configuration.token_expiration
5
+ json.token_type 'Bearer'
6
+ json.scope @token.scope
7
+ json.partial! 'authorio/users/profile', request: @auth_request
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.partial! 'authorio/users/profile', request: @request
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.me url_for(@token.authorio_user)
4
+ json.client_id @token.client
5
+ json.scope @token.scope
@@ -7,8 +7,7 @@
7
7
  <div class="col-md-4 auth-panel">
8
8
  <h3>Authorio</h3>
9
9
  <div class="client-row">Local Login</div>
10
- <%= render 'shared/login_form', target: session_path(@session),
11
- user: @session.authorio_user, scopes: nil, cancel: false %>
10
+ <%= render 'shared/login_form', target: session_path(@session), cancel: false %>
12
11
  </div>
13
12
  <div class="col-md-4"></div>
14
13
  </div>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ json.me profile_url(@auth_request.authorio_user)
4
+ if @auth_request.scope&.include? 'profile'
5
+ json.profile do
6
+ json.name(@auth_request.authorio_user.full_name)
7
+ json.call(@auth_request.authorio_user, :url, :photo)
8
+ json.email(@auth_request.authorio_user.email) if @auth_request.scope.include?('email')
9
+ end
10
+ end
@@ -0,0 +1,18 @@
1
+ <%= stylesheet_link_tag "authorio/auth" %>
2
+ <% content_for :title, "User Account" %>
3
+
4
+ <%= indieauth_tag %>
5
+
6
+ <div class="container authorio-auth">
7
+ <div class="row">
8
+ <div class="col-md-4"></div>
9
+ <div class="col-md-4 auth-panel">
10
+ <h3>IndieAuth Account</h3>
11
+ Full Name <%= @user.full_name %>
12
+ URL <%= @user.url %>
13
+ Photo <%= image_tag @user.photo %>
14
+ Email <%= @user.email %>
15
+ </div>
16
+ <div class="col-md-4"></div>
17
+ </div>
18
+ </div>
@@ -0,0 +1 @@
1
+ <%= indieauth_tag %>
@@ -1,9 +1,9 @@
1
- <%= form_with(model: user, url: target, method: :post) do |form| %>
2
- <% if scopes %>
1
+ <%= form_with(url: target, method: :post) do |form| %>
2
+ <% if params[:scope] %>
3
3
  <%= fields_for :scope do |req_scope| %>
4
4
  <div class="scopes">
5
5
  <ul class="scope">
6
- <% for scope in scopes %>
6
+ <% for scope in params[:scope].split %>
7
7
  <li>
8
8
  <%= label_tag(:scope, class: 'scope-label') do %>
9
9
  <%= req_scope.check_box(:scope, {multiple: true, checked: true}, scope, nil) %>
@@ -15,22 +15,27 @@
15
15
  </div>
16
16
  <% end %>
17
17
  <% end -%>
18
- <%= form.label(:url, "User URL") %>
19
- <%= form.text_field(:url, value: params[:me] || user_url(user), readonly: true) %>
20
- <% unless logged_in? %>
21
- <%= form.label(:password, "Password") %>
22
- <%= form.password_field(:password, autofocus: true) %>
23
- <% if rememberable? %>
24
- <%= label_tag(:remember_me, class: 'remember') do %>
25
- <%= form.check_box :remember_me %>
26
- <span class='r-m'>Remember me for <%= distance_of_time_in_words Authorio.configuration.local_session_lifetime -%></span>
18
+ <%= fields_for :user do |user_scope| %>
19
+ <%= user_scope.hidden_field :dummy, value: 42 %>
20
+ <% if Authorio.configuration.multiuser %>
21
+ <%= user_scope.label(:username, "Username") %>
22
+ <%= user_scope.text_field(:username) %>
23
+ <% end -%>
24
+ <% unless logged_in? %>
25
+ <%= user_scope.label(:password, "Password") %>
26
+ <%= user_scope.password_field(:password, autofocus: true) %>
27
+ <% if rememberable? %>
28
+ <%= label_tag(:remember_me, class: 'remember') do %>
29
+ <%= user_scope.check_box :remember_me %>
30
+ <span class='r-m'>Remember me for <%= distance_of_time_in_words Authorio.configuration.local_session_lifetime -%></span>
31
+ <% end %>
27
32
  <% end %>
28
33
  <% end %>
29
- <% end %>
34
+ <% end -%>
30
35
  <div class='auth-btn-row'>
36
+ <%= form.submit("Sign in", class: 'btn btn-success auth-btn') %>
31
37
  <% if cancel %>
32
38
  <%= form.submit("Cancel", class: 'btn btn-default auth-btn') %>
33
39
  <% end %>
34
- <%= form.submit("Sign in", class: 'btn btn-success auth-btn') %>
35
40
  </div>
36
41
  <% end %>
data/config/routes.rb CHANGED
@@ -1,12 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Authorio::Engine.routes.draw do
2
- get Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'authorization_interface'
3
- post Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'send_profile'
4
- resources :users, only: [:edit, :update] do
5
- post 'authorize', on: :member, to: 'auth#authorize_user'
6
- end
7
- resource :session, only: [:new, :create]
8
- get 'session' => 'sessions#destroy', as: 'logout'
9
- get Authorio.configuration.token_endpoint, controller: 'auth', action: 'verify_token'
10
- post Authorio.configuration.token_endpoint, controller: 'auth', action: 'issue_token'
11
- root to: 'authorio#index'
12
- end
4
+ root to: 'authorio#index'
5
+
6
+ get Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'authorization_interface'
7
+ resources :users, only: %i[show edit update]
8
+ post 'user/authorize', to: 'auth#authorize_user', as: 'authorize_user'
9
+ resource :session, only: %i[new create]
10
+ get 'session', to: 'sessions#destroy', as: 'logout'
11
+ get 'user/(:id)/verify', to: 'users#verify', as: 'verify_user'
12
+ defaults format: :json do
13
+ post Authorio.configuration.authorization_endpoint, controller: 'auth', action: 'send_profile'
14
+ get Authorio.configuration.token_endpoint, controller: 'auth', action: 'verify_token'
15
+ post Authorio.configuration.token_endpoint, controller: 'auth', action: 'issue_token'
16
+ end
17
+ end
@@ -0,0 +1,7 @@
1
+ class ChangePathToUsernameInUsers < ActiveRecord::Migration[6.1]
2
+ def change
3
+ change_table :authorio_users do |t|
4
+ t.rename :profile_path, :username
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ class AddCodeChallengeToRequests < ActiveRecord::Migration[6.1]
2
+ def change
3
+ add_column :authorio_requests, :code_challenge, :string
4
+ end
5
+ end
@@ -1,15 +1,17 @@
1
- module Authorio
2
- class Configuration
1
+ # frozen_string_literal: true
3
2
 
4
- attr_accessor :authorization_endpoint, :token_endpoint, :mount_point, :token_expiration,
5
- :local_session_lifetime
3
+ module Authorio
4
+ class Configuration
5
+ attr_accessor :authorization_endpoint, :token_endpoint, :mount_point, :token_expiration,
6
+ :local_session_lifetime, :multiuser
6
7
 
7
- def initialize
8
- @authorization_endpoint = "auth"
9
- @token_endpoint = "token"
10
- @mount_point = "authorio"
11
- @token_expiration = 4.weeks
12
- @local_session_lifetime = nil
13
- end
14
- end
8
+ def initialize
9
+ @authorization_endpoint = 'auth'
10
+ @token_endpoint = 'token'
11
+ @mount_point = 'authorio'
12
+ @token_expiration = 4.weeks
13
+ @local_session_lifetime = nil
14
+ @multiuser = false
15
+ end
16
+ end
15
17
  end
@@ -1,15 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
- class Engine < ::Rails::Engine
3
- isolate_namespace Authorio
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Authorio
4
6
 
5
- initializer "authorio.load_helpers" do |app|
6
- Rails.application.reloader.to_prepare do
7
- ActionView::Base.send :include, Authorio::TagHelper
8
- end
9
- end
7
+ initializer 'authorio.load_helpers' do
8
+ Rails.application.reloader.to_prepare do
9
+ ActionView::Base.send :include, Authorio::TagHelper
10
+ end
11
+ end
10
12
 
11
- initializer "authorio.assets.precompile" do |app|
12
- app.config.assets.precompile += %w( authorio/auth.css authorio/application.css )
13
- end
14
- end
13
+ initializer 'authorio.assets.precompile' do |app|
14
+ app.config.assets.precompile << ['authorio/application.css', 'authorio/auth.css']
15
+ end
16
+ end
15
17
  end
@@ -1,14 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
- module Exceptions
3
- class InvalidGrant < RuntimeError; end
4
- class InvalidPassword < RuntimeError; end
5
-
6
- class SessionReplayAttack < StandardError
7
- attr_accessor :session
8
-
9
- def initialize(session)
10
- @session = session
11
- end
12
- end
13
- end
4
+ module Exceptions
5
+ class InvalidGrant < RuntimeError; end
6
+
7
+ class InvalidPassword < RuntimeError; end
8
+
9
+ class SessionReplayAttack < StandardError
10
+ attr_accessor :session
11
+
12
+ def initialize(session)
13
+ super("Session replay attack on user account #{session.authorio_user.id}")
14
+ @session = session
15
+ end
16
+ end
17
+
18
+ class UserNotFound < StandardError; end
19
+
20
+ class TokenExpired < StandardError; end
21
+ end
14
22
  end
@@ -1,9 +1,12 @@
1
- module ActionDispatch::Routing
2
- class Mapper
1
+ # frozen_string_literal: true
3
2
 
4
- # Provide a custom mounting command, just so we can track our own mount point
5
- def authorio_routes
6
- mount Authorio::Engine, at: Authorio.configuration.mount_point
7
- end
8
- end
3
+ module ActionDispatch
4
+ module Routing
5
+ class Mapper
6
+ # Provide a custom mounting command, just so we can track our own mount point
7
+ def authorio_routes
8
+ mount Authorio::Engine, at: Authorio.configuration.mount_point
9
+ end
10
+ end
11
+ end
9
12
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
- VERSION = '0.8.3'
4
+ VERSION = '0.8.7'
3
5
  end
data/lib/authorio.rb CHANGED
@@ -1,27 +1,21 @@
1
- require "authorio/version"
2
- require "authorio/engine"
3
- require "authorio/configuration"
4
- require "authorio/routes"
5
- require "authorio/exceptions"
1
+ # frozen_string_literal: true
6
2
 
7
- module Authorio
8
- class << self
9
- attr_accessor :configuration, :authorization_path
10
- end
3
+ Dir[File.join(__dir__, 'authorio', '*.rb')].sort.each { |f| require f }
11
4
 
12
- def self.configuration
13
- @configuration ||= Configuration.new
14
- end
5
+ module Authorio
6
+ def self.configuration
7
+ @configuration ||= Configuration.new
8
+ end
15
9
 
16
- def self.configure
17
- yield configuration
18
- end
10
+ def self.configure
11
+ yield configuration
12
+ end
19
13
 
20
- def self.authorization_path
21
- return [Authorio.configuration.mount_point, Authorio.configuration.authorization_endpoint].join("/")
22
- end
14
+ def self.authorization_path
15
+ [Authorio.configuration.mount_point, Authorio.configuration.authorization_endpoint].join('/')
16
+ end
23
17
 
24
- def self.token_path
25
- return [Authorio.configuration.mount_point, Authorio.configuration.token_endpoint].join("/")
26
- end
18
+ def self.token_path
19
+ [Authorio.configuration.mount_point, Authorio.configuration.token_endpoint].join('/')
20
+ end
27
21
  end
@@ -1,17 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Authorio
2
4
  class InstallGenerator < Rails::Generators::Base
3
-
4
5
  def self.source_paths
5
6
  paths = []
6
7
  paths << File.expand_path('../templates', "../../#{__FILE__}")
7
8
  paths << File.expand_path('../templates', "../#{__FILE__}")
8
- paths << File.expand_path('../templates', __FILE__)
9
+ paths << File.expand_path('templates', __dir__)
9
10
  paths.flatten
10
11
  end
11
12
 
12
13
  def add_files
13
14
  template 'authorio.rb', 'config/initializers/authorio.rb'
14
15
  end
15
-
16
16
  end
17
17
  end
@@ -1,24 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Configuration for Authorio IndieAuth authentication
2
4
 
3
5
  Authorio.configure do |config|
6
+ # Mount point for Authorio URLs. Typically you would call this in your routes.rb
7
+ # as mount Authorio::Engine, at: mount_point
8
+ # But Authorio needs to know its own mount point, so we define it here and use a custom mount command in the config
9
+ # config.mount_point = "authorio"
4
10
 
5
- # Mount point for Authorio URLs. Typically you would call this in your routes.rb
6
- # as mount Authorio::Engine, at: mount_point
7
- # But Authorio needs to know its own mount point, so we define it here and use a custom mount command in the config
8
- # config.mount_point = "authorio"
11
+ # The path where clients will be redirected to provide authentication
12
+ # config.authorization_endpoint = "auth"
9
13
 
10
- # The path where clients will be redirected to provide authentication
11
- # config.authorization_endpoint = "auth"
14
+ # The path for token requests
15
+ # config.token_endpoint = "token"
12
16
 
13
- # The path for token requests
14
- # config.token_endpoint = "token"
17
+ # Set to true to enable multiple user accounts. By default (in single user mode)
18
+ # there is only one user, and therefore you do not need to enter a username
19
+ # config.multiuser = false
15
20
 
16
- # How long tokens will last before expiring
17
- # config.token_expiration = 4.weeks
21
+ # How long tokens will last before expiring
22
+ # config.token_expiration = 4.weeks
18
23
 
19
- # Enable local session lifetime to keep yourself "logged in" to your own server
20
- # If set to eg:
21
- # config.local_session_lifetime = 30.days
22
- # then you will only have to enter your password every 30 days. Default is off (nil)
23
- # config.local_session_lifetime = nil
24
+ # Enable local session lifetime to keep yourself "logged in" to your own server
25
+ # If set to eg:
26
+ # config.local_session_lifetime = 30.days
27
+ # then you will only have to enter your password every 30 days. Default is off (nil)
28
+ # config.local_session_lifetime = nil
24
29
  end
@@ -1,18 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  namespace :authorio do
2
- desc "Set password for initial Authorio user"
3
- require 'io/console'
4
+ desc 'Set password for initial Authorio user'
5
+ require 'io/console'
4
6
 
5
- def input_no_echo(prompt)
6
- print("\n#{prompt}")
7
- STDIN.noecho(&:gets).chop
8
- end
7
+ def input_no_echo(prompt)
8
+ print("\n#{prompt}")
9
+ $stdin.noecho(&:gets).chop
10
+ end
9
11
 
10
- task :password => :environment do
11
- passwd = input_no_echo("Enter new password: ")
12
- passwd_confirm = input_no_echo("Confirm password: ")
13
- user = Authorio::User.
14
- create_with(password: passwd, password_confirmation:passwd_confirm).
15
- find_or_create_by!(profile_path: '/')
16
- puts("\nPassword set")
17
- end
12
+ task password: :environment do
13
+ passwd = input_no_echo('Enter new password: ')
14
+ passwd_confirm = input_no_echo('Confirm password: ')
15
+ Authorio::User.create_with(password: passwd, password_confirmation: passwd_confirm)
16
+ .find_or_create_by!(profile_path: '/')
17
+ puts("\nPassword set")
18
+ end
18
19
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: authorio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.8.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Meckler
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-08-09 00:00:00.000000000 Z
11
+ date: 2022-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -16,20 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 6.1.3
20
- - - ">="
21
- - !ruby/object:Gem::Version
22
- version: 6.1.3.2
19
+ version: '7.0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
27
24
  - - "~>"
28
25
  - !ruby/object:Gem::Version
29
- version: 6.1.3
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: 6.1.3.2
26
+ version: '7.0'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: bcrypt
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -44,6 +38,20 @@ dependencies:
44
38
  - - "~>"
45
39
  - !ruby/object:Gem::Version
46
40
  version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: jbuilder
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
47
55
  - !ruby/object:Gem::Dependency
48
56
  name: factory_bot_rails
49
57
  requirement: !ruby/object:Gem::Requirement
@@ -125,8 +133,14 @@ files:
125
133
  - app/models/authorio/token.rb
126
134
  - app/models/authorio/user.rb
127
135
  - app/views/authorio/auth/authorization_interface.html.erb
136
+ - app/views/authorio/auth/issue_token.json.jbuilder
137
+ - app/views/authorio/auth/send_profile.json.jbuilder
138
+ - app/views/authorio/auth/verify_token.json.jbuilder
128
139
  - app/views/authorio/sessions/new.html.erb
140
+ - app/views/authorio/users/_profile.json.jbuilder
129
141
  - app/views/authorio/users/edit.html.erb
142
+ - app/views/authorio/users/show.html.erb
143
+ - app/views/authorio/users/verify.html.erb
130
144
  - app/views/layouts/authorio/main.html.erb
131
145
  - app/views/shared/_login_form.html.erb
132
146
  - config/routes.rb
@@ -136,6 +150,8 @@ files:
136
150
  - db/migrate/20210723161041_add_expiry_to_tokens.rb
137
151
  - db/migrate/20210726164625_create_authorio_sessions.rb
138
152
  - db/migrate/20210801184120_add_profile_to_users.rb
153
+ - db/migrate/20210817010101_change_path_to_username_in_users.rb
154
+ - db/migrate/20210831155106_add_code_challenge_to_requests.rb
139
155
  - lib/authorio.rb
140
156
  - lib/authorio/configuration.rb
141
157
  - lib/authorio/engine.rb
@@ -145,28 +161,29 @@ files:
145
161
  - lib/generators/authorio/install/install_generator.rb
146
162
  - lib/generators/authorio/install/templates/authorio.rb
147
163
  - lib/tasks/authorio_tasks.rake
148
- homepage:
164
+ homepage: https://blog.reiterate.app/tag/authorio/
149
165
  licenses:
150
166
  - MIT
151
167
  metadata:
152
168
  source_code_uri: https://github.com/reiterate-app/authorio
153
- post_install_message:
169
+ changelog_uri: https://github.com/reiterate-app/authorio/blob/master/CHANGELOG.md
170
+ post_install_message:
154
171
  rdoc_options: []
155
172
  require_paths:
156
173
  - lib
157
174
  required_ruby_version: !ruby/object:Gem::Requirement
158
175
  requirements:
159
- - - ">="
176
+ - - "~>"
160
177
  - !ruby/object:Gem::Version
161
- version: '0'
178
+ version: '3.0'
162
179
  required_rubygems_version: !ruby/object:Gem::Requirement
163
180
  requirements:
164
181
  - - ">="
165
182
  - !ruby/object:Gem::Version
166
183
  version: '0'
167
184
  requirements: []
168
- rubygems_version: 3.2.11
169
- signing_key:
185
+ rubygems_version: 3.3.10
186
+ signing_key:
170
187
  specification_version: 4
171
188
  summary: Indieauth Authentication endpoint for Rails
172
189
  test_files: []