Skip to content

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.

Terminal window
cargo add nest-rs --features social

The social feature implies authn: the authorization-code machinery SocialModule drives (OAuthClient, TokenSet) arrives with it.

apps/auth/src/module.rs
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.

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.

Terminal window
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/callback

NESTRS_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
Completeactive
Absent entirelyinert — 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 invalidboot 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.

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, call authorize, return a 302 with the PKCE/CSRF transaction in a signed, short-lived cookie. Unknown provider ⇒ 404.
  • GET /social/:provider/callback — the redirect URI. A Strategy bound with #[use_guards(OAuthGuard)] validates the state against the cookie, exchanges the code, fetches the profile, resolves it to a local user, and hands the handler a Ctx<Caller> to issue this app’s token from. A forged callback is a 401 denial — do not mark this route #[public], or the guard’s denial is absorbed and the failure surfaces as a missing principal instead.
crates/features/src/oauth/http/controller.rs (from the demo, abridged)
#[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.

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:

crates/features/src/social/provider.rs
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:

crates/features/src/social/config.rs
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.

crates/features/src/social/provider.rs
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.

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.

  • 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).