# OAuth account completion — Security.VerifyPendingOAuthEmail / LinkOAuthFromPending

> 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.

<!-- id: security-oauth-completion · area: security · stability: stable · html: https://osysharp.com/reference/security/oauth-completion/ -->

## Summary        {#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:

```osy title="finish a new OAuth sign-in — create the account, link the identity, issue the ticket" test app=security-oauth-completion
[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      {#signature}
```osy syntax
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`](https://osysharp.com/reference/stdlib/security/)): 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`](https://osysharp.com/reference/security/auth-bootstrap/) as `OAuthSignup` / `OAuthLink`.

## Description    {#description}

### What happens when a provider sign-in comes back?   {#outcomes}
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   {#linking}
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:

```osy title="link a provider identity to the existing account with that email" test app=security-oauth-completion
[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   {#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?   {#wiring}
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 { }`](https://osysharp.com/reference/security/entity-security/) grants for that role, nothing more:

```osy title="the principal, and the auth entry points wired together" test app=security-oauth-completion
[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
  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();
  // 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`](https://osysharp.com/reference/ui/navigation/)
(`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       {#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`   {#testing}
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:

```osy title="a sealed pending token, so the completion path can be tested at all" syntax
[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       {#see-also}
- [[AuthMethod] — a function an unauthenticated visitor may call](https://osysharp.com/reference/security/auth-method/) — `[AuthMethod]`, the marker that lets a signed-out visitor call these
- [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/) — `app.AuthBootstrap`, where `OAuthSignup`/`OAuthLink` are wired
- [Security.* — hashing, verifying, tickets, random ids](https://osysharp.com/reference/stdlib/security/) — `IssueJwt`, `VerifyPassword`, and the rest of the auth toolkit
- [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) — granting a role once an account exists
