feat: store app-only Graph credentials in the staff-api tenant (PPT-2000) - #442
Conversation
Bug found by the first dev e2e run — fixed in
|
Re-verified end to end on
|
|
…-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>
|
Pushed What an adversarial review of this stack foundPre-existing on master (not introduced here): both
The flow then builds an oauth strat pointing at the attacker's directory and writes it to New in #442, and the reason this became urgent: One thing that limits current exposure: the flow dies at step 1 unless The fix
I chose a server-side token over an HMAC because single-use needs server state anyway, and this avoids introducing a signing key. Verification status — please read before merging
Two smaller things the review raised that I have not changed, both in
Happy to fold those in here or split them out — say which you prefer. |
67a1e25 to
04b002f
Compare
…-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>
2f45c34 to
e45fcaa
Compare
e45fcaa to
0057728
Compare
The admin-consent flow registered the Bookings Visualiser application
with app-only Graph permissions (Calendars.ReadWrite, Group.Read.All,
User.Read.All) but never minted a secret for it and discarded its
client id - the consented access was unusable, and calendar credentials
still had to be entered by hand in Backoffice.
The visualiser registration now mints a client secret and the flow
writes {tenant, client_id, client_secret} into the staff-api tenant for
the domain (created when missing, updated otherwise - the flow owns the
domain's Microsoft configuration, as it already does for login_url and
outlook_config). Existing delegated tenants are switched to app-only;
delegated mode can be re-enabled in Backoffice. Creating the tenant
before the outlook step also fixes the silent no-op where outlook_config
had nothing to attach to on a fresh domain.
Credentials are encrypted at rest by the model (Level::NeverDisplay)
and surface on the progress page as a new 'Connect room calendar
access' step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flow looked up the staff-api tenant and built the Outlook add-in URLs from the host in the request. Backoffice can drive the integration for any domain, so integrating one authority while browsing another wrote the new Microsoft configuration onto the wrong tenant - observed on dev, where integrating the demo authority (azure-demo.placeos-dev.aca.im) overwrote the dev domain's own tenant (placeos-dev.aca.im), switching it off delegated access and replacing its calendar credentials. The authority record already supplied the domain used for the auth app's redirect URI; use it for the tenant lookup, the Outlook identifier URIs and the add-in URLs too. Only the consent callback URL still derives from the request host, where it has to - it must match the redirect URI registered on the management application. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The visualiser application was granted Calendars.ReadWrite, Group.Read.All and User.Read.All. Room mailboxes were therefore readable, but the Graph places API - which room discovery uses to enumerate rooms and room lists - returned 403, verified against the sandbox tenant with a provisioned app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-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>
Stacked on #441 (which stacks on #440). Closes the gap where the 1-click flow registered the app-only "PlaceOS Bookings Visualiser" application — with admin-consented
Calendars.ReadWrite/Group.Read.All/User.Read.Allapplication permissions — but never minted a secret for it and discarded its client id, so the consented app-only Graph access was unusable and calendar credentials still had to be typed into Backoffice → Admin → Staff API by hand.What changes
create_appnow mints a client secret for the visualiser app (sameaddPasswordcall the auth app already used, wrapped in the replication retry).{tenant, client_id, client_secret}into the staff-apiTenantfor the domain — created if the domain has none, updated otherwise. The model encrypts credentials at rest (Level::NeverDisplay) and validates them asOffice365Config.delegated: false). Rationale: the flow already owns the domain's Microsoft configuration (login_url,outlook_config) and the visualiser app exists precisely to provide app-only access. Delegated mode remains one toggle away in Backoffice.create_outlook_config, which previously silently no-oped on a fresh domain (Tenant.find_by?→ log + return).rescue flow_errornaming, verbose block).Verification
crystal build --no-codegenclean on 1.21; ameba clean on all touched files.spec/tenant_consent_spec.cr): create-when-missing and switch-existing-delegated — both assert the stored ciphertext decrypts to exactly the written credential and thatplace_calendar_clientbuilds from the row.The triggers service (
GraphSecretExpiryFinder) will start probing the stored credential for secret-expiry metadata — that's existing behaviour for any non-delegated office365 tenant.🤖 Generated with Claude Code