Osy#betaRun it on Osyrin: osyrin.com

Reference / Config

Connect an account from a page — Security.OAuthConnectUrl

string Security.OAuthConnectUrl(string client, string secret, string returnTo, string account)

Lets an admin CONNECT an account the app then uses with nobody present — a support inbox a background poll reads — from a page: `Security.OAuthConnectUrl(client, secret, returnTo, account)` answers the provider's consent URL; the platform's callback keeps the refresh token the provider grants as the APP secret `secret`, where the app reads it as `Secret.X` like any other credential, and sends the admin back to `returnTo`. Only the account asked for is accepted; nobody is signed in by it.

preview1 example compiled by CIconfigoauthsecuritymail

Summary#

OAuthClient.X.ConnectedToken spends the SIGNED-IN person's grant, so it fails where nobody is signed in — a workflow step, a poll. An account the APP acts through (a support inbox) is connected once, by an admin, and kept as the app's own secret:

[Authorize(IsAdmin)]
public string ConnectSupport() {
  return Security.OAuthConnectUrl("GoogleMail", "SupportMailboxToken", "/settings/mailboxes", "[email protected]");
}
// the page:  action Connect() { Navigation.Open(ConnectSupport(), sameTab: true); }

Signature#

string Security.OAuthConnectUrl(
  string client,    // an app.OAuthClients entry with OAuthCapability.Connection — its Scopes are what is asked
  string secret,    // an app.Secrets name — where the granted refresh token is KEPT
  string returnTo,  // a path on this app ("/settings/mailboxes") — where the admin lands afterwards
  string account)   // the account they must connect AS ("" = any) — any other is refused

Needs use Osysharp.Oauth;. Server-only.

Description#

What happens#

  1. The function (behind the app's own authorization — [Authorize(IsAdmin)]) answers the provider's consent URL. It seals the client, the secret, the account, returnTo and the person who started it into a single-use state that expires in fifteen minutes.
  2. The page navigates there; the account's owner signs in to the provider and consents.
  3. The provider returns to the platform's OAuth callback. The platform exchanges the code (PKCE, the client secret), checks the account is the one asked for, and writes the refresh token into the secret — then sends the admin to returnTo with #oauth_connected=<secret> in the fragment, or #oauth_connect_error=<why> (the wrong account; no refresh token granted; the secret not declared). Nobody is signed in, and the token never reaches the browser.
  4. The app reads it as Secret.X — Security.IsSecretSet("X") says whether it is connected yet.

Why a secret#

Everything a credential needs is already true of a secret: encrypted at rest, unreadable by a page, confined so a function cannot return it, never recorded in a workflow's durable history, and leashed so a package reads only the secrets the app granted it. Keeping the grant there means a poll uses a connected inbox exactly as it would use one whose token an operator set by hand, and "reconnect" is simply connecting again.

Who may start it#

The app decides, with its own authorization on the function that calls this — the platform adds no rule of its own. A PACKAGE may aim it only at a secret the app granted it (use … { secret "X"; }), as for reading one.

The provider side#

The client's Scopes are what the consent screen asks for — for a Gmail inbox openid, email, https://www.googleapis.com/auth/gmail.readonly and …/gmail.send. The platform asks for offline access (Google: access_type=offline, prompt=consent), which is what yields a refresh token; a provider that grants none is refused with that reason. Register the platform's callback (https://<app's host>/api/oauth/callback) as a redirect URI.

Examples#

using Osysharp.Oauth;

[Role] enum AppRole { Authenticator, Admin }
[RoleGrant] entity RoleGrant { [Required] User Member; [Required] AppRole Role; security { allow read when IsAuthenticated; } }
policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Admin);

[Principal] entity User {
  [Unique, MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

app.Secrets = [ new Secret("GoogleMailSecret"), new Secret("SupportMailboxToken") ];

app.OAuthClients = [
  new OAuthClient("GoogleMail") {
    Provider     = OAuthProvider.Google,
    Capabilities = [OAuthCapability.Connection],
    ClientId     = "1234-abc.apps.googleusercontent.com",
    ClientSecret = Secret.GoogleMailSecret,
    Scopes       = ["openid", "email", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send"],
  },
];

/// The page's "Connect" button calls this; the inbox's owner consents, and the grant becomes SupportMailboxToken.
[Authorize(IsAdmin)]
public string ConnectSupport() {
  return Security.OAuthConnectUrl("GoogleMail", "SupportMailboxToken", "/settings/mailboxes", "[email protected]");
}

/// Whether the inbox is connected yet.
[Authorize(IsAdmin)]
public bool SupportConnected() {
  return Security.IsSecretSet("SupportMailboxToken");
}

See also#

Related

OAuth clients (app.OAuthClients)

`app.OAuthClients` declares the third-party OAuth providers your app uses — for signing users in (Login), for…

declaring secrets (app.Secrets)

`app.Secrets` declares the named secrets your app uses — API keys, tokens, client secrets. Each is `new…

Pushed mail — Gmail through Pub/Sub, Microsoft 365 through Graph notifications

A watched mailbox that can PUSH is told of new mail the moment it lands instead of being asked every minute: Gmail…

Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP)

An app WATCHES a mailbox — it is never a mail server. The mail arrives where it always did (a Google Workspace or…