Skip to content
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
196 changes: 196 additions & 0 deletions spec/user_resource_token_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
require "http/server"

require "./helper"

module PlaceOS::Model
# Minimal OAuth2 provider token endpoint for exercising the refresh flow
class MockTokenServer
getter requests = [] of URI::Params
property response_status = HTTP::Status::OK
property response_body = %({"access_token": "new-access", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "new-refresh"})

getter port : Int32

def initialize
@server = HTTP::Server.new do |context|
@requests << URI::Params.parse(context.request.body.try(&.gets_to_end) || "")
context.response.status = response_status
context.response.content_type = "application/json"
context.response.print response_body
end
@port = @server.bind_tcp("127.0.0.1", 0).port
spawn { @server.listen }
Fiber.yield
end

def close
@server.close
end
end

describe "User#resource_token" do
server = uninitialized MockTokenServer
authority = uninitialized Authority

Spec.before_each do
server = MockTokenServer.new
authority = Generator.authority(domain: "http://resource-token.#{RANDOM.hex(4)}.dev").save!
strategy = Generator.oauth_strat(authority)
strategy.site = "http://127.0.0.1:#{server.port}"
strategy.token_url = "/oauth/token"
strategy.save!
end

Spec.after_each do
server.close
end

it "returns a non-expiring access token as is" do
user = Generator.user(authority)
user.access_token = "stored-access"
user.expires = false
user.save!

token = user.resource_token(authority)
token.token.should eq "stored-access"
token.expires.should be_nil
server.requests.should be_empty
end

it "returns an access token that is not close to expiry" do
expiry = 1.hour.from_now.to_unix
user = Generator.user(authority)
user.access_token = "stored-access"
user.expires = true
user.expires_at = expiry
user.save!

token = user.resource_token(authority)
token.token.should eq "stored-access"
token.expires.should eq expiry
server.requests.should be_empty
end

it "defaults to the user's authority" do
user = Generator.user(authority)
user.access_token = "stored-access"
user.save!

user.resource_token.token.should eq "stored-access"
end

it "refreshes and saves a token that is close to expiry" do
user = Generator.user(authority)
user.access_token = "stored-access"
user.refresh_token = "stored-refresh"
user.expires = true
user.expires_at = 2.minutes.from_now.to_unix
user.save!

token = user.resource_token(authority)
token.token.should eq "new-access"
expires = token.expires.not_nil!
expires.should be_close(1.hour.from_now.to_unix, 10)

server.requests.size.should eq 1
server.requests.first["grant_type"].should eq "refresh_token"
server.requests.first["refresh_token"].should eq "stored-refresh"

saved = User.find!(user.id.as(String))
saved.access_token.should eq "new-access"
saved.refresh_token.should eq "new-refresh"
saved.expires_at.should eq expires
end

it "refreshes when there is no stored access token" do
user = Generator.user(authority)
user.refresh_token = "stored-refresh"
user.expires = true
user.save!

user.resource_token(authority).token.should eq "new-access"
end

it "keeps the existing refresh token when the provider doesn't rotate it" do
server.response_body = %({"access_token": "new-access", "token_type": "Bearer", "expires_in": 3600})
user = Generator.user(authority)
user.refresh_token = "stored-refresh"
user.expires = true
user.save!

user.resource_token(authority)
User.find!(user.id.as(String)).refresh_token.should eq "stored-refresh"
end

it "uses the authority's configured oauth strategy" do
other = MockTokenServer.new
begin
other.response_body = %({"access_token": "configured-access", "token_type": "Bearer", "expires_in": 3600})
configured = Generator.oauth_strat(authority)
configured.site = "http://127.0.0.1:#{other.port}"
configured.save!
authority.internals["oauth-strategy"] = JSON::Any.new(configured.id.as(String))
authority.save!

user = Generator.user(authority)
user.refresh_token = "stored-refresh"
user.expires = true
user.save!

user.resource_token(authority).token.should eq "configured-access"
server.requests.should be_empty
ensure
other.close
end
end

it "falls back to the current token when refresh fails and it hasn't expired" do
server.response_status = HTTP::Status::BAD_REQUEST
server.response_body = %({"error": "invalid_grant"})
expiry = 2.minutes.from_now.to_unix
user = Generator.user(authority)
user.access_token = "stored-access"
user.refresh_token = "stored-refresh"
user.expires = true
user.expires_at = expiry
user.save!

token = user.resource_token(authority)
token.token.should eq "stored-access"
token.expires.should eq expiry
end

it "raises when refresh fails and the token has expired" do
server.response_status = HTTP::Status::BAD_REQUEST
server.response_body = %({"error": "invalid_grant"})
user = Generator.user(authority)
user.access_token = "stored-access"
user.refresh_token = "stored-refresh"
user.expires = true
user.expires_at = 1.minute.ago.to_unix
user.save!

expect_raises(OAuth2::Error) { user.resource_token(authority) }
end

it "raises NoResourceToken without a refresh token" do
user = Generator.user(authority)
user.access_token = "stored-access"
user.expires = true
user.expires_at = 1.minute.ago.to_unix
user.save!

expect_raises(Error::NoResourceToken, "no refresh token available") { user.resource_token(authority) }
end

it "raises NoResourceToken without an oauth strategy" do
bare = Generator.authority(domain: "http://no-strategy.#{RANDOM.hex(4)}.dev").save!
user = Generator.user(bare)
user.refresh_token = "stored-refresh"
user.expires = true
user.save!

expect_raises(Error::NoResourceToken, "no oauth configuration found") { user.resource_token(bare) }
end
end
end
4 changes: 4 additions & 0 deletions src/placeos-models/error.cr
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ module PlaceOS
class NoScope < Error
end

# The user has no usable SSO resource token (see `User#resource_token`)
class NoResourceToken < Error
end

class MalformedFilter < Error
def initialize(filters : Array(String)?)
super("One or more invalid regexes: #{filters}")
Expand Down
75 changes: 75 additions & 0 deletions src/placeos-models/user.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require "CrystalEmail"
require "crypto/bcrypt/password"
require "digest/md5"
require "oauth2"
require "./base/model"
require "./api_key"
require "./metadata"
Expand Down Expand Up @@ -394,6 +395,80 @@ module PlaceOS::Model
self.password_digest = digest.to_s
new_password
end

# SSO resource tokens
###############################################################################################

record ResourceToken, token : String, expires : Int64? do
include JSON::Serializable
end

# Returns a token for delegated access to the user's SSO resources, such as
# the MS Graph API or Google APIs, in the context of the user.
#
# The stored access token is refreshed (and saved) when it expires within
# five minutes. If the refresh fails, a token that hasn't actually expired
# yet is still returned.
#
# Raises `Model::Error::NoResourceToken` when there is nothing to return or refresh with.
def resource_token(authority : Authority? = self.authority) : ResourceToken
expired = true

if token = access_token.presence
return ResourceToken.new(token, nil) unless expires

if expiry = expires_at
expires_at_time = Time.unix(expiry)
return ResourceToken.new(token, expiry) if 5.minutes.from_now < expires_at_time

# Allow for clock drift
expired = 15.seconds.from_now > expires_at_time
end
end

refresh = refresh_token.presence
raise Model::Error::NoResourceToken.new("no refresh token available") unless refresh

strategy = authority.try { |auth| oauth_strategy(auth) }
raise Model::Error::NoResourceToken.new("no oauth configuration found") unless strategy

begin
token = oauth_client(strategy).get_access_token_using_refresh_token(refresh, strategy.scope)

self.access_token = token.access_token
self.refresh_token = token.refresh_token if token.refresh_token
self.expires_at = Time.utc.to_unix + token.expires_in.not_nil!
save!

ResourceToken.new(token.access_token, expires_at)
rescue error
Log.warn(exception: error) { "failed to refresh resource token for user #{id}" }
raise error if expired
ResourceToken.new(access_token.as(String), expires_at)
end
end

private def oauth_strategy(authority : Authority) : OAuthAuthentication?
if strategy_id = authority.internals["oauth-strategy"]?.try(&.as_s?)
OAuthAuthentication.find?(strategy_id)
else
OAuthAuthentication.where(authority_id: authority.id).first?
end
end

private def oauth_client(strategy : OAuthAuthentication) : OAuth2::Client
# `token_url` may be relative to the provider's `site`
token_uri = URI.parse(strategy.site).resolve(strategy.token_url)

OAuth2::Client.new(
token_uri.hostname.as(String),
strategy.client_id,
strategy.client_secret,
port: token_uri.port,
scheme: token_uri.scheme || "https",
token_uri: token_uri.request_target,
)
end
end
end

Expand Down
Loading