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 tokenBoth 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:
| Situation | What happens |
|---|---|
| The identity is already linked | Signed straight in — your pages never see it. |
| No account has this email | The visitor lands on your signup-completion page with a token; you call CompleteOAuthSignup. |
| A local account has this email but no link | The 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#
- [AuthMethod] — a function an unauthenticated visitor may call —
[AuthMethod], the marker that lets a signed-out visitor call these - auth bootstrap (login, before anyone is signed in) —
app.AuthBootstrap, whereOAuthSignup/OAuthLinkare wired - Security.* — hashing, verifying, tickets, random ids —
IssueJwt,VerifyPassword, and the rest of the auth toolkit - role grants (and the first admin) — granting a role once an account exists