Skip to content

Federated Login Flow (Social) ​

This page documents SP-initiated external identity federation in GrydAuth v5 — "Continue with Google", "Sign in with Microsoft", "Sign in with Apple" — as the browser and the SPA actually see it.

Replaces POST /api/v1/auth/social-login

The old endpoint, which accepted a provider token from the client, no longer exists. It could not be made safe: a token minted for another application is indistinguishable from one minted for this one, so anything the client hands over has to be trusted on its word. The flow below is backend-driven — GrydAuth talks to the identity provider itself and the browser never carries an assertion the server did not ask for.

Overview ​

Three endpoints, and the SPA only ever calls the first and the last:

EndpointCalled byReturns
GET /api/v1/auth/federation/{connectionKey}/startThe browser, as a top-level navigation302 to the provider
GET or POST /api/v1/auth/federation/{connectionKey}/callbackThe identity provider, returning the browser302 to your returnUrl carrying a single-use code
POST /api/v1/auth/federation/exchangeThe SPA, as an XHRThe same body POST /auth/login returns

{connectionKey} is google, microsoft or apple — whichever the deployment has enabled.

Why a code instead of the token

The callback ends in a redirect, and a redirect URL is written to browser history, sent in the Referer header of the next request, and recorded by every proxy and access log in between. So the redirect carries an opaque, single-use, 60-second code and nothing else. The SPA trades it for the session over a normal XHR.

Sequence Diagram ​

100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar

1. Start ​

http
GET /api/v1/auth/federation/google/start?returnUrl=https://app.contoso.com/callback&loginHint=user@contoso.com
ParameterRequiredNotes
connectionKey (path)yesgoogle, microsoft, apple. Case-insensitive.
returnUrl (query)noWhere to land afterwards. Must be allowlisted by origin. Omitted → the configured default.
loginHint (query)noForwarded to the provider as an account hint.

This must be a top-level navigation, not fetch. The response is a redirect to another origin and it sets a cookie the callback requires; an XHR would follow the redirect into a CORS failure and the browser would discard the cookie.

ResponseWhen
302 to the providerSuccess. Set-Cookie carries the correlation cookie.
404No such connection — or the deployment has it disabled. The two are indistinguishable on purpose.
400returnUrl is not allowlisted.
502The provider is unreachable or misconfigured. Upstream fault, not the caller's.

2. Callback ​

Called by the identity provider, not by you. Both GET and POST are accepted because the binding is the provider's choice — Google and Microsoft return with a query string, Apple with response_mode=form_post.

On success it responds 302 to your returnUrl with one parameter added:

https://app.contoso.com/callback?federation_code=0f8c…

and sets the same session cookies the native login sets: the HttpOnly refresh cookie and the JS-readable CSRF cookie. The access token is not in the URL, and neither is the refresh token.

On failure it responds 400 with an RFC 7807 body and does not redirect. Every rejection — replayed state, a state minted for another connection, a missing correlation cookie, an invalid id_token, a risk refusal, a refused account link — answers identically. Which control actually fired is recorded in the audit trail and in the trace, never in the response.

3. Exchange ​

http
POST /api/v1/auth/federation/exchange
Content-Type: application/json

{ "code": "0f8c…" }

Returns exactly the body POST /api/v1/auth/login returns, produced by the same response factory:

json
{
  "token": "eyJhbGciOi…",
  "expiresAt": "2026-07-21T12:10:00Z",
  "tokenType": "Tenant",
  "permissions": ["users:read"],
  "isFirstLogin": false,
  "mustChangePassword": false,
  "daysUntilPasswordExpiration": null,
  "isGlobal": false,
  "requiresTenantSelection": false,
  "availableTenants": [],
  "currentTenant": { "…": "…" },
  "smartAutoSwitched": true,
  "groupId": null,
  "groupName": null,
  "tenantType": "Standard"
}

There is no refreshToken field — federated or not, v5 keeps the refresh token in the HttpOnly cookie. The exchange sets no cookie of its own; the cookies were already set on the callback, which is a top-level navigation and therefore does not depend on the SPA's CORS credentials configuration.

The code is single-use and expires in 60 seconds. A second exchange with the same code answers 400, and so does one with a code nobody issued.

No CSRF header here

CSRF protects endpoints that act on ambient credentials. This one acts on an unguessable, single-use value in the request body, which a cross-site attacker cannot supply.

What the SPA has to do ​

ts
// 1. Send the browser to the provider. A navigation — never fetch().
window.location.assign(
  `/api/v1/auth/federation/google/start?returnUrl=${encodeURIComponent(returnUrl)}`
);

// 2. On the return URL, trade the code for the session.
const code = new URLSearchParams(window.location.search).get('federation_code');

if (code) {
  const session = await post('/api/v1/auth/federation/exchange', { code });

  // Identical to the POST /auth/login response — the same store, the same token
  // handling, the same tenant-selection and MFA branches.
  history.replaceState({}, '', window.location.pathname); // drop the code from the URL
}

The session that comes back is an ordinary one. If it is a Global token the user still has to select a tenant; if the tenant's policy demands it the user still faces the MFA challenge. Federation contributes an authenticated subject and nothing else — it adds no token shape, no session mechanism and no branch the SPA does not already handle.

Account linking ​

Which account a federated login resolves to is decided by the linking rules of the design document (§5), in this order:

#ConditionResult
1The (issuer, subject) pair is already knownThe existing user. The ordinary login.
2An enterprise connection, and the email's domain is verified for its tenantProvisioned inside that tenant
3The email matches a local account, and the provider asserted it verified, and the provider is a trusted email verifierAttached to that account
4OtherwiseA new user is provisioned

Rule 3 is conjunctive on purpose. Microsoft is deliberately not a trusted email verifier — an attacker-controlled Entra tenant can assert any email claim it likes, which is the nOAuth attack — so a Microsoft login never attaches itself to an existing account by email alone.

A refusal here answers with the same generic message for every case, so the response cannot be used to discover whether a given address has an account.

Configuration ​

Providers are configured globally, and a provider that is not enabled is not registered at all — it never reaches the routing table, so /start answers exactly what it answers for a connection key that was never configured.

jsonc
{
  "GrydAuth": {
    "Federation": {
      // Your API's own base URI. Each connection's redirect URI is derived from it, so no two
      // connections can share a callback path — that is the mix-up defense.
      "CallbackBaseUri": "https://api.contoso.com",

      // Compared by ORIGIN (scheme + host + port), never by prefix.
      "AllowedReturnUrls": ["https://app.contoso.com"],
      "DefaultReturnUrl": "https://app.contoso.com",
      "ChallengeLifetime": "00:10:00",

      "Providers": {
        "Google": {
          "Enabled": true,
          "ClientId": "…apps.googleusercontent.com",
          "ClientSecret": "…",          // from a secret store, never source control
          "HostedDomain": "contoso.com" // optional Workspace restriction
        },
        "Microsoft": {
          "Enabled": true,
          "ClientId": "…",
          "ClientSecret": "…",
          "AllowedTenantIds": []        // empty = any work/school tenant
        },
        "Apple": {
          "Enabled": false,
          "ClientId": "io.gryd.signin",
          "TeamId": "…",
          "KeyId": "…",
          "PrivateKeyPem": "-----BEGIN PRIVATE KEY-----…"
        }
      }
    }
  }
}
csharp
services.AddGrydAuthSocialProviders(configuration);

Configuration is validated at startup: a deployment whose enabled provider cannot work — a plaintext authority, a public client, a symmetric signature algorithm, a return-URL allowlist that is empty or relative — fails to boot rather than serving a button that fails for every user who presses it.

Register each connection's redirect URI verbatim at the provider:

https://api.contoso.com/api/v1/auth/federation/google/callback
https://api.contoso.com/api/v1/auth/federation/microsoft/callback
https://api.contoso.com/api/v1/auth/federation/apple/callback

Security notes ​

  • state, nonce and PKCE are minted by the core, not by the provider implementation, and handed over ready-made. A provider cannot negotiate plain PKCE, reuse a state or skip the nonce, because it never creates them.
  • The state is bound to the browser by an HttpOnly correlation cookie; only its hash is cached. A state observed in the URL bar or at the IdP is useless in another browser. A missing cookie is a rejection, never a skip.
  • The correlation cookie is SameSite=None, and that is a requirement rather than a relaxation: the browser returns from the IdP cross-site, and Apple returns by form POST. Under Strict or Lax the cookie would be withheld on precisely the request that needs it.
  • The redirect URI is derived from configuration, never from the request host — a forwarded Host header is attacker-controlled.
  • returnUrl is checked against an origin allowlist, never a prefix: a prefix check treats https://app.contoso.com.evil.test as a match.
  • The link and the session are issued in one transaction. A link that survived a failed token issuance would send the next login down a different branch of the rules above.
  • The federation endpoints have their own rate-limit budgets, partitioned by client IP and connection, so a flood aimed at one provider cannot spend another's.

Error Reference ​

The code extension of the RFC 7807 body is what the SPA should branch on — never the detail string, which is localized.

StatusCodeMeaning
400FEDERATION_SESSION_INVALIDThe challenge is unusable — absent, expired, replayed, minted for another connection, or presented by another browser
400FEDERATION_CALLBACK_FAILEDThe provider's response failed validation
400FEDERATION_RETURN_URL_NOT_ALLOWEDreturnUrl is not allowlisted
400FEDERATION_LINK_VERIFICATION_REQUIREDThe email matches a local account that this provider may not attach to. Tell the user to sign in and link from settings.
400FEDERATION_PROVISIONING_NOT_ALLOWEDThe connection may not create accounts in its tenant
400FEDERATION_EMAIL_REQUIREDThe provider asserted no usable email
403AUTH_RISK_FORBIDDENZero Trust refused the session
404FEDERATION_CONNECTION_NOT_FOUNDNo such connection — or it is disabled
429AUTH_RATE_LIMIT_EXCEEDEDFederation rate limit; honour Retry-After
502FEDERATION_CHALLENGE_FAILEDThe provider could not be reached

Only FEDERATION_LINK_VERIFICATION_REQUIRED carries an instruction the user can act on. The rest are deliberately uninformative — every callback rejection answers identically so a probe cannot learn which control it tripped.

  • Standard Login — the session contract the exchange returns
  • Switch Tenant — when the federated session is a Global token
  • MFA — when the tenant's policy demands a second factor
  • Refresh Token — the same refresh cookie, federated or not
  • Design document — the reasoning behind every control above

Released under the MIT License.