Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Security

OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending

string Security.VerifyPendingOAuthEmail(string token) · bool Security.LinkOAuthFromPending(Guid userId, string token)

The two server-side calls that finish an OAuth sign-in on your own pages. When someone signs in with a provider (Google, …) and has no account yet — or a local account that isn't linked — the callback hands your app a sealed token. `VerifyPendingOAuthEmail` reads the provider-VERIFIED email out of that token (the email is inside the seal, never a value the browser can set), and `LinkOAuthFromPending` records the identity link. You provision through your own `[AuthMethod]`, so the account model stays yours.

stable3 examples compiled by CIsecurityauthenticationoauth

Summary#

When a visitor signs in with an OAuth provider and there is no account for them yet, the provider sign-in cannot be the whole story — you still need their name, or a decision to link. So the platform validates the provider identity on the server, seals the verified email into a short-lived token, and sends the visitor to a page of yours with that token. Your page collects what it needs and calls an [AuthMethod] that finishes the job — creating a proper, no-password account and recording the identity link:

[AuthMethod]
string CompleteOAuthSignup(string pendingToken, string firstName, string lastName) {
  var email = Security.VerifyPendingOAuthEmail(pendingToken);   // the VERIFIED email, read from inside the sealed token
  if (email == "") { return ""; }                               // invalid / expired / unverified → no ticket
  var u = new User { Email = email, FirstName = firstName, LastName = lastName };
  Security.LinkOAuthFromPending(u.Id, pendingToken);            // record the identity link (the provider subject stays sealed)
  return Security.IssueJwt(u.Id, u.Email);                      // signed up == signed in
}

The email is a return value, not a parameter — that is the whole point. A browser could type any email into a form, but it cannot forge the sealed token, so VerifyPendingOAuthEmail hands back only an address the provider actually verified. Your function trusts it because it came out of the seal, not off the wire.

Signature#

string Security.VerifyPendingOAuthEmail(string token)   // the provider-verified email inside the token, or "" if it is
                                                        // invalid, expired, for another app, or not provider-verified
bool   Security.LinkOAuthFromPending(Guid userId, string token)   // record the identity link for this user; false on a bad token

Both are server-only (like Security.IssueJwt): the token is sealed with your app's platform-managed key, so only the server can open it. They run inside an [AuthMethod] — a function a signed-out visitor may call — wired into app.AuthBootstrap as OAuthSignup / OAuthLink.

Description#

What happens when a provider sign-in comes back?#

A provider sign-in resolves to one of three things, and the platform decides which by looking at the verified email:

SituationWhat happens
The identity is already linkedSigned straight in — your pages never see it.
No account has this emailThe visitor lands on your signup-completion page with a token; you call CompleteOAuthSignup.
A local account has this email but no linkThe visitor lands on your link-confirm page; you call an [AuthMethod] that verifies the token and calls LinkOAuthFromPending for the account already found by that email.

The second and third are yours to render and provision — the platform only carries the verified identity to you, sealed, and back.

Linking an existing account#

When the email already belongs to a local (say, password) account, you don't create anything — you attach the provider identity to the account already there, so next time "Continue with Google" signs them straight in:

[AuthMethod]
string LinkOAuthAccount(string pendingToken) {
  var email = Security.VerifyPendingOAuthEmail(pendingToken);
  if (email == "") { return ""; }
  var u = User.Where(x => x.Email == email).FirstOrDefault();   // the account is found by the SEALED email, not client input
  if (u == null) { return ""; }
  Security.LinkOAuthFromPending(u.Id, pendingToken);
  return Security.IssueJwt(u.Id, u.Email);
}

Why the email is safe to trust#

Everything an [AuthMethod] receives from a page is untrusted — including the token. What makes this safe is that the token is sealed with your app's key and bound to your app: a browser cannot mint one, cannot alter the email inside it, and cannot replay one minted for a different app. So the email that comes out of VerifyPendingOAuthEmail is exactly the one the provider verified, even though it arrived through the visitor's browser. If you passed the email in as a plain argument instead, anyone could complete a signup for anyone else's address — which is the mistake this surface exists to make impossible. The provider subject (the stable id the link is keyed on) never leaves the seal at all; LinkOAuthFromPending writes it for you.

How are the completion functions wired?#

The completion functions are ordinary [AuthMethod]s, wired into app.AuthBootstrap so a signed-out visitor may call them, and armed with the same ephemeral role as Login/Signup — so what they may write is exactly your security { } grants for that role, nothing more:

[Role] enum AppRole { Authenticator, Member }

[Principal]
entity User {
  [Required, MaxLength(255)] string Email;
  [MaxLength(200)] string FirstName;              // collected on the signup-completion form
  [MaxLength(200)] string LastName;
  [MaxLength(200)] string? PasswordHash;          // empty for an OAuth-only account — it has no password
  security {
    allow read, create when IsAuthenticator;      // the auth flow finds/creates the account
    allow read where Id == user.Id;               // and a signed-in user reads their own row
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity RoleGrant {
  [Required] User User;
  [Required] AppRole Role = AppRole.Member;
  security { allow create when IsAuthenticator; }
}

policy IsAuthenticator => RoleGrant.Any(g => g.User == user && g.Role == AppRole.Authenticator);

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

app.AuthBootstrap = new AuthBootstrap {
  Role        = AppRole.Authenticator,
  Login       = Login,
  OAuthSignup = CompleteOAuthSignup,     // the new-account page calls this
  OAuthLink   = LinkOAuthAccount,        // the link-confirm page calls this
};

Your completion page reads the token out of the URL fragment it was sent to — see Navigation.Hash (Text.Split(Navigation.Hash, "pending_oauth=")) — and passes it to the [AuthMethod]; the returned ticket goes to Session.SignIn, exactly like a password login.

Examples#

The three fences above assemble into one working app: a [Principal] with an OAuth-ready model, a password Login, and the two OAuth [AuthMethod]s wired into app.AuthBootstrap. Compile it and both "Continue with Google" outcomes — brand-new account and link-an-existing-one — are handled on your own pages, in your own account model.

Testing it — TestOAuth.PendingToken#

The token these verbs read is minted by the platform and SEALED with the host's own key, which is exactly what makes trusting the email inside it safe — and exactly what makes it impossible to write one by hand in a test. So there is a verb that mints a real one:

[Test] void a_verified_email_completes_the_account() {
  var token = TestOAuth.PendingToken("ada@example.com");
  Assert.Equal("ada@example.com", Security.VerifyPendingOAuthEmail(token));
}

TEST-ONLY — it is refused outside a [Test] / [TestFixture] body, and it runs on the server for the same reason the seal exists: the key is the host's, so nothing else can produce one. Each call mints a fresh token, so two calls in one test are two different tokens.

See also#

Related

[AuthMethod] — a function an unauthenticated visitor may call

`[AuthMethod]` marks a sign-in function — login, signup, password-reset — as reachable by a visitor who is not signed…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

Security.* — hashing, verifying, tickets, random ids

The calls an authentication flow needs: `HashPassword` (salted, one-way), `VerifyPassword` (constant-work comparison…

role grants (and the first admin)

A role is granted by an ordinary entity — any entity that has both a reference to your `[Principal]` and a property…