# Social sign-in

> Sign people in with Google, GitHub, Apple, Microsoft, GitLab, Slack, LinkedIn, Discord, X or any OpenID Connect provider, link the accounts to Navree users and issue normal Navree tokens.

## Introduction

(Feature: `social`) Many people would rather click "Continue with Google" than invent another password. `navree-social` adds that button to your app without changing how the rest of it works: a social sign-in ends with the same Navree user and the same access and refresh tokens as a [password login](https://navree.com/docs/security/authentication.md), so every route, permission and audit entry behaves as before.

It covers the whole flow:

- **Providers:** Google, GitHub, Apple, Microsoft, GitLab (including self-hosted), Slack, LinkedIn, Discord and X built in, any OpenID Connect provider by its issuer URL, and your own through a trait.
- **A safe redirect:** the state parameter is sealed with your application key and carries a PKCE verifier and, for OpenID Connect, a nonce. It expires after 10 minutes and works once.
- **Verified identities:** ID tokens are checked against the provider's published keys (signature, issuer, audience, expiry and nonce) before Navree trusts them.
- **Linked accounts:** one user can sign in with a password, GitHub and Google. Accounts are linked automatically only when the provider has verified the email address.
- **Navree tokens:** the callback answers exactly like `POST /api/auth/login`, two-factor challenge included.
- **Provider tokens** (optional), encrypted at rest and refreshed for you, for apps that call the provider's API later, such as syncing meetings from Google Calendar into CRM activities.

## Installation

Turn on the `social` feature of the `navree` facade. It is opt-in: the default `full` feature leaves it out.

```toml title="Cargo.toml"
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["social"] }
```

Add `SocialModule` after `AuthModule`. It calls the providers through the [HTTP client](https://navree.com/docs/digging-deeper/http-client.md), so include `HttpClientModule`:

```rust title="src/app.rs"
Navree::app()
    .module(ConfigModule::for_root(sources))
    .module(CryptoModule::from_config())      // seals states, encrypts tokens
    .module(HttpModule::from_config())
    .module(HttpClientModule::from_config())
    .module(DatabaseModule::from_config())
    .module(CacheModule::from_config())       // remembers used states
    .module(AuthModule::from_config())
    .module(SocialModule::from_config())      // section `social`
    // ...
```

`CacheModule` is optional: without it, used states are remembered in the process, which is enough for a single instance. With several instances, give the cache a shared store so a state used on one instance is refused on the others.

The module contributes the migration set `social` (after `auth`), which creates `social_accounts`: one row per linked provider account, with the user, the provider, the provider's user id, the email, name, avatar and handle it reported, and the encrypted provider tokens when you store them. Deleting a user deletes their rows.

## Configuration

Register an OAuth app with each provider, then list the providers in the `social` section. Each provider's callback URL is `{redirect_base}/{provider}`; register that exact URL with the provider:

```toml title="config/app.toml"
[social]
redirect_base = "https://app.acme.test/auth/callback"  # the front end's page
link_by_email = "verified"                             # verified | never

[social.providers.github]
client_id = "Iv1.8a61f9b3a7aba766"

[social.providers.google]
client_id = "481203-k2l9.apps.googleusercontent.com"
hosted_domain = "acme.test"   # optional: only this Google Workspace

[social.providers.microsoft]
client_id = "0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f"
tenant = "organizations"      # common | organizations | consumers | <tenant id>
```

Client secrets go in `.env`:

```env title=".env"
APP_SOCIAL__PROVIDERS__GITHUB__CLIENT_SECRET=4f1c0e7b9a...
APP_SOCIAL__PROVIDERS__GOOGLE__CLIENT_SECRET=GOCSPX-7Hk2...
APP_SOCIAL__PROVIDERS__MICROSOFT__CLIENT_SECRET=Q8a8Q~mX2...
```

A provider without a `client_id`, an unknown provider type, a `redirect_base` that isn't an absolute URL, an Apple key that can't be read or a `custom` provider that isn't registered fails the boot, naming the key. Provider keys are lower-case letters, digits, `-` and `_`. `SocialModule::for_root(SocialOptions { .. })` takes the same options in code.

### Providers

| Key | Provider | Notes |
|---|---|---|
| `google` | Google | OpenID Connect. `hosted_domain` limits sign-in to one Google Workspace domain (the `hd` claim is checked). |
| `github` | GitHub | Reads the primary verified email from the emails API. `url` and `api_url` point at GitHub Enterprise Server. |
| `apple` | Sign in with Apple | Needs `team_id`, `key_id` and `private_key` (the `.p8` key); Navree signs the ES256 client secret for you. Apple posts the callback (`response_mode=form_post`) and sends the name only on the first sign-in, in the `user` field; pass it on, and Navree stores it then. |
| `microsoft` | Microsoft Entra ID and personal accounts | `tenant` picks who may sign in. Microsoft lets account owners set their email address, so it counts as verified only with the `xms_edov` optional claim. |
| `gitlab` | GitLab | `url` for a self-hosted instance (default `https://gitlab.com`). |
| `slack` | Slack | OpenID Connect; `team` limits sign-in to one workspace (team id). |
| `linkedin` | LinkedIn | OpenID Connect. |
| `discord` | Discord | Only verified emails link accounts. |
| `x` | X | Add the `users.email` scope to read the confirmed email address. |

Any other OpenID Connect provider (Okta, Auth0, Keycloak, Zitadel, your company's SSO) works by its issuer. Navree reads its discovery document when the app starts, and again on first use if the provider couldn't be reached then. A document that names another issuer is refused:

```toml
[social.providers.okta]
type = "oidc"
name = "Okta"                        # shown in errors and in the accounts list
issuer = "https://acme.okta.com"
client_id = "0oa8f2kq1xYz3AbC4d5e"
scopes = ["openid", "profile", "email", "groups"]
```

Each provider asks for the scopes it needs to read a name and an email. Add more with `scopes`; they are requested in addition to the defaults. Every provider gets a PKCE challenge (S256) unless you set `pkce = false` for it.

How Navree reads the user:

- **Google, Apple, Microsoft and `oidc` providers:** from the ID token, verified against the provider's JSON Web Key Set (RS256, PS256, ES256 or EdDSA; key sets are cached for an hour and fetched again when a new key id appears). When the ID token has no email, Google and `oidc` providers fill it in from their userinfo endpoint.
- **GitLab, Slack and LinkedIn:** from their userinfo endpoints, with the access token.
- **GitHub, Discord and X:** from their APIs.

## How the flow works

*Diagram: Backend for frontend: the browser talks only to a front-end server that keeps the tokens in httpOnly cookies and calls the Navree API on the user's behalf, while real-time updates reach the browser over WebSockets.*

Navree's auth is token-based and a browser app keeps the tokens in its [backend-for-frontend](https://navree.com/docs/patterns/backend-for-frontend.md) (BFF), so a social sign-in runs through the BFF too:

1. The sign-in page links to a BFF route, say `/auth/social/github`. The BFF calls `GET /api/auth/social/github/redirect`, stores the returned `state` in a short-lived httpOnly cookie and redirects the browser to `url`.
2. The user approves at GitHub, and GitHub sends the browser to `https://app.acme.test/auth/callback/github?code=...&state=...`.
3. The BFF's callback page checks that the `state` in the URL equals the one in its cookie. That check binds the flow to this browser, so nobody can sign a victim into the attacker's account.
4. The BFF posts `{code, state}` to `POST /api/auth/social/github/callback` and gets the same answer as a login: it stores the tokens in its cookies, as it does for a password login, and sends the browser on to the app.

Apple posts `code`, `state` and `user` to the callback page instead of adding them to the URL, so that page accepts a `POST`, and the state cookie must be sent on that cross-site post (`SameSite=None; Secure`).

The first call:

```http
GET /api/auth/social/github/redirect?return_to=/deals HTTP/1.1
```

```json
{
  "url": "https://github.com/login/oauth/authorize?response_type=code&client_id=Iv1.8a61f9b3a7aba766&redirect_uri=https%3A%2F%2Fapp.acme.test%2Fauth%2Fcallback%2Fgithub&scope=read%3Auser+user%3Aemail&state=nvs1.Bq2...&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256",
  "state": "nvs1.Bq2..."
}
```

The callback:

```http
POST /api/auth/social/github/callback HTTP/1.1
Content-Type: application/json

{ "code": "6f0a3d1e9b2c8f7a4e5d", "state": "nvs1.Bq2..." }
```

```json
{
  "user": { "__type": "User", "id": "01a17748-e211-72fc-abb3-836e604457d0", "name": "Ada Lovelace", "email": "ada@example.com", "email_verified_at": "2026-10-26T08:17:07Z", "created_at": "2026-10-26T08:17:07Z" },
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "refresh_token": "01a17748-e21b-71c6-97cf-968b61f8e6b3.1ug0BGRxe4Fa...",
  "token_type": "Bearer",
  "expires_in": 900,
  "return_to": "/deals"
}
```

`return_to` comes back from the sealed state, and only relative paths are accepted (`/deals`, never `https://...` or `//...`), so it can't send the browser to another site. A native or mobile app that holds its own tokens follows the same two calls, with its own redirect URI and its own state check.

What can go wrong:

| Answer | `code` | When |
|---|---|---|
| 400 `Invalid or expired state` | `invalid_state` | The state was changed, issued for another provider or another app, has expired, or was used already |
| 400 `GitHub refused the sign-in: ...` | `provider_refused` | The provider refused the code (`invalid_grant`, `access_denied`, ...) |
| 400 `Google sent an invalid ID token` | `invalid_id_token` | The ID token's signature, issuer, audience, expiry or nonce is wrong |
| 400 `GitHub did not share an email address` | `missing_email` | A new user would be created, but the provider shared no email |
| 403 | `sign_in_not_allowed` | Another Google Workspace or Slack workspace, or an email outside `allowed_email_domains` |
| 403 `Registration is closed` | `registration_closed` | No matching user, and registration is off |
| 409 | `email_taken` | The email belongs to a user and linking doesn't apply (see below) |
| 429 | `too_many_login_attempts` | Too many callbacks from this address (`auth.login_throttle_per_minute`, with `Retry-After`) |
| 502 | `provider_unavailable` | The provider couldn't be reached |

Every answer carries its `code` in the [error body](https://navree.com/docs/basics/errors.md#error-codes), so your sign-in page can tell "registration is closed" from "use your work address" in the user's language.

## Linking accounts

When someone signs in with a provider, Navree looks for their user in this order:

1. **A linked account.** A `social_accounts` row for this provider and provider user id signs in its user, and is updated with what the provider reported this time.
2. **The same verified email.** With `link_by_email = "verified"` (the default), a user whose email matches an email the provider has **verified** gets the account linked, and is signed in. An unverified email never links: anyone can type any address into some providers.
3. **A new user.** With `social.registration` on (it defaults to `auth.registration`), Navree creates the user with the provider's name and email, marks the email as verified when the provider did, links the account and signs them in. The user has no password until they set one through [password reset](https://navree.com/docs/security/account-flows.md#password-reset). A provider that shares no email address can't create a user (400).
4. **Otherwise** the callback answers 409, so the person signs in with their password and connects the provider from their settings:

```json
{ "statusCode": 409, "message": "An account with this email already exists. Sign in, then connect GitHub in your settings.", "error": "Conflict", "code": "email_taken" }
```

With registration off and no matching user, the answer is 403 `Registration is closed`. Restrict who may sign up through a provider with `allowed_email_domains = ["acme.test"]`, per provider or for the whole section: only a verified address in one of those domains may sign in or sign up.

### Two-factor authentication

A user with [two-factor authentication](https://navree.com/docs/security/account-flows.md#two-factor-authentication) gets the same challenge from the callback as from a password login (`two_factor_required`, `challenge_token`, `expires_in`), and finishes with `POST /api/auth/2fa/verify`. Set `social.two_factor = "skip"` to trust the provider's own second factor instead.

### Connecting and disconnecting providers

Signed-in users manage their providers with three routes:

| Route | Does |
|---|---|
| `GET /api/auth/social/accounts` | The user's linked accounts: `provider`, `provider_name`, `email`, `name`, `avatar_url`, `linked_at` |
| `POST /api/auth/social/{provider}/link` | `{code, state}` from a flow started with `GET .../redirect?intent=link`; links the account to the signed-in user (201) |
| `DELETE /api/auth/social/accounts/{provider}` | Disconnects the provider (204) |

`GET .../redirect?intent=link` needs the user's access token too (401 without it): the state is sealed for that user, so only they can finish the link, and a link state can't sign anyone in. Linking an account that already belongs to another user is a 409 (`account_already_linked`), and so is connecting a second account of a provider the user has already connected (`provider_already_connected`). Disconnecting the last way a user can sign in (no password and no other provider) is a 409 too (`last_sign_in_method`), so nobody locks themselves out; disconnecting a provider that isn't connected is a 404 (`provider_not_connected`).

## Using the Social service

The routes are built on the `Social` service. Use it directly when you want your own routes, a different response or extra checks:

```rust title="src/modules/sso/controller.rs"
use navree::auth::{ClientMeta, LoginOutcome};
use navree::prelude::*;
use navree::social::{Social, SocialCallback, SocialRedirect};

#[injectable]
pub struct SsoController {
    social: Arc<Social>,
}

#[controller("/sso", version = "1")]
impl SsoController {
    /// `GET /api/v1/sso/okta`: where to send the browser.
    #[get("/okta")]
    #[public]
    async fn redirect(&self) -> Result<Json<SocialRedirect>> {
        Ok(Json(self.social.redirect("okta").await?))
    }

    /// `POST /api/v1/sso/okta`: `{code, state}` from the callback page.
    #[post("/okta")]
    #[public]
    async fn callback(
        &self,
        meta: ClientMeta,
        Json(callback): Json<SocialCallback>,
    ) -> Result<Json<serde_json::Value>> {
        let profile = self.social.user("okta", &callback).await?;
        let groups = profile.raw["groups"].as_array().cloned().unwrap_or_default();
        if !groups.iter().any(|g| g == "crm-users") {
            return Err(AppError::forbidden().with_detail("Ask IT for access to the CRM"));
        }
        match self.social.sign_in(&profile, &meta).await? {
            LoginOutcome::Authenticated { user, tokens } => Ok(Json(serde_json::json!({
                "user_id": user.id,
                "access_token": tokens.access_token,
                "refresh_token": tokens.refresh_token,
            }))),
            LoginOutcome::TwoFactorRequired { challenge_token, .. } => Ok(Json(serde_json::json!({
                "two_factor_required": true,
                "challenge_token": challenge_token,
            }))),
        }
    }
}
```

A `SocialError` returned with `?` keeps its answer (400 for a bad state, 409 for the linking rules, and so on).

| Method | What it does |
|---|---|
| `redirect(provider)` | The provider's authorization URL and the sealed `state` |
| `redirect_with(provider, RedirectOptions { scopes, params, return_to, intent, user_id })` | The same with extra scopes, extra query parameters (`prompt=consent`), a return path and the intent; `Intent::Link` needs the `user_id` it links to |
| `user(provider, &callback)` | Checks the state, exchanges the code and returns the `SocialUser` |
| `sign_in(&social_user, &meta)` | Finds, links or creates the user and issues tokens: a `LoginOutcome` |
| `link(&uow, user_id, &social_user)`, `unlink(&uow, user_id, provider)` | Connects or disconnects a provider |
| `accounts(user_id)` | The user's linked accounts (`LinkedAccount`) |
| `find_user(provider, provider_user_id)` | The linked user, if any |
| `token(user_id, provider)` | A current provider access token (see below) |
| `providers()`, `provider_name(provider)` | The configured provider keys and their display names, for a sign-in page |

`SocialUser` has `provider`, `id` (the provider's user id), `name`, `email`, `email_verified`, `avatar_url`, `nickname`, `token` and `raw`, the provider's whole profile as JSON (the ID token's claims, or the API's user object). Build one with `SocialUser::new(provider, id)` and `.name(..)`, `.email(Some(..), verified)`, `.avatar_url(..)`, `.nickname(..)`, `.token(..)` and `.raw(..)`.

`sign_in` and the token refresh join the request's unit of work in a handler; `link` and `unlink` take yours. Linking and disconnecting emit their events and audit entries after the commit.

## Calling the provider's API later

To call the provider on the user's behalf after sign-in (read their Google Calendar, post to their Slack), ask for the scopes and store the tokens:

```toml
[social]
store_tokens = true

[social.providers.google]
client_id = "481203-k2l9.apps.googleusercontent.com"
scopes = ["https://www.googleapis.com/auth/calendar.readonly"]
# Ask Google for a refresh token:
params = { access_type = "offline", prompt = "consent" }
```

Tokens are encrypted with the application key before they're written to `social_accounts`, at every sign-in and link. `token` returns a current access token, refreshing it first when it has expired or expires within a minute:

```rust
let google = social.token(user.id, "google").await?; // Secret<String>
let events = http
    .get("https://www.googleapis.com/calendar/v3/calendars/primary/events")
    .bearer(google.expose())
    .send()
    .await?;
```

When the provider refuses the refresh (the user revoked access) or no refresh token was stored, `token` returns `SocialError::Reauthorize`, and the account stays linked for sign-in. Without stored tokens it returns `SocialError::NoToken`, and for a provider the user hasn't connected, `SocialError::NotLinked`. Returned from a handler with `?`, they answer 409 `reauthorization_required`, 404 `no_provider_token` and 404 `provider_not_connected`, so the front end can offer "Connect Google again".

## Your own provider

A provider that speaks neither OAuth 2.0 with a standard profile endpoint nor OpenID Connect gets a `SocialProvider` implementation. Implement its key and the two steps:

```rust title="src/modules/sso/zoho.rs"
use navree::BoxFuture;
use navree::prelude::*;
use navree::social::{
    AuthorizeUrl, ProviderConfig, ProviderToken, SocialError, SocialProvider, SocialUser,
};

#[injectable]
pub struct Zoho {
    http: Arc<navree::http_client::HttpClient>,
}

impl SocialProvider for Zoho {
    fn key(&self) -> &'static str {
        "zoho"
    }

    fn authorize_url(&self, _config: &ProviderConfig, url: AuthorizeUrl) -> AuthorizeUrl {
        url.endpoint("https://accounts.zoho.com/oauth/v2/auth")
            .scopes(["AaaServer.profile.Read"])
    }

    fn user<'a>(
        &'a self,
        _config: &'a ProviderConfig,
        token: ProviderToken,
    ) -> BoxFuture<'a, Result<SocialUser, SocialError>> {
        Box::pin(async move {
            let profile: serde_json::Value = self
                .http
                .get("https://accounts.zoho.com/oauth/user/info")
                .bearer(token.access_token.expose())
                .send()
                .await?
                .json()?;
            Ok(SocialUser::new("zoho", profile["ZUID"].to_string())
                .name(profile["Display_Name"].as_str().unwrap_or_default())
                .email(profile["Email"].as_str(), true)
                .token(token)
                .raw(profile))
        })
    }
}
```

Register it with `.module(SocialModule::for_feature::<Zoho>())` and configure it under its key, `[social.providers.zoho]`, with `type = "custom"` and `token_url`. Navree still does the state, PKCE, code exchange (sending the client secret in the form), linking and token issuing. `authorize_url` receives the URL with Navree's parameters, the configured scopes and parameters already in place; `config` has the provider's whole configuration, endpoint overrides included.

## Events and audit entries

| Event | `NAME` | When |
|---|---|---|
| `SocialAccountLinked` | `social.linked` | A provider account was linked, at sign-in or from settings |
| `SocialAccountUnlinked` | `social.unlinked` | A provider account was disconnected |

Both carry `user_id`, `provider` and `provider_user_id`, and are emitted after the change commits, when `EventsModule` is in the app.

With the [audit log](https://navree.com/docs/security/audit-log.md), sign-ins are recorded as `auth.login` with `metadata.provider` (`auth.two_factor_challenge` when a second factor is due), new users as `auth.register` with `metadata.provider`, and links as `social.linked` and `social.unlinked`. Provider tokens are never recorded.

## Testing

`Social::fake()` returns a `Social` that never calls a provider, and a `SocialFake` that decides what each code returns. States are still sealed and checked, so your tests go through the same steps as production:

```rust title="tests/social.rs"
use navree::prelude::*;
use navree::social::{Social, SocialUser};
use navree::testing::TestApp;
use serde_json::json;

#[tokio::test]
async fn a_new_github_user_gets_an_account_and_tokens() {
    let (social, fake) = Social::fake();
    fake.on_code(
        "github",
        "code-ada",
        SocialUser::new("github", "583231")
            .name("Ada Lovelace")
            .email(Some("ada@example.com"), true),
    );

    let app = TestApp::builder()
        .temp_database()
        .config("app.key", navree::crypto::generate_key())
        .config("social.redirect_base", "https://app.acme.test/auth/callback")
        .config("social.providers.github.client_id", "test")
        .module(CryptoModule::from_config())
        .module(DatabaseModule::from_config())
        .module(AuthModule::from_config())
        .module(SocialModule::from_config())
        .override_value(Arc::new(social))
        .build()
        .await;

    let redirect = app
        .client()
        .get("/api/auth/social/github/redirect")
        .send()
        .await
        .assert_status(200);
    let state = redirect.json_value()["state"].as_str().unwrap().to_owned();

    app.client()
        .post("/api/auth/social/github/callback")
        .json(&json!({ "code": "code-ada", "state": state }))
        .send()
        .await
        .assert_status(200)
        .assert_json_path("user.email", json!("ada@example.com"));

    // The state works once.
    app.client()
        .post("/api/auth/social/github/callback")
        .json(&json!({ "code": "code-ada", "state": state }))
        .send()
        .await
        .assert_error(400, "Invalid or expired state");
}
```

| `SocialFake` | Does |
|---|---|
| `on_code(provider, code, user)` | Answers a code with a profile |
| `on_code_error(provider, code, error)` | Answers a code with a provider error (`access_denied`): a 400 `GitHub refused the sign-in: access_denied` |
| `on_refresh(provider, token)` | Answers token refreshes for `Social::token`; without it a refresh is refused (`SocialError::Reauthorize`) |
| `exchanged()` | The `(provider, code)` pairs exchanged so far |

A code the fake doesn't know is refused with `invalid_grant`, as a provider would. The fake skips an `oidc` provider's discovery document. To test a provider integration itself, keep the real `Social`, point the provider's endpoint keys (`url`, `authorize_url`, `token_url`, `userinfo_url`, `jwks_url`) at a local mock server, and let the HTTP client reach it (`http_client.ssrf.allow_hosts = ["127.0.0.1"]`).

## Reference

| Key | Default | Meaning |
|---|---|---|
| `social.redirect_base` | none | Callback URLs are `{redirect_base}/{provider}`; required |
| `social.routes` | `true` | Mounts the `/api/auth/social` routes |
| `social.registration` | `auth.registration` | Creates users on a first sign-in |
| `social.link_by_email` | `verified` | `verified` links matching verified emails; `never` doesn't |
| `social.allowed_email_domains` | `[]` | When not empty, only verified addresses of these domains may sign in or sign up |
| `social.two_factor` | `require` | `require` or `skip` for users with two-factor authentication |
| `social.state_ttl_secs` | `600` | Lifetime of a sealed state |
| `social.store_tokens` | `false` | Keeps encrypted provider tokens for `Social::token` |
| `social.providers.<key>.type` | the key | `google`, `github`, `apple`, `microsoft`, `gitlab`, `slack`, `linkedin`, `discord`, `x`, `oidc` or `custom` |
| `social.providers.<key>.name` | the provider's name, or the key | Shown in messages and in the accounts list |
| `social.providers.<key>.client_id`, `.client_secret` | none | The OAuth app; the secret from `.env` |
| `social.providers.<key>.scopes` | `[]` | Extra scopes (a list, or a comma-separated string) |
| `social.providers.<key>.params` | `{}` | Extra authorization URL parameters |
| `social.providers.<key>.pkce` | `true` | Sends a PKCE challenge (S256) |
| `social.providers.<key>.allowed_email_domains` | `[]` | Per-provider domain limit |
| `social.providers.<key>.issuer` | none | The issuer of an `oidc` provider |
| `social.providers.<key>.hosted_domain` | none | Google: one Google Workspace domain |
| `social.providers.<key>.tenant` | `common` | Microsoft: `common`, `organizations`, `consumers` or a tenant id |
| `social.providers.<key>.team` | none | Slack: one workspace (team id) |
| `social.providers.<key>.team_id`, `.key_id`, `.private_key` | none | Apple: the team, the key id and the `.p8` key (PEM; `\n` escapes are accepted) |
| `social.providers.<key>.url`, `.api_url` | the provider's | GitLab's instance; GitHub Enterprise Server and its API root |
| `social.providers.<key>.authorize_url`, `.token_url`, `.userinfo_url`, `.jwks_url` | the provider's | Endpoint overrides; `token_url` is required for `custom` |

| Route | Access |
|---|---|
| `GET /api/auth/social/{provider}/redirect` | Public (signed in with `?intent=link`) |
| `POST /api/auth/social/{provider}/callback` | Public; counted by the login throttle |
| `GET /api/auth/social/accounts` | Signed in |
| `POST /api/auth/social/{provider}/link` | Signed in |
| `DELETE /api/auth/social/accounts/{provider}` | Signed in |

The routes sit under `auth.routes_prefix`, next to the [authentication routes](https://navree.com/docs/security/authentication.md).

- [Authentication](https://navree.com/docs/security/authentication.md): The tokens a social sign-in ends with.
- [Account flows](https://navree.com/docs/security/account-flows.md): Two-factor authentication, password reset and email verification.
- [Backend for frontend](https://navree.com/docs/patterns/backend-for-frontend.md): Where the browser's tokens live.
- [OAuth server](https://navree.com/docs/packages/oauth-server.md): Let other apps sign in with your app.
