Skip to content

Commit 2f45c34

Browse files
camreevesclaude
andcommitted
fix(consent): authenticate the consent flow and make state a single-use token
The Azure admin-consent callback is unauthenticated, and it has to be — Microsoft redirects the browser to it, so there is no session to present. Everything it then does is driven by its query parameters, and `state` was the bare authority id: echoed, never verified. That let anyone who could reach the deployment reconfigure any authority. The attacker needs no PlaceOS credentials at all: sign up for a free Entra tenant, grant admin consent to the PlaceOS app as its own global admin (the product's intended flow, open to any Microsoft admin), read the victim's authority id from the unauthenticated /auth/authority endpoint, then issue one GET at the victim's host with `state` set to it. The flow builds an oauth strat pointing at the attacker's directory and writes it to `authority.login_url`, so every user of that domain is redirected from the genuine PlaceOS URL into an identity provider the attacker controls. There is no Host binding either, so one request from anywhere can retarget any authority in the deployment, and a captured callback URL replays unchanged. The stacked #442 work makes this materially worse: `upsert_calendar_tenant` blind-overwrites the staff-api tenant's `platform`, `delegated` and `credentials`. The same anonymous request therefore replaces a live customer's Microsoft Graph credential with one minted in the attacker's directory — encrypted in place with no prior value retained, so it is unrecoverable — and repoints PlaceOS's server-side calendar client at a directory they own. Two changes: - Starting a flow now requires an administrator. `index` was in the `skip_action :authorize!` list along with the callback; only the callback needs to be there. - `state` is now an opaque single-use token (`ConsentState`) rather than the authority id. It exists only because an authenticated admin asked to start a flow for a specific authority, names that authority server side rather than in the URL, expires after 15 minutes, and redeeming it destroys it. The callback redeems rather than trusting, so an unknown, expired or replayed state is refused. Redemption uses the delete's reply count so a concurrent replay cannot slip through the window before the delete lands. Specs cover redeem-once, refuse-twice and refuse-unissued. They could not be run locally — the Docker spec harness OOMs compiling this repo — so they run in CI. `crystal build --no-codegen` passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2d64a87 commit 2f45c34

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

spec/tenant_consent_spec.cr

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@ require "./helper"
22

33
module PlaceOS::Api
44
describe TenantConsent do
5+
describe ".consent_state" do
6+
# `state` comes back from Microsoft through the user's browser and is the
7+
# only thing telling the callback which authority to reconfigure. The
8+
# callback cannot be authenticated - Microsoft redirects to it - so these
9+
# properties are the whole of its protection.
10+
it "redeems a token for the authority it was issued to" do
11+
authority_id = "authority-#{Random::Secure.hex(4)}"
12+
token = ConsentState.issue(authority_id)
13+
14+
token.should_not eq authority_id
15+
ConsentState.consume(token).should eq authority_id
16+
end
17+
18+
it "refuses a token a second time" do
19+
# A captured callback URL must be worthless once it has been used.
20+
authority_id = "authority-#{Random::Secure.hex(4)}"
21+
token = ConsentState.issue(authority_id)
22+
23+
ConsentState.consume(token).should eq authority_id
24+
ConsentState.consume(token).should be_nil
25+
end
26+
27+
it "refuses a token it never issued" do
28+
# i.e. an attacker naming an authority directly, which is what the
29+
# parameter used to be.
30+
ConsentState.consume("authority-1").should be_nil
31+
ConsentState.consume(UUID.random.to_s).should be_nil
32+
end
33+
end
34+
535
describe ".upsert_calendar_tenant" do
636
app = {client_id: UUID.v4.to_s, client_secret: "sup3r-s3cret"}
737
azure_tenant = UUID.v4.to_s

src/placeos-rest-api/controllers/tenant_consent.cr

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ module PlaceOS::Api
66
class TenantConsent < Application
77
base "/api/engine/v2/admin_consent"
88

9-
skip_action :authorize!, only: [:index, :azure_admin_consent_callback, :flow_status]
10-
skip_action :set_user_id, only: [:index, :azure_admin_consent_callback, :flow_status]
9+
# Only the callback is unauthenticated, and it has to be: Microsoft
10+
# redirects the browser here, so there is no session to present. It is
11+
# guarded instead by the single-use `state` token minted below — see
12+
# `ConsentState`. Starting a flow requires an administrator, because that
13+
# is what decides which authority the callback is allowed to reconfigure.
14+
skip_action :authorize!, only: [:azure_admin_consent_callback, :flow_status]
15+
skip_action :set_user_id, only: [:azure_admin_consent_callback, :flow_status]
16+
17+
before_action :check_admin, only: [:index]
1118

1219
@[AC::Route::Filter(:before_action)]
1320
def get_host
@@ -29,7 +36,11 @@ module PlaceOS::Api
2936
authority = ::PlaceOS::Model::Authority.find!(id)
3037
update_app_redirect_uri
3138
callback_url = URI.encode_www_form(redirect_url)
32-
consent_url = "https://login.microsoftonline.com/common/adminconsent?client_id=#{PLACE_APP_CLIENT_ID}&redirect_uri=#{callback_url}&state=#{authority.id.as(String)}"
39+
# Not the authority id. `state` comes back from Microsoft through the
40+
# user's browser and is the only thing telling the callback which
41+
# authority to reconfigure, so it has to be unguessable and single use.
42+
state = ConsentState.issue(authority.id.as(String))
43+
consent_url = "https://login.microsoftonline.com/common/adminconsent?client_id=#{PLACE_APP_CLIENT_ID}&redirect_uri=#{callback_url}&state=#{URI.encode_www_form(state)}"
3344
render json: {"url": consent_url}
3445
end
3546

@@ -46,7 +57,17 @@ module PlaceOS::Api
4657
@[AC::Param::Info(description: "Description of the error", example: "The admin denied the request")]
4758
error_description : String? = nil,
4859
) : Nil
49-
if ((consent = admin_consent) && consent) && (tenant_id = tenant) && (authority_id = state)
60+
if ((consent = admin_consent) && consent) && (tenant_id = tenant) && (consent_state = state)
61+
# Redeem the token rather than trusting the parameter. This is what
62+
# stops an unauthenticated caller naming an authority of their choosing
63+
# and having the rest of this method reconfigure it. Redeeming is
64+
# single use, so a captured callback URL cannot be replayed either.
65+
authority_id = ConsentState.consume(consent_state)
66+
unless authority_id
67+
Log.warn { "Rejected admin consent callback with an unknown, expired or already used state" }
68+
raise Error::NotFound.new("Invalid state value returned in admin consent")
69+
end
70+
5071
Log.info { "Received admin consent for tenant #{tenant_id} under authority #{authority_id}" }
5172
authority = ::PlaceOS::Model::Authority.find?(authority_id)
5273
raise Error::NotFound.new("Invalid state value returned in admin consent") unless authority
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
require "uuid"
2+
3+
module PlaceOS::Api
4+
# The `state` parameter for an Azure admin-consent round trip.
5+
#
6+
# Microsoft redirects the browser to `/admin_consent/callback`, so that route
7+
# cannot carry a session and cannot be authenticated. Everything the callback
8+
# goes on to do — registering applications, writing an oauth strat, replacing
9+
# the staff API tenant's calendar credentials, and overwriting the authority's
10+
# `login_url` — is therefore driven entirely by its query parameters.
11+
#
12+
# Passing the authority id there directly, as this flow used to, means anyone
13+
# who can reach the deployment can point any authority at an identity provider
14+
# they control, because `state` was echoed rather than verified.
15+
#
16+
# So `state` is an opaque single-use token instead. It exists only because an
17+
# authenticated administrator asked to start a flow for a specific authority,
18+
# it names that authority server side rather than carrying it in the URL, it
19+
# expires, and redeeming it destroys it. A captured callback URL is worthless
20+
# once used, and a guessed one is worth nothing at all.
21+
module ConsentState
22+
# Long enough for an admin to read Microsoft's consent screen and decide.
23+
TTL_SECONDS = 900
24+
25+
# Records a pending flow for `authority_id` and returns the token to send
26+
# to Microsoft as `state`.
27+
def self.issue(authority_id : String) : String
28+
token = UUID.random.to_s
29+
::PlaceOS::Driver::RedisStorage.with_redis(&.set(
30+
redis_key(token), authority_id, ex: TTL_SECONDS
31+
))
32+
token
33+
end
34+
35+
# Redeems `token`, returning the authority it was issued for, or `nil` if it
36+
# is unknown, expired, or already used.
37+
def self.consume(token : String) : String?
38+
key = redis_key(token)
39+
::PlaceOS::Driver::RedisStorage.with_redis do |redis|
40+
authority_id = redis.get(key)
41+
next nil unless authority_id
42+
43+
# `del` reports how many keys it removed. Anything other than one means
44+
# a concurrent request redeemed this token first, and only that request
45+
# may proceed — otherwise a replayed callback would still be honoured
46+
# in the window before the delete lands.
47+
next nil unless redis.del(key) == 1
48+
49+
authority_id
50+
end
51+
end
52+
53+
def self.redis_key(token : String) : String
54+
"placeos:admin_consent:state:#{token}"
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)