Social login
Open provider contract — one module import activates every configured provider, and a third party publishes their own through the same discovery seam.
Social login is an open provider contract. The framework ships the
SocialProvider trait, an inventory-based registry, and first-party
GitHub and Google providers. One import activates the registry; each
provider turns on when its credentials are configured. A third party
publishes a new provider as its own crate through the same seam — no
fork, no framework change.
Install
Section titled “Install”cargo add nest-rs --features socialThe social feature implies authn: the authorization-code machinery
SocialModule drives (OAuthClient, TokenSet) arrives with it.
use nest_rs::core::module;use nest_rs::social::SocialModule;
#[module(imports = [SocialModule, OAuthHttpModule])]pub struct AuthModule;SocialModule owns every registry entry, so it is the module gate — no
SocialModule, no social login. Which providers come up inside that gate
is a config decision, not a second import.
OAuthHttpModule is yours — the two routes below are app code, for the
reason The request flow explains.
Wire it in
Section titled “Wire it in”Each provider reads its credentials from the dual-path config
(NESTRS_SOCIAL__<PROVIDER>__* env or a provided struct). Only the
client id, secret, and redirect URL are deployment config — the
auth/token/userinfo endpoints are provider constants, and scopes
default to each provider’s canonical login set.
NESTRS_SOCIAL__GITHUB__CLIENT_ID=…NESTRS_SOCIAL__GITHUB__CLIENT_SECRET=…NESTRS_SOCIAL__GITHUB__REDIRECT_URL=https://auth.example/social/github/callback
NESTRS_SOCIAL__GOOGLE__CLIENT_ID=…NESTRS_SOCIAL__GOOGLE__CLIENT_SECRET=…NESTRS_SOCIAL__GOOGLE__REDIRECT_URL=https://auth.example/social/google/callbackNESTRS_SOCIAL__GITHUB__SCOPES and NESTRS_SOCIAL__GOOGLE__SCOPES take a
comma-separated list and default to each provider’s canonical login set, so
neither is deployment config until you need more than sign-in.
Three outcomes per provider, and no fourth:
Config for NESTRS_SOCIAL__<PROVIDER>__* | Outcome |
|---|---|
| Complete | active |
| Absent entirely | inert — one boot warn naming the provider and its NESTRS_SOCIAL__<PROVIDER>__* namespace; the registry does not know the key, so your :provider route answers 404 |
| Partial, or invalid | boot fails, naming the provider |
Real credentials are a deployment’s explicit intent, so a provider never activates by accident; and a half-configured login never starts serving silently.
Note what you do not write: SocialModule takes no configuration. Each
provider carries its own #[config], and its registry entry names it — so
discovering a provider is what loads its credentials. The module never learns
which providers are linked, which is exactly why it has nothing to declare
about them — the import at the top of this page is the whole wiring, whether
one provider is linked or five.
Adding a provider is adding its crate to Cargo.toml and its credentials to
the environment. No import changes, no list to keep in sync.
A provider’s credentials are deployment data — the client id, the secret and the redirect URL all differ between your laptop and production — so they have no pinned-in-code path, and that is deliberate rather than missing. A test that must not read the ambient environment seeds the config on the builder, the same hard pin every other config uses.
The request flow
Section titled “The request flow”nest-rs-social ships no routes. A social provider is not a DI provider —
it is reached only through SocialRegistry, which is what lets a third-party
provider ship as two files with no module of its own. The flip side is that the
two HTTP legs are app code: the path prefix, the throttle, the cookie name
and what “resolve this profile to a local user” means are all yours.
Two legs, both dispatched by a :provider path segment:
GET /social/:provider/authorize— begins the redirect. A plain handler: ask the registry for the provider, callauthorize, return a302with the PKCE/CSRF transaction in a signed, short-lived cookie. Unknown provider ⇒404.GET /social/:provider/callback— the redirect URI. AStrategybound with#[use_guards(OAuthGuard)]validates thestateagainst the cookie, exchanges the code, fetches the profile, resolves it to a local user, and hands the handler aCtx<Caller>to issue this app’s token from. A forged callback is a401denial — do not mark this route#[public], or the guard’s denial is absorbed and the failure surfaces as a missing principal instead.
#[controller(path = "/")]pub struct OAuthController { #[inject] svc: Arc<OAuthService>,}
#[routes]impl OAuthController { #[get("/social/:provider/authorize")] #[public] async fn social_authorize(&self, provider: Path<String>) -> Result<Response> { let authorization = self .svc .authorize(&provider) // → SocialRegistry .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))??; Ok(redirect_with_transaction_cookie(authorization)) }
#[get("/social/:provider/callback")] #[use_guards(OAuthGuard)] async fn social_callback(&self, caller: Ctx<Caller>) -> Result<Json<AccessTokenDto>> { Ok(Json(self.svc.issue(Some(caller.user_id), caller.org_id, caller.roles.clone())?)) }}Both legs run through the shared authorization-code flow — the same PKCE and stateless-CSRF machinery documented in OAuth2. A provider only supplies the piece that is genuinely provider-specific: how to read the profile.
Write your own provider
Section titled “Write your own provider”A provider is real code — the profile shape, and any provider quirk — behind a public trait, discovered at link time. Two files, three steps.
1. Implement the trait. authorize and exchange default to the
shared flow, so a standard provider writes only profile:
impl SocialProvider for AcmeProvider { fn key(&self) -> &'static str { "acme" } fn client(&self) -> &OAuthClient { &self.client }
fn profile<'a>(&'a self, tokens: &'a TokenSet) -> ProfileFuture<'a> { Box::pin(async move { let me: AcmeUser = self.client.fetch("https://api.acme.dev/me", &tokens.access_token).await?; Ok(SocialProfile::new("acme", me.id.to_string()) .with_email(me.email, me.email_verified) .with_name(me.name)) }) }}A provider whose protocol deviates — a per-request signed client
secret, or identity carried in the TokenSet’s id_token instead of a
userinfo endpoint — overrides exchange too. The trait does not
change.
2. Declare the config. A #[config] type plus the one question the
registry asks before it separates inert from misconfigured:
impl SocialProviderConfig for AcmeSocialConfig { fn is_unconfigured(&self) -> bool { self.client_id.is_empty() && self.client_secret.is_empty() && self.redirect_url.is_empty() }}Report false for a partially set config — that is what makes it fail
validate and abort boot instead of vanishing.
3. Submit the registry entry. resolve_provider is the standard
build: a provided instance wins, then a provided config, then the
environment.
nest_rs::core::inventory::submit! { SocialProviderEntry { key: "acme", provider_type_name: || std::any::type_name::<AcmeProvider>(), config_namespace: AcmeSocialConfig::NAMESPACE, build: |container| { resolve_provider::<AcmeProvider, AcmeSocialConfig>(container, |config| { let client = OAuthClient::new(config.oauth2_config()) .map_err(|e| anyhow::anyhow!("invalid Acme OAuth2 client config: {e}"))?; Ok(AcmeProvider::new(client)) }) }, }}There is no module to write. A social provider is never #[inject]ed by
type — it is reached through the registry as Arc<dyn SocialProvider> —
so it has nothing for a module of its own to own. Adding the crate to
Cargo.toml and setting NESTRS_SOCIAL__ACME__* is the whole wiring.
Configuration
Section titled “Configuration”Per provider, under NESTRS_SOCIAL__<PROVIDER>__: CLIENT_ID,
CLIENT_SECRET, REDIRECT_URL, and optional SCOPES (a comma list;
defaults to the provider’s login set). The secret is secret-shaped —
keep it out of committed config.
Going further
Section titled “Going further”- OAuth2 — the shared authorization-code client every provider composes.
- Split deployment — where the token this flow issues is verified.
- Providers — keyed injection, the tool for static compile-time roles (distinct from the open provider set here).