Skip to content

Commit

Permalink
IndieAuth
Browse files Browse the repository at this point in the history
  • Loading branch information
mawise committed Aug 4, 2023
1 parent eac90f6 commit 9ea300a
Show file tree
Hide file tree
Showing 25 changed files with 844 additions and 2 deletions.
20 changes: 20 additions & 0 deletions app/assets/stylesheets/misc.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,23 @@ form.comments {
a.feed_entry_skip {
float: right;
}

img.indie-client-logo {
display: inline-block;
width: 80px;
height: 80px;
margin-bottom: 1em;
}

p.indie-client-logo {
text-align: center;
}

p.indie-client-name {
text-align: center;
font-weight: bold;
}

span.indie-auth-client {
font-weight: bold;
}
168 changes: 168 additions & 0 deletions app/controllers/indieauth_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
require 'indie_auth_scopes'
require 'indie_auth_request_obj'
require 'indie_auth_request_approval'
require 'indie_auth_profile_request'
require 'indie_auth_token_request'
require Rails.root.join('lib', 'indie_auth_client') # fixes LoadError in tests?

class IndieauthController < ApplicationController
before_action :authenticate_user!, except:[:profile, :token, :user, :metadata]
before_action :verify_admin, except:[:profile, :token, :user, :metadata]
protect_from_forgery with: :null_session, only:[:profile, :user]
skip_before_action :verify_authenticity_token, only:[:profile, :user]

def metadata
render json: {
"issuer" => generate_issuer,
"authorization_endpoint" => indie_authorization_endpoint_url,
"token_endpoint" => indie_token_endpoint_url,
"code_challenge_methods_supported" => ["S256"],
# "introspection_endpoint" => "TODO",
# "introspection_endpoint_auth_methods_supported" => "TODO",
# "revocation_endpoint" => "TODO",
# "revocation_endpoint_auth_methods_supported" => ["none"],
"scopes_supported" => IndieAuthScopes.valid_scopes,
"response_types_supported" => ["code"],
"grant_types_supported" => ["authorization_code"],
"service_documentation" => "https://indieauth.spec.indieweb.org"
# "userinfo_endpoint" => "TODO"
}
end

def authorization
@auth_request = IndieAuthRequestObj.new(params)
@client = IndieAuthClient.new(@auth_request.client_id)
if !@client.valid_redirect?(@auth_request.redirect_uri)
head :bad_request
end
end

### sample params: {"profile"=>"1", "read"=>"1", "create"=>"0", "state"=>"18JVlvTs7lOplQ1fE4nJ", "code_challenge"=>"ZmI2MTFhMjUwMTNiMTQ5MDMzZjU3NjE3MjIwNGRlYjQyMmY4NjY4OTMzY2JjNWIwOTAwMDhjM2FlODhlMGU3MQ==", "client_id"=>"http://localhost:4567/", "redirect_uri"=>"http://localhost:4567/redirect", "commit"=>"Approve", "controller"=>"indieauth", "action"=>"approval"}
def approval
if params[:commit] == 'Approve'
@request_approval = IndieAuthRequestApproval.new(params)
@indie_auth_request = current_user.indie_auth_requests.create!(
code: SecureRandom.urlsafe_base64(35), #creates ~47 characters
state: @request_approval.state,
code_challenge: @request_approval.code_challenge,
client_id: @request_approval.client_id,
scope: @request_approval.scope
)
redirect_url = @request_approval.redirect_uri
redirect_url += "?" + URI.encode_www_form({
"state" => @indie_auth_request.state,
"code" => @indie_auth_request.code,
"iss" => generate_issuer
})
redirect_to redirect_url, allow_other_host: true
elsif params[:commit] == 'Deny'
flash[:alert] = "You denied the request"
redirect_to root
end
end

def profile
@profile_request = IndieAuthProfileRequest.new(params)
@authorization_request = IndieAuthRequest.find_by(code: @profile_request.code)
valid = is_auth_request_valid? @authorization_request, params
if valid
resp = {
"me" => "#{generate_issuer}/indieauth/user/#{@authorization_request.user.id}"
}
profile_resp = {}
if @authorization_request.scope.split(" ").include? "profile"
profile_resp["name"] = @authorization_request.user.name
profile_resp["url"] = resp["me"]
end
if @authorization_request.scope.split(" ").include? "email"
profile_resp["email"] = @authorization_request.user.email
end
unless profile_resp.empty?
resp["profile"] = profile_resp
end
@authorization_request.destroy!
render json: resp.to_json
else
head :unauthorized #401
end
end

def token
@token_request = IndieAuthTokenRequest.new(params)
@auth_request = IndieAuthRequest.find_by(code: @token_request.code)
valid = is_auth_request_valid? @auth_request, params
if valid and @auth_request.scope.empty?
head :bad_request # no tokens allowed for empty scope
elsif valid
@indie_auth_token = @auth_request.user.indie_auth_tokens.create!({
access_token: SecureRandom.urlsafe_base64(55), #creates ~74 characters
scope: @auth_request.scope,
client_id: @auth_request.client_id
})
resp = {
"me" => "#{generate_issuer}/indieauth/user/#{@auth_request.user.id}",
"access_token" => @indie_auth_token.access_token,
"token_type" => "Bearer",
"scope" => @indie_auth_token.scope
}
profile_resp = {} ## refactor? Same as profile response above.
if @auth_request.scope.split(" ").include? "profile"
profile_resp["name"] = @auth_request.user.name
profile_resp["url"] = resp["me"]
end
if @auth_request.scope.split(" ").include? "email"
profile_resp["email"] = @auth_request.user.email
end
unless profile_resp.empty?
resp["profile"] = profile_resp
end
@auth_request.destroy!
render json: resp.to_json
else
head :unauthorized #401
end
end

def token_destroy
token = current_user.indie_auth_tokens.find(params["token_id"])
token.destroy!
redirect_to request.referer
end

def user
# intentionally left blank
end

private
def generate_issuer
iss = request.protocol
if iss == "http://" and request.host_with_port.end_with? ":80"
iss += request.host
elsif iss == "https://" and request.host_with_port.end_with? ":443"
iss += request.host
else
iss += request.host_with_port
end
iss
end

## Note, destroys expired requests
def is_auth_request_valid? authorization_request, params
valid = false
if authorization_request.nil?
valid = false
elsif authorization_request.created_at > 10.minutes.ago and authorization_request.used < 1
if Base64.urlsafe_encode64(Digest::SHA256.digest(params["code_verifier"])).chomp("=") == authorization_request.code_challenge
valid = true
authorization_request.update!(used: 1)
else
logger.info("IndieAuth code_challenge #{authorization_request.code_challenge} didn't match code_verifier #{params['code_verifier']}")
valid = false
end
else
logger.info("IndieAuthRequest has expired")
valid = false
authorization_request.destroy!
end
end
end
5 changes: 5 additions & 0 deletions app/models/indie_auth_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class IndieAuthRequest < ApplicationRecord
belongs_to :user
validates :code_challenge, presence: true
validates :client_id, presence: true
end
5 changes: 5 additions & 0 deletions app/models/indie_auth_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class IndieAuthToken < ApplicationRecord
belongs_to :user
validates :access_token, presence: true
validates :client_id, presence: true
end
3 changes: 3 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class User < ApplicationRecord
has_many :feeds, dependent: :destroy
has_many :feed_entries, through: :feeds

has_many :indie_auth_requests, dependent: :destroy
has_many :indie_auth_tokens, dependent: :destroy

# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
Expand Down
22 changes: 21 additions & 1 deletion app/views/devise/registrations/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
rss_url = "#{scheme}//#{rss_user}:#{rss_pass}@#{host}/rss"
%>

<p title="RSS is feed of a web site. You can use this RSS link in a feed-reader app or your own Haven to follow updates from this Haven. Since Haven is private and requires you to login, the URL provided here is unique to you and includes secret credentials."><img src="/feed-icon.svg" style="width:1em"/> <%= rss_url %></p>
<p title="RSS is feed of a web site. You can use this RSS link in a feed-reader app or your own Haven to follow updates from this Haven. Since Haven is private and requires you to login, the URL provided here is unique to you and includes secret credentials. Note that not all feed readers support private feeds."><img src="/feed-icon.svg" style="width:1em"/> <%= rss_url %></p>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
Expand Down Expand Up @@ -49,6 +49,26 @@
</div>
<% end %>
<% if current_user.indie_auth_tokens.size > 0 %>
<h3>Access Tokens</h3>
<p>The following applications have access to your Haven</p>
<table>
<tr><th>Application</th><th>Permissions</th><th>Created</th><th>Action</th></tr>
<% current_user.indie_auth_tokens.each do |token| %>
<tr>
<td><%= token.client_id %></td>
<td><%= token.scope %></td>
<td><%= token.created_at.strftime("%b %-d, %Y") %></td>
<td><%= link_to 'Revoke',
destroy_token_path(token),
method: :delete,
data: { confirm: "Are you sure you want to revoke #{token.client_id}'s access to your Haven?"} %>
</td>
</tr>
<% end %>
</table>
<% end %>

<h3>Delete Account</h3>

<p><%= button_to "Delete my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %></p>
Expand Down
4 changes: 4 additions & 0 deletions app/views/indieauth/_client.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<p class="indie-client-logo"><img class="indie-client-logo" src="<%=@client.logo%>"/></p>
<p class="indie-client-name"><%=@client.name%></p>
<p>DEBUG</p>

25 changes: 25 additions & 0 deletions app/views/indieauth/authorization.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<p>You are logged in as <%= current_user.name %> with email <%= current_user.email %> </p>

<% render 'client' %>
<% if @auth_request.scope.size > 0 %>
<p><span class="indie-auth-client"><%= @auth_request.client_id %></span> is requesting access to your Haven. It it requesting permision to take the following actions on your behalf. You can select each permission you want to grant individually. Removing a permission will prevent the application from taking that action.</p>
<% else %>
<p><span class="indie-auth-client"><%= @auth_request.client_id %></span> is requesting access to your Haven. By approving the request, it will learn that you are able to sign into this Haven, but will not be able to access anything else about your Haven.
<% end %>
<%= form_with url: "/indieauth/approval", method: :post do |form| %>
<% if @auth_request.scope.size > 0 %>
<% @auth_request.scope.each do |s| %>
<%= form.check_box s, checked: true %>
<%= form.label s, "#{s}: #{IndieAuthScopes.scope_description(s)}" %><br/>
<% end %>
<% end %>
<%= form.hidden_field :state, value: @auth_request.state %>
<%= form.hidden_field :code_challenge, value: @auth_request.code_challenge %>
<%= form.hidden_field :client_id, value: @auth_request.client_id %>
<%= form.hidden_field :redirect_uri, value: @auth_request.redirect_uri %>
<%= form.submit "Approve" %>
<%= form.submit "Deny" %>
<% end %>

1 change: 1 addition & 0 deletions app/views/indieauth/user.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!-- This page intentionally left blank -->
5 changes: 5 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
<link rel="icon" href="/icon.svg" type="image/svg+xml" sizes="any" />
<link rel="apple-touch-icon" href="/apple.png" /><!-- 180×180 -->
<link rel="manifest" href="/manifest.webmanifest" />

<link rel="indieauth-metadata" href="<%=indie_auth_metadata_url %>" />
<link rel="authorization_endpoint" href="<%=indie_authorization_endpoint_url %>" />
<link rel="token_endpoint" href="<%= indie_token_endpoint_url %>" />

<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% unless current_user.nil? %>
Expand Down
8 changes: 8 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
Rails.application.routes.draw do

get 'indieauth/metadata', to: 'indieauth#metadata', as: "indie_auth_metadata"
get 'indieauth/authorization', to: 'indieauth#authorization', as: "indie_authorization_endpoint"
post 'indieauth/authorization', to: 'indieauth#profile', as: "indie_auth_profile"
post 'indieauth/approval', to: 'indieauth#approval', as: "indie_auth_approval"
post 'indieauth/token', to: 'indieauth#token', as: "indie_token_endpoint"
delete 'indieauth/token/:token_id', to: 'indieauth#token_destroy', as: "destroy_token"
get 'indieauth/user/:user_id', to: 'indieauth#user'

resources :feeds, only: [:index, :create, :destroy]
get 'read', to: 'feeds#read'
get 'read/:id', to: 'feeds#read_feed', as: 'read_feed'
Expand Down
19 changes: 19 additions & 0 deletions db/migrate/20230720024044_create_indie_auth_requests.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class CreateIndieAuthRequests < ActiveRecord::Migration[6.1]
def change
create_table :indie_auth_requests do |t|
t.bigint "user_id"
t.string :code
t.string :state
t.string :code_challenge
t.string :client_id
t.string :scope
t.integer :used, default: 0

t.timestamps

t.index ["code"], name: "index_indie_auth_requests_on_code", unique: true
end
add_foreign_key :indie_auth_requests, :users

end
end
14 changes: 14 additions & 0 deletions db/migrate/20230722191607_create_indie_auth_tokens.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class CreateIndieAuthTokens < ActiveRecord::Migration[6.1]
def change
create_table :indie_auth_tokens do |t|
t.bigint :user_id
t.string :access_token
t.string :scope
t.string :client_id

t.timestamps

t.index ["access_token"], name: "index indie_auth_tokens_on_access_token", unique: true
end
end
end
26 changes: 25 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2023_04_20_030604) do
ActiveRecord::Schema.define(version: 2023_07_22_191607) do

# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
Expand Down Expand Up @@ -86,6 +86,29 @@
t.datetime "updated_at", null: false
end

create_table "indie_auth_requests", force: :cascade do |t|
t.bigint "user_id"
t.string "code"
t.string "state"
t.string "code_challenge"
t.string "client_id"
t.string "scope"
t.integer "used", default: 0
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["code"], name: "index_indie_auth_requests_on_code", unique: true
end

create_table "indie_auth_tokens", force: :cascade do |t|
t.bigint "user_id"
t.string "access_token"
t.string "scope"
t.string "client_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["access_token"], name: "index indie_auth_tokens_on_access_token", unique: true
end

create_table "likes", force: :cascade do |t|
t.string "reaction", default: "👍"
t.datetime "created_at", null: false
Expand Down Expand Up @@ -151,6 +174,7 @@
add_foreign_key "comments", "users", column: "author_id"
add_foreign_key "feed_entries", "feeds"
add_foreign_key "feeds", "users"
add_foreign_key "indie_auth_requests", "users"
add_foreign_key "likes", "posts"
add_foreign_key "likes", "users"
add_foreign_key "login_links", "users"
Expand Down
Loading

0 comments on commit 9ea300a

Please sign in to comment.