Osy#betaRun it on Osyrin: osyrin.com

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:

using Osysharp.Oauth;

[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
Guid?  Security.PendingOAuthLinkedUser(string token)   // the account this identity is ALREADY linked to, or null
string Security.PendingOAuthName(string token)         // the name the provider gave for the person, or ""
List<string> Security.OAuthLinkedProviders(Guid userId)          // the providers that sign this account in
bool   Security.UnlinkOAuth(Guid userId, string provider)       // stop one signing it in; false when it did not
List<string> Security.SignInProviders()                // the declared `app.OAuthClients` that may sign people in AND
                                                       // can here: a ClientId, and a value for its ClientSecret

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.

Or all three are yours. Wire the OAuthSignIn slot and the platform signs nobody in by itself: every provider sign-in — the linked one included — lands on your /oauth/sign-in page, and your [AuthMethod] decides, starting from Security.PendingOAuthLinkedUser(token). That is what an app with a second factor needs, since the platform's sign-straight-in would skip it ([[security-auth-bootstrap#oauth-sign-in]]). The [[security-accounts-kit#providers|Accounts kit]] does it for you.

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.

⚠ Only an account that has PROVEN the address. The provider verified that this person receives mail at the address; it says nothing about who made the local account. Anybody can make an account in a stranger's name with a password of their own — and if a provider identity for that address is then linked to it, the two share one account. So link only an account whose address was proven by a message it received (the UserAccounts kit's EmailVerifiedAt), and send the rest a link to prove it:

using Osysharp.Oauth;

[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 || u.EmailVerifiedAt == null) { return ""; }    // …and only when the account itself proved that address
  Security.LinkOAuthFromPending(u.Id, pendingToken);
  return Security.IssueJwt(u.Id, u.Email);
}

Gating a signup on WHO the provider is, and how OLD the account is#

The same seal carries three more facts about the identity the provider verified, and they are read the same way — from inside the token, never off the wire:

string    Security.PendingOAuthProvider(string token)            // "GitHub", "Google", … — which client verified this
string    Security.PendingOAuthLogin(string token)               // the provider's HANDLE for the account, "" if it has none
DateTime? Security.PendingOAuthAccountCreatedAt(string token)    // when the PROVIDER says the account was created — null if unknown

They exist because some signups are not open to everyone. A beta may accept one provider and no other, and may require the account to have existed for a while — a rule that only means something if the age is measured by the platform against the provider, not supplied by the page:

[AuthMethod]
string CompleteBetaSignup(string pendingToken, string appDescription) {
  if (Security.PendingOAuthProvider(pendingToken) != "GitHub") { return ""; }   // this beta is GitHub-only
  var born = Security.PendingOAuthAccountCreatedAt(pendingToken);
  // ⚠ null is UNKNOWN, and an age gate refuses it. It is what an invalid token, a provider that reports no creation
  //    time, and an unparseable one all look like — none of which is evidence the account is old.
  if (born == null || born > Date.AddDays(DateTime.UtcNow, -30)) { return ""; }
  …
}

PendingOAuthAccountCreatedAt is nullable on purpose. A sentinel — DateTime.MinValue, or a zero — would read as "created in year 1", i.e. as arbitrarily old, and would turn every unknown into a silent pass. null makes you say what your rule does with "we don't know", which for an age gate is refuse.

Not every provider reports an account creation time; GitHub does (created_at on its user record), and the platform reads it on the very response that verified the email — so nothing is fetched twice, and the provider's access token is never retained anywhere. PendingOAuthLogin is the handle (a GitHub login), which is what an operator follows back to a profile page; it is not the email and not the display name.

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:

using Osysharp.Oauth;

[Role] enum AppRole { Authenticator, Member }

[Principal]
entity User {
  [Required, Unique, 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
  DateTime? EmailVerifiedAt;                      // set when a message sent to Email was received and acted on
  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;
  }
}

[RoleGrant] 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();
  // Spend the same time either way — an early return makes "no such account" measurably faster
  // than "wrong password", and that difference is an enumeration oracle anyone can time. The
  // one-argument form verifies against nothing, costs a full KDF, and answers false.
  if (u == null) { Security.VerifyPassword(password); 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("[email protected]");
  Assert.Equal("[email protected]", 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.

It takes up to four arguments, so the facts a signup gates on can be varied:

TestOAuth.PendingToken("[email protected]")                                                  // Google, no handle, no age
TestOAuth.PendingToken("[email protected]", "GitHub")                                        // a provider
TestOAuth.PendingToken("[email protected]", "GitHub", "ada")                                 // …and a handle
TestOAuth.PendingToken("[email protected]", "GitHub", "ada", Date.AddDays(DateTime.UtcNow, -400))  // …and an account age

The three-argument form deliberately carries no creation time: that is what a provider reporting none looks like, and it is the case an age gate must refuse rather than wave through.

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 you mark `[RoleGrant]` — one that holds a reference to your `[Principal]` and a…