# Sign people in with Google

SocialScope can run "Continue with Google" for your app, so you can sign people up or in without operating your own Google OAuth client. Your backend starts an identity attempt, your page auto-posts a one-use launch token to SocialScope, the person approves at Google, and your backend redeems the verified result exactly once. You then create or find your own user and start your own session. This flow is separate from connecting a YouTube channel: it creates no SocialScope user, no connection and no YouTube access.

## How it differs from Connect

|                            | Google identity                                | Social Connect                        |
| -------------------------- | ---------------------------------------------- | ------------------------------------- |
| Needs an existing app user | No. Only a tenant key and a return URL         | Yes, a scope with `externalSubjectId` |
| Google permissions         | `openid email profile`                         | Provider read permissions             |
| Result                     | Verified claims, returned once to your backend | A durable connection                  |
| Return hint                | `ss_identity_id`                               | `ss_session_id`                       |
| Launch path                | `/api/identity/redeem`                         | `/api/connect/redeem`                 |

A person can run both flows at the same time in different tabs.

## Before you start

- An admin enables Google identity for your client and registers your identity return URL, for example `https://app.example.com/auth/google/return`. The URL must be on one of your registered origins and must not contain any query parameter starting with `ss_`.
- `integrationReadiness.googleIdentityAvailable` is `true`. If it is `false` or the readiness call fails, hide or disable the Google button.
- Your server has `SOCIALSCOPE_GRAPHQL_URL` and `SOCIALSCOPE_CONSUMER_KEY`. See [Authentication](/authentication.md).
- Pull request preview deployments cannot use Google identity. SocialScope serves per-PR preview hosts through one shared preview client registered with a host template instead of exact origins, and that client is limited to Connect. See [Testing](/testing.md#preview-deployments).

The examples import `socialscope` from `lib/socialscope.ts`. Copy that file from [Authentication](/authentication.md#call-it). It keeps `extensions.code` and `retryAfterSeconds` on every error, which you need for `RATE_LIMITED`.

## The flow

```mermaid
sequenceDiagram
  autonumber
  actor B as Browser
  participant App as Your backend
  participant SS as SocialScope
  participant G as Google
  B->>App: Continue with Google
  App->>SS: createGoogleIdentitySession(tenantKey, returnUrl)
  SS-->>App: id, launchUrl, launchToken, resultProof
  App->>App: Save id, tenantKey, resultProof with the browser session
  App-->>B: no-store page that auto-posts launchToken
  B->>SS: POST /api/identity/redeem (Origin = your origin)
  SS-->>B: SocialScope page names your app, then Google
  B->>G: Sign in and approve
  G-->>B: Redirect to SocialScope callback
  B->>SS: Callback (SocialScope verifies the ID token)
  SS-->>B: 303 to returnUrl?ss_identity_id={id}
  B->>App: GET returnUrl?ss_identity_id={id}
  App->>SS: redeemGoogleIdentityResult(id, tenantKey, resultProof)
  SS-->>App: status, issuer, googleSub, email, emailVerified, name, pictureUrl
  App->>App: Find or create user by issuer + googleSub, then start your session
```

## 1. Start the attempt

There may be no user yet, so bind the attempt to the browser session instead, for example a short-lived, signed, HttpOnly pre-login cookie. Your server picks the tenant and return URL from its own configuration.

```ts
import { socialscope } from "./lib/socialscope";
import { launchPage } from "./lib/socialscope-launch";

const START_GOOGLE_IDENTITY = `
  mutation StartGoogleIdentity($input: GoogleIdentityInput!) {
    createGoogleIdentitySession(input: $input) {
      id
      launchUrl
      launchToken
      resultProof
      expiresAt
      resultExpiresAt
    }
  }
`;

type IdentitySession = {
  id: string;
  launchUrl: string;
  launchToken: string;
  resultProof: string;
  expiresAt: string; // authorization deadline, 10 minutes after creation
  resultExpiresAt: string; // redeem deadline, 20 minutes after creation
};

// `browserSessionId` identifies the pre-login browser session. `identityAttempts` is your server-side store.
export async function startGoogleSignIn(browserSessionId: string) {
  const tenantKey = "example-workspace";
  const returnUrl = "https://app.example.com/auth/google/return";
  const { createGoogleIdentitySession: attempt } = await socialscope<{ createGoogleIdentitySession: IdentitySession }>(START_GOOGLE_IDENTITY, { input: { tenantKey, returnUrl } });

  // resultProof never leaves the server: not to the browser, a URL or a log.
  await identityAttempts.insert({
    id: attempt.id,
    browserSessionId,
    tenantKey,
    resultProof: attempt.resultProof,
    resultExpiresAt: attempt.resultExpiresAt,
    used: false,
  });

  return launchPage(attempt.launchUrl, attempt.launchToken, "/api/identity/redeem");
}
```

Errors you can get here:

| Code                     | Meaning                                                                    | What to do                                                     |
| ------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `CAPABILITY_UNAVAILABLE` | Google identity is not available in this SocialScope environment right now | Hide the button. Readiness will say `false` too                |
| `FORBIDDEN`              | Google identity is not enabled for your app, or the tenant is not yours    | Ask the admin                                                  |
| `BAD_USER_INPUT`         | The return URL is not exactly registered                                   | Fix the configured return URL                                  |
| `RATE_LIMITED`           | Your app already has 20 unfinished attempts                                | Wait `extensions.retryAfterSeconds` (1 to 600) before retrying |

## 2. Send the browser

Use the same launch page as Connect, with the identity path. Serve it from the origin of your identity return URL.

```ts
// lib/socialscope-launch.ts
// Server only. Returns a standard Response, usable as-is in a Next.js route handler.
type LaunchPath = "/api/connect/redeem" | "/api/identity/redeem";

const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ESCAPES[char]);

export function launchPage(launchUrl: string, launchToken: string, expectedPath: LaunchPath): Response {
  const connectOrigin = new URL(process.env.SOCIALSCOPE_GRAPHQL_URL!).origin;
  const launch = new URL(launchUrl);
  if (launch.origin !== connectOrigin || launch.pathname !== expectedPath || launch.search || launch.hash) {
    throw new Error("Unexpected SocialScope launch URL");
  }
  if (!/^[0-9a-f]{64}$/.test(launchToken)) throw new Error("Unexpected SocialScope launch token");

  const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Continuing</title></head>
<body>
<form method="post" action="${escapeHtml(launchUrl)}">
<input type="hidden" name="launchToken" value="${escapeHtml(launchToken)}">
<button type="submit">Continue</button>
</form>
<script>document.forms[0].submit()</script>
</body>
</html>`;

  return new Response(html, {
    headers: {
      "content-type": "text/html; charset=utf-8",
      "cache-control": "no-store",
      "referrer-policy": "strict-origin",
      "content-security-policy": `default-src 'none'; script-src 'unsafe-inline'; form-action ${connectOrigin}; base-uri 'none'`,
      "x-content-type-options": "nosniff",
    },
  });
}
```

SocialScope shows a page naming your app and asks the person to continue to Google. After Google, it redirects to your return URL with only `ss_identity_id`. No claims, code or token ever appear in the URL.

## 3. Redeem the result

At your return route, require the same browser session that started the attempt and match the hint to its saved unused attempt. Redeem once from your server with the saved tenant and proof, then mark the attempt used unless the status is `PENDING`, which does not consume the proof. The browser reaches this route through a cross-site redirect from SocialScope, so the pre-login cookie you check must be `SameSite=Lax` or `None`. A `SameSite=Strict` cookie is not sent on that request.

```ts
import { socialscope } from "./lib/socialscope";

const REDEEM_GOOGLE_IDENTITY = `
  mutation RedeemGoogleIdentity($id: ID!, $tenantKey: String!, $resultProof: String!) {
    redeemGoogleIdentityResult(id: $id, tenantKey: $tenantKey, resultProof: $resultProof) {
      status
      issuer
      googleSub
      email
      emailVerified
      name
      pictureUrl
    }
  }
`;

type IdentityResult = {
  status: "PENDING" | "COMPLETED" | "DENIED" | "FAILED" | "EXPIRED";
  issuer: string | null;
  googleSub: string | null;
  email: string | null;
  emailVerified: boolean | null;
  name: string | null;
  pictureUrl: string | null;
};

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export async function googleReturn(url: URL, browserSessionId: string) {
  const hints = url.searchParams.getAll("ss_identity_id");
  if (hints.length !== 1 || !UUID.test(hints[0])) return { outcome: "retry" as const };

  const saved = await identityAttempts.findUnused({ id: hints[0], browserSessionId });
  if (!saved || new Date(saved.resultExpiresAt) <= new Date()) return { outcome: "retry" as const };

  const { redeemGoogleIdentityResult: person } = await socialscope<{ redeemGoogleIdentityResult: IdentityResult }>(REDEEM_GOOGLE_IDENTITY, { id: saved.id, tenantKey: saved.tenantKey, resultProof: saved.resultProof });

  // PENDING does not consume the proof. Every other status does.
  if (person.status === "PENDING") return { outcome: "pending" as const };
  await identityAttempts.markUsed(saved.id);

  if (person.status !== "COMPLETED" || !person.issuer || !person.googleSub) {
    return { outcome: "not-signed-in" as const, status: person.status };
  }

  // Key the user by issuer + googleSub. Email can change and is never a linking key.
  const user = await users.findOrCreateByGoogle({
    issuer: person.issuer,
    googleSub: person.googleSub,
    email: person.email,
    emailVerified: person.emailVerified === true,
    name: person.name,
    pictureUrl: person.pictureUrl,
  });
  // Start your session only after the user write succeeded.
  return { outcome: "signed-in" as const, userId: user.id };
}
```

Rules:

- Only `COMPLETED` with a non-empty `issuer` and `googleSub` can create or sign in a user. `issuer` is `https://accounts.google.com`.
- Key your user by issuer plus Google subject in your own database. Email may change, so never link accounts by email alone.
- Check `emailVerified` before treating the email as verified.
- Create your app's session only after your own user write succeeds.
- `DENIED`, `FAILED` and `EXPIRED` create no user. Offer a retry.
- `PENDING` means the authorization is still running. Redeem again shortly, until the 20-minute result deadline.
- The proof redeems once. A second redeem returns `NOT_FOUND` and must never sign anyone in.
- Strip `ss_identity_id` from the URL before analytics or third-party scripts load.
- If the browser session that started the attempt has expired, have the person start again rather than accepting the hint.

## Statuses

| Status      | Meaning                                                               | Proof consumed |
| ----------- | --------------------------------------------------------------------- | -------------- |
| `PENDING`   | Still running and the 10-minute authorization deadline has not passed | No             |
| `COMPLETED` | Verified. Claims are returned once                                    | Yes            |
| `DENIED`    | The person cancelled at Google                                        | Yes            |
| `FAILED`    | The Google exchange or verification failed                            | Yes            |
| `EXPIRED`   | The authorization deadline passed                                     | Yes            |

`redeemGoogleIdentityResult` returns `NOT_FOUND` for a second redeem, a wrong proof, a wrong tenant, another app's attempt, a result past its 20-minute deadline, or when identity was turned off for your app. These are deliberately indistinguishable.

## Failure and recovery

| What happened                                         | What your app sees                                                   | What to do                                                                                   |
| ----------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| The launch form was blocked or the tab closed         | No return. A redeem gives `PENDING`, then `EXPIRED` after 10 minutes | Start a new attempt                                                                          |
| The person cancelled at Google                        | Return with the hint. Redeem gives `DENIED`                          | Show cancelled. Offer a retry                                                                |
| Google's code exchange failed                         | The browser may stay on SocialScope's error page and never return    | Redeem from your server with the saved attempt. You get `FAILED` or `EXPIRED`. Offer a retry |
| The person reloaded SocialScope's page after starting | SocialScope says the request could not be verified                   | Start again from your app                                                                    |
| The network dropped during your redeem call           | The proof may already be consumed and the claims gone                | Start a new attempt                                                                          |
| Identity was turned off mid-flow                      | Later steps fail. Redeem gives `NOT_FOUND`                           | Start again once it is re-enabled                                                            |

## What Google identity does not do

- It does not create a SocialScope user or session. Your app owns both.
- It does not grant YouTube access. Use [Connect](/connect.md) with `YOUTUBE` for that.
- It does not store any Google access or refresh token.
