navree
Documentation

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.

On this page

Introduction

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

Cargo.toml

Add SocialModule after AuthModule. It calls the providers through the HTTP client, so include HttpClientModule:

src/app.rs

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:

config/app.toml

Client secrets go in .env:

.env

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

KeyProviderNotes
googleGoogleOpenID Connect. hosted_domain limits sign-in to one Google Workspace domain (the hd claim is checked).
githubGitHubReads the primary verified email from the emails API. url and api_url point at GitHub Enterprise Server.
appleSign in with AppleNeeds 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.
microsoftMicrosoft Entra ID and personal accountstenant 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.
gitlabGitLaburl for a self-hosted instance (default https://gitlab.com).
slackSlackOpenID Connect; team limits sign-in to one workspace (team id).
linkedinLinkedInOpenID Connect.
discordDiscordOnly verified emails link accounts.
xXAdd 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

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

Backend for frontendThe browser calls only its own web server, on the same origin, and holds cookies it can't read. The Next.js BFF keeps the access token (navree_at) and the refresh token (navree_rt) in httpOnly cookies and calls the Navree API with Authorization: Bearer. When the access token expires it refreshes once per refresh token, and Navree rotates the refresh token on every use. The Navree API reads and writes PostgreSQL in one transaction per request, and after a change it broadcasts to its WebSocket server, which pushes the event straight to the browser. The socket carries no token; private channels are authorized through the BFF.Browsercookies it can't readNext.js BFFhttpOnly cookiesnavree_ataccessnavree_rtrefreshNavree APIHTTP and WebSocketPostgreSQLusers · token familiessame originBearerrefresh,rotatesone transaction
Backend for frontend
  • request
  • real-time event

Navree's auth is token-based and a browser app keeps the tokens in its backend-for-frontend (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
JSON

The callback:

HTTP
JSON

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:

AnswercodeWhen
400 Invalid or expired stateinvalid_stateThe state was changed, issued for another provider or another app, has expired, or was used already
400 GitHub refused the sign-in: ...provider_refusedThe provider refused the code (invalid_grant, access_denied, ...)
400 Google sent an invalid ID tokeninvalid_id_tokenThe ID token's signature, issuer, audience, expiry or nonce is wrong
400 GitHub did not share an email addressmissing_emailA new user would be created, but the provider shared no email
403sign_in_not_allowedAnother Google Workspace or Slack workspace, or an email outside allowed_email_domains
403 Registration is closedregistration_closedNo matching user, and registration is off
409email_takenThe email belongs to a user and linking doesn't apply (see below)
429too_many_login_attemptsToo many callbacks from this address (auth.login_throttle_per_minute, with Retry-After)
502provider_unavailableThe provider couldn't be reached

Every answer carries its code in the error body, 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. 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

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

RouteDoes
GET /api/auth/social/accountsThe 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:

src/modules/sso/controller.rs

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

MethodWhat 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

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

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:

src/modules/sso/zoho.rs

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

EventNAMEWhen
SocialAccountLinkedsocial.linkedA provider account was linked, at sign-in or from settings
SocialAccountUnlinkedsocial.unlinkedA 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, 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:

tests/social.rs
SocialFakeDoes
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

KeyDefaultMeaning
social.redirect_basenoneCallback URLs are {redirect_base}/{provider}; required
social.routestrueMounts the /api/auth/social routes
social.registrationauth.registrationCreates users on a first sign-in
social.link_by_emailverifiedverified 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_factorrequirerequire or skip for users with two-factor authentication
social.state_ttl_secs600Lifetime of a sealed state
social.store_tokensfalseKeeps encrypted provider tokens for Social::token
social.providers.<key>.typethe keygoogle, github, apple, microsoft, gitlab, slack, linkedin, discord, x, oidc or custom
social.providers.<key>.namethe provider's name, or the keyShown in messages and in the accounts list
social.providers.<key>.client_id, .client_secretnoneThe 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>.pkcetrueSends a PKCE challenge (S256)
social.providers.<key>.allowed_email_domains[]Per-provider domain limit
social.providers.<key>.issuernoneThe issuer of an oidc provider
social.providers.<key>.hosted_domainnoneGoogle: one Google Workspace domain
social.providers.<key>.tenantcommonMicrosoft: common, organizations, consumers or a tenant id
social.providers.<key>.teamnoneSlack: one workspace (team id)
social.providers.<key>.team_id, .key_id, .private_keynoneApple: the team, the key id and the .p8 key (PEM; \n escapes are accepted)
social.providers.<key>.url, .api_urlthe provider'sGitLab's instance; GitHub Enterprise Server and its API root
social.providers.<key>.authorize_url, .token_url, .userinfo_url, .jwks_urlthe provider'sEndpoint overrides; token_url is required for custom
RouteAccess
GET /api/auth/social/{provider}/redirectPublic (signed in with ?intent=link)
POST /api/auth/social/{provider}/callbackPublic; counted by the login throttle
GET /api/auth/social/accountsSigned in
POST /api/auth/social/{provider}/linkSigned in
DELETE /api/auth/social/accounts/{provider}Signed in

The routes sit under auth.routes_prefix, next to the authentication routes.