|
| 1 | +require "../helper" |
| 2 | +require "jwt" |
| 3 | + |
| 4 | +module PlaceOS::Auth |
| 5 | + # Reproduction for the dev-server 403 (PPT-2536): a *refreshed* access token |
| 6 | + # was losing its `scope` entirely. authly's refresh grant derives scope from |
| 7 | + # the request (absent on a standard refresh) or the auth code (absent on a |
| 8 | + # refresh) and otherwise returns "" — so `ClaimsProvider` emitted |
| 9 | + # `scope: [] `. Downstream rest-api's `can_read` needs the `public` scope |
| 10 | + # present, so every API call with a refreshed token 403'd |
| 11 | + # (e.g. GET /api/engine/v2/oauth_apps). |
| 12 | + # |
| 13 | + # The fix must carry the originally-granted scope across refresh, matching |
| 14 | + # the Ruby Doorkeeper behaviour. |
| 15 | + describe OAuth, tags: "refresh-scope" do |
| 16 | + decode = ->(token : String) { |
| 17 | + payload, _ = JWT.decode(token, ::Authly.config.public_key.as(String), JWT::Algorithm::RS256) |
| 18 | + payload |
| 19 | + } |
| 20 | + |
| 21 | + scopes_of = ->(claims : JSON::Any) { |
| 22 | + # UserJWT emits scope as an array of scope strings. |
| 23 | + claims["scope"].as_a.map(&.as_s) |
| 24 | + } |
| 25 | + |
| 26 | + login = -> { |
| 27 | + authority = ::PlaceOS::Model::Authority.find_by_domain("localhost").not_nil! |
| 28 | + user = ::PlaceOS::Model::Generator.user(authority).tap do |u| |
| 29 | + u.name = "Refresh Scope User" |
| 30 | + u.support = true |
| 31 | + u.sys_admin = true |
| 32 | + u.save! |
| 33 | + end |
| 34 | + password = "bcrypt-please-#{Random.rand(99999)}" |
| 35 | + user.password = password |
| 36 | + user.save! |
| 37 | + |
| 38 | + app = ::PlaceOS::Model::DoorkeeperApplication.new |
| 39 | + app.name = "refresh-scope-#{Random.rand(99999)}" |
| 40 | + app.redirect_uri = "https://app.example/cb" |
| 41 | + app.scopes = "public" |
| 42 | + app.owner_id = user.id.as(String) |
| 43 | + app.save! |
| 44 | + |
| 45 | + cookie = Spec.signin!(client, user, password) |
| 46 | + authorize = client.get( |
| 47 | + "/auth/authorize?response_type=code" \ |
| 48 | + "&client_id=#{URI.encode_www_form(app.uid.as(String))}" \ |
| 49 | + "&redirect_uri=#{URI.encode_www_form("https://app.example/cb")}&scope=public", |
| 50 | + headers: HTTP::Headers{"Host" => "localhost", "Cookie" => cookie}, |
| 51 | + ) |
| 52 | + code = URI::Params.parse(authorize.headers["Location"].split('?', 2).last)["code"] |
| 53 | + |
| 54 | + token = client.post("/auth/token", headers: HTTP::Headers{ |
| 55 | + "Host" => "localhost", "Content-Type" => "application/x-www-form-urlencoded", |
| 56 | + }, body: URI::Params.build { |fp| |
| 57 | + fp.add("grant_type", "authorization_code") |
| 58 | + fp.add("client_id", app.uid.as(String)) |
| 59 | + fp.add("client_secret", app.secret) |
| 60 | + fp.add("code", code) |
| 61 | + fp.add("redirect_uri", "https://app.example/cb") |
| 62 | + }) |
| 63 | + token.status_code.should eq 200 |
| 64 | + body = JSON.parse(token.body) |
| 65 | + {user, app, body["access_token"].as_s, body["refresh_token"].as_s} |
| 66 | + } |
| 67 | + |
| 68 | + refresh = ->(app : ::PlaceOS::Model::DoorkeeperApplication, refresh_token : String) { |
| 69 | + result = client.post("/auth/token", headers: HTTP::Headers{ |
| 70 | + "Host" => "localhost", "Content-Type" => "application/x-www-form-urlencoded", |
| 71 | + }, body: URI::Params.build { |fp| |
| 72 | + fp.add("grant_type", "refresh_token") |
| 73 | + fp.add("client_id", app.uid.as(String)) |
| 74 | + fp.add("client_secret", app.secret) |
| 75 | + fp.add("refresh_token", refresh_token) |
| 76 | + }) |
| 77 | + result.status_code.should eq 200 |
| 78 | + JSON.parse(result.body) |
| 79 | + } |
| 80 | + |
| 81 | + it "preserves scope across refresh (the 403 repro)" do |
| 82 | + user, app, access_token, refresh_token = login.call |
| 83 | + |
| 84 | + # sanity: the freshly-minted token carries the granted scope |
| 85 | + scopes_of.call(decode.call(access_token)).should eq ["public"] |
| 86 | + |
| 87 | + refreshed = refresh.call(app, refresh_token) |
| 88 | + claims = decode.call(refreshed["access_token"].as_s) |
| 89 | + |
| 90 | + # THE BUG: currently this is [] -> rest-api can_read fails -> 403 |
| 91 | + scopes_of.call(claims).should eq ["public"] |
| 92 | + ensure |
| 93 | + app.try &.destroy |
| 94 | + user.try &.destroy |
| 95 | + end |
| 96 | + |
| 97 | + it "preserves scope across refresh-of-refresh" do |
| 98 | + user, app, _at, refresh_token = login.call |
| 99 | + first = refresh.call(app, refresh_token) |
| 100 | + second = refresh.call(app, first["refresh_token"].as_s) |
| 101 | + scopes_of.call(decode.call(second["access_token"].as_s)).should eq ["public"] |
| 102 | + ensure |
| 103 | + app.try &.destroy |
| 104 | + user.try &.destroy |
| 105 | + end |
| 106 | + end |
| 107 | +end |
0 commit comments