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 ClientSecretBoth 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.
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 unknownThey 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 ageThe 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#
- [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