Consent Page
Summary
Core owns the OAuth endpoints, but the consent UI belongs to your client app. When a client asks for authorization and no consent has been given yet, Core redirects the browser to your app. Your page shows who is connecting and what access they want, records the user's decision, and sends the browser back. This document is the contract that page must implement.
When does it fire?
Core redirects to your consent page whenever GET /connect/authorize runs without a valid consent cookie:
Client → /connect/authorize → (no consent cookie) → {Client:BaseUrls[0]}/oauth/consent?returnUrl=<authorize URL>
returnUrl is the original authorize request, URL-encoded. After the user decides, your page navigates back to it with the decision appended, and Core completes or refuses the request.
What the page must do
1. Validate returnUrl
Treat returnUrl as untrusted input — it is an open-redirect vector. Accept it only when:
- it parses as an absolute URI,
- its origin equals your API's origin, and
- its path is exactly
/connect/authorize(no extra segments).
Reject anything else with an error instead of navigating to it.
2. Require a signed-in user
If the visitor is anonymous, send them to your sign-in page carrying the same returnUrl, then return to the consent page. The grant lands in whichever tenant the user signs into, so show the account and tenant on the consent screen.
3. Fetch client info
Parse client_id, scope, and redirect_uri from the returnUrl query, then call:
GET {returnUrl origin}/api/oauth/consent/info?clientId=<client_id>&scope=<scope>
Authorization: Bearer <user's Core JWT>
Response:
| Field | Description |
|---|---|
clientId |
The verified client id |
clientName |
Display name from the client's registration |
scopes |
Array of { name, description } for each requested scope |
Call the consent endpoints on the returnUrl's origin, not your configured API base URL. The consent cookie must be set on the exact host the authorize request will be replayed against — they differ when a tunnel or proxy sits in front of the same API.
4. Render the decision
- Show the client name and the redirect host (the
redirect_urihost) — that is where the code is really sent; the name is freely chosen by the client. - Present the data scopes (
benevia.read/benevia.write/benevia.delete) as cumulative access levels and default to the least-privilege option. - Hide plumbing scopes such as
offline_access.
5. Approve
POST {returnUrl origin}/api/oauth/consent/approve
Authorization: Bearer <user's Core JWT>
Content-Type: application/json
{ "clientId": "<client_id>", "scopes": [ "benevia.read", "benevia.write" ] }
Send the request with browser credentials — the response sets the consent cookie. Then navigate (full page load) to:
{returnUrl}&benevia_consent=approve
Core reads the cookie, creates the grant, and returns the authorization code to the client.
The consent cookie is
__Host-BeneviaOAuth: HttpOnly, Secure, SameSite=None, valid for 10 minutes, and bound to the client id and chosen scopes. Approving one client never approves another.
6. Deny
Denying needs no session or API call — navigate straight to:
{returnUrl}&benevia_consent=deny
Core refuses the request and the client receives access_denied.
Reference implementation
The Benevia ERP frontend (Hatchery) implements this contract in Pages/OAuthConsent.razor with the returnUrl guard in Services/OAuthReturnUrl.cs. It is a good template: cumulative access-level cards capped to the requested scopes, least-privilege default, redirect-host display, and a switch-account action that preserves returnUrl.
Notes
- Only data scopes are read from the approve request; non-data scopes are decided from the authorize request itself.
- The authorize endpoint fails closed: consent for client A never satisfies a request from client B, and a missing or expired cookie just re-prompts.
- See Connected apps for revoking what was approved here.