Browse API 1 documentation
OAuth integrations
Direct and brokered OAuth flows, PKCE material, and broker trust requirements.
Grove can authenticate a visible Sprig or composer utility with a remote service through the system authentication UI. Mango owns the callback, state, and PKCE material so an extension never has to embed a client secret or receive a password.
Use the oauth.authorize capability for the user-visible authorization step. Token exchange and authenticated API requests still use ordinary fetch when the provider supports the extension origin, or the separately granted network.request bridge when native HTTPS transport is required.
Declare the access
A direct integration with one known service should request exact origins:
{
"capabilities": ["oauth.authorize", "network.request", "storage.secure"],
"hostPermissions": [
"https://identity.example.com",
"https://api.example.com"
]
}
oauth.authorize opens the system authorization session. network.request is optional and should be requested only when the token or API endpoint does not support browser CORS. storage.secure enables the Keychain-backed grove.secrets namespaces used for access and refresh tokens.
An integration for user-selected self-hosted servers may need https://*. That broad grant is a conspicuous consent item and should not replace exact origins for a service known at package time. Validate the configured HTTPS server again before every request.
The OAuth provider must accept this exact redirect URI:
mango://grove/oauth
Keep the provider’s existing web and mobile redirects. Add Mango’s URI as another allowed redirect; do not replace the redirects used by the provider’s own clients.
Choose the flow
Use a direct flow when the extension can construct the provider’s authorization URL itself. Mango adds and protects the callback, state, response type, and PKCE parameters.
Use a brokered flow when the service must receive Mango’s callback, state, and PKCE challenge before it can create the real authorization URL. Immich uses this shape because its API brokers whichever OAuth or OpenID Connect provider the server administrator configured.
Both flows end with a short-lived authorization code and a host-generated PKCE verifier. Their trust boundaries differ: the direct flow inserts Mango’s parameters into the authorization URL, while the brokered flow uses the broker URL unchanged. Mango does not check that a brokered URL includes the prepared callback, state, and S256 challenge. The broker must preserve those values and the provider must enforce PKCE at exchange; callback state validation alone does not establish that PKCE was used. See the authorization reference.
Direct Authorization Code and PKCE
Create the provider URL with its public client identifier, scopes, and provider-specific parameters. Do not add redirect_uri, state, response_type, code_challenge, or code_challenge_method; Mango replaces those security-sensitive parameters:
const authorizationURL = new URL('https://identity.example.com/oauth/authorize')
authorizationURL.searchParams.set('client_id', 'public-client-id')
authorizationURL.searchParams.set('scope', 'openid profile offline_access')
const authorization = await grove.oauth.authorize(authorizationURL.toString())
// authorization.code
// authorization.codeVerifier
// authorization.redirectURI === 'mango://grove/oauth'
// authorization.callbackURL
Mango validates the initial HTTPS origin against hostPermissions, opens ASWebAuthenticationSession, and resolves the promise only after the fixed callback path and random state validate. Redirects performed by the provider inside that system session may cross identity-provider origins; they do not widen the extension WebView’s network access.
Exchange the code using the same redirect URI and verifier:
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'public-client-id',
code: authorization.code,
redirect_uri: authorization.redirectURI,
code_verifier: authorization.codeVerifier
})
const response = await fetch('https://identity.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
credentials: 'omit',
redirect: 'error'
})
if (!response.ok) throw new Error(`Sign-in failed with HTTP ${response.status}`)
const token = await response.json()
await grove.secrets.application.set('oauth-session', JSON.stringify(token))
Use a public OAuth client with PKCE. A client secret packaged in JavaScript is not secret. If CORS prevents the exchange, send the same bounded request through grove.network.request after requesting that capability and declaring the token endpoint’s origin.
Brokered PKCE
Prepare a one-use host transaction before asking the service for its authorization URL:
const transaction = await grove.oauth.prepare()
// transactionID
// redirectURI
// state
// codeChallenge
// codeChallengeMethod === 'S256'
Send only the values the broker needs. Mango keeps the verifier private:
const start = await grove.network.request(`${server}/api/oauth/authorize`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
redirectUri: transaction.redirectURI,
state: transaction.state,
codeChallenge: transaction.codeChallenge
})
})
if (!start.ok) throw new Error(`Authorization setup failed with HTTP ${start.status}`)
const payload = start.json()
if (!payload || typeof payload.url !== 'string') throw new Error('The service returned no authorization URL.')
const authorization = await grove.oauth.authorize(payload.url, {
transactionID: transaction.transactionID
})
After Mango validates the callback, send the complete callback URL, original state, and verifier to the broker’s callback endpoint. Do not reconstruct the callback from authorization.code; brokers may require other callback parameters:
const finish = await grove.network.request(`${server}/api/oauth/callback`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
url: authorization.callbackURL,
state: transaction.state,
codeVerifier: authorization.codeVerifier
})
})
if (!finish.ok) throw new Error(`Sign-in completion failed with HTTP ${finish.status}`)
const session = finish.json()
await grove.secrets.application.set('service-session', JSON.stringify(session))
The Immich Photos composer extension uses this flow against the user’s selected Immich server. Immich remains responsible for its configured identity provider; the extension contains no Authentik-, Keycloak-, or Authelia-specific code.
See the complete tagged implementation in immich-composer v0.1.0.
System-session behavior
By default, the system authorization session may reuse the provider’s normal browser cookies. This matches ordinary OAuth sign-in and lets the provider offer its usual account chooser.
Pass { prefersEphemeralSession: true } only when an isolated session is part of the integration’s intended UX:
await grove.oauth.authorize(url, {
transactionID: transaction.transactionID,
prefersEphemeralSession: true
})
An ephemeral session avoids silently inheriting an unrelated browser account, but it also prevents the normal shared sign-in convenience. Explain that choice in the extension UI when it may surprise the user.
Cancellation, retries, and concurrency
User cancellation, provider rejection, a malformed callback, state mismatch, invalid transaction, or another active app-wide authorization session rejects authorize(...). Handle the rejection as a recoverable sign-in result and let the user try again without restarting the extension.
A prepared transaction is single-use and is consumed even when authorization fails. Call prepare() again for every brokered retry. A later prepare() replaces an earlier unused transaction. Mango permits only one active OAuth authorization session across the app.
Do not show a permanent error-dismiss button solely for cancellation. Clear the previous recoverable error when the user starts another sign-in attempt.
Token storage and lifecycle
Store access tokens, refresh tokens, and service sessions in grove.secrets.application when one account should be shared by every instance of the extension. Use the network-scoped grove.secrets namespace only when each IRC network intentionally has a separate remote account.
Never put tokens in Web storage, URLs, diagnostics, cards, IRC messages, or composer text. Do not persist authorization codes, callback URLs, states, or PKCE verifiers after the exchange.
Treat a 401 from the service as an expired or revoked session. Refresh through the provider’s documented flow when a refresh token exists; otherwise clear the stored session and return to the signed-out UI. A Sign out action should revoke or close the remote session when supported, then remove the local Keychain value even if the remote request fails.
Security checklist
- Register
mango://grove/oauthas an exact redirect with the provider. - Use Authorization Code with S256 PKCE and a public client.
- Request exact
hostPermissionswhenever the service is known in advance. - Validate every authorization URL, token response, redirect, and API payload as untrusted input.
- Keep credentials in
grove.secretsand redact them from errors and logs. - Use
network.requestonly when native non-CORS HTTPS is necessary. - Start a new prepared transaction for every brokered retry.
- Keep the final user action explicit; successful sign-in is not permission to send IRC messages.
See JavaScript bridge for the exact facade return values, Storage and settings for Keychain namespaces, and Security model for the native trust boundary.