Nest Authbeta

Custom OAuth / SSO Provider

Plug in any provider not built in — Microsoft, Okta, Auth0, Discord, an internal SSO — with a full, end-to-end example.

Need Microsoft, Discord, Slack, Okta, Auth0, an internal enterprise SSO? Extend BaseAuthProvider, implement validate(), and pass an instance to the module. Nest Auth handles everything else — identity lookup, user creation, account linking, session, and events (see how SSO works).

Full example: a Discord provider, end to end

Five steps take you from nothing to a working "Sign in with Discord". Swap the URLs/fields for Microsoft, Okta, Auth0, or your internal IdP — the shape is identical.

1. Register the OAuth app with the provider

In the Discord Developer PortalNew Application → OAuth2. Add your redirect URI, select the identify + email scopes, and copy the Client ID and Client Secret. (Every provider has an equivalent console step — this is where you get the credentials.)

2. Write the provider

A BaseAuthProvider subclass needs exactly three things: providerName, getRequiredFields(), and validate(). validate() verifies the credential with the provider and returns a normalized user.

// src/auth/discord.provider.ts
import { BaseAuthProvider, AuthProviderUser } from '@ackplus/nest-auth';
import { UnauthorizedException } from '@nestjs/common';
 
interface DiscordOptions {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}
 
export class DiscordAuthProvider extends BaseAuthProvider {
  // The name you pass as `providerName` on POST /auth/login. (NOT `name`.)
  providerName = 'discord';
 
  // The user already authenticated with Discord — skip Nest Auth's own MFA
  // challenge. Omit (defaults false) to still require app-level MFA.
  skipMfa = true;
 
  constructor(private readonly opts: DiscordOptions) {
    // super() with no args — the provider registry injects the DB repositories
    // the base helpers need once the module boots.
    super();
  }
 
  // The credential fields the client MUST send (validated before validate()).
  getRequiredFields(): string[] {
    return ['token']; // switch to ['code'] to accept an auth code instead
  }
 
  async validate(credentials: { token?: string }): Promise<AuthProviderUser | null> {
    // 1. Verify the credential with the provider.
    const me = await fetch('https://discord.com/api/users/@me', {
      headers: { Authorization: `Bearer ${credentials.token}` },
    }).then((r) => r.json());
 
    if (!me?.id) throw new UnauthorizedException('Invalid Discord token');
 
    // 2. Return the normalized user.
    return {
      userId: me.id,                    // the PROVIDER's user id — field is `userId`, not `providerId`
      email: me.email,
      emailVerified: me.verified === true, // true ONLY if the provider attests it
      metadata: { username: me.username, avatarUrl: me.avatar },
    };
  }
}

3. Register it in the module

Pass a plain instance in customAuthProviders. That's it — the repositories are injected for you, so no forRootAsync or DI wiring is needed.

// app.module.ts
NestAuthModule.forRoot({
  session: { jwt: { secret: process.env.JWT_SECRET! } },
  customAuthProviders: [
    new DiscordAuthProvider({
      clientId: process.env.DISCORD_CLIENT_ID!,
      clientSecret: process.env.DISCORD_CLIENT_SECRET!,
      redirectUri: process.env.DISCORD_REDIRECT_URI!,
    }),
  ],
});

POST /auth/login with { providerName: 'discord', … } now works.

4. Call it from the frontend

Get a Discord access token with your OAuth flow, then log in. A first-time user needs createUserIfNotExists: true — the client SDK's socialLogin() sets it for you:

import { AuthClient } from '@ackplus/nest-auth-client';
const client = new AuthClient({ baseUrl: '/api' });
 
// socialLogin defaults createUserIfNotExists: true
const res = await client.socialLogin('discord', discordAccessToken);
// res is a normal auth response (session/tokens) — the user is signed in.

React (useNestAuth().login) — pass the flag yourself:

const { login } = useNestAuth();
await login({
  providerName: 'discord',
  credentials: { token: discordAccessToken },
  createUserIfNotExists: true,
});

5. Verify with curl

curl -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{
    "providerName": "discord",
    "credentials": { "token": "<a real discord access token>" },
    "createUserIfNotExists": true
  }'

A 2xx with a session/token body means it works. On the first call a NestAuthUser + identity row are created; subsequent logins with the same Discord id resolve to the same user.

The AuthProviderUser you return

FieldMeaning
userId (required)The provider's stable user id (an OAuth sub, Discord id, …). Identities are keyed on this — not your local user id.
email / phoneUsed to find/create the local user and to link to an existing account.
emailVerified / phoneVerifiedSet true only when the provider proves it. Controls the linking gate and whether emailVerifiedAt is stamped.
metadataAnything else (username, avatar, raw profile) — stored on the identity and available to your hooks.

Return null (or throw) when the credential is invalid.

What you get for free

Once validate() returns, the library:

  • Looks up the identity (nest_auth_identities for provider='discord' + the returned userId) → logs the existing user in.
  • Creates a new NestAuthUser + identity when the identity is new and createUserIfNotExists is set — emitting UserRegisteredEvent.
  • Links to an existing account when the email matches — but only when emailVerified: true (the verified-email gate; social.requireVerifiedEmailForLinking).
  • Issues the session / JWT and emits UserLoggedInEvent.

When the client sends an auth code instead of a token

If your provider hands the frontend a one-time authorization code, exchange it for a token inside validate() and declare getRequiredFields() as ['code']:

getRequiredFields() { return ['code']; }
 
async validate(credentials: { code?: string }): Promise<AuthProviderUser | null> {
  const tokenResp = await fetch('https://discord.com/api/oauth2/token', {
    method: 'POST',
    body: new URLSearchParams({
      client_id: this.opts.clientId,
      client_secret: this.opts.clientSecret,
      code: credentials.code!,
      grant_type: 'authorization_code',
      redirect_uri: this.opts.redirectUri,
    }),
  }).then((r) => r.json());
 
  const me = await fetch('https://discord.com/api/users/@me', {
    headers: { Authorization: `Bearer ${tokenResp.access_token}` },
  }).then((r) => r.json());
 
  if (!me?.id) return null;
  return { userId: me.id, email: me.email, emailVerified: me.verified === true };
}

Enterprise SSO (SAML / OIDC / IdP groups)

The same shape covers enterprise SSO — verify the assertion/id-token in validate() (for OIDC, validate the id_token signature against the IdP's JWKS and return its sub/email) and return the user. To map IdP groups → Nest Auth roles, see the external role resolver recipe.

Common mistakes

SymptomCause
providerName unknown on loginYou set name instead of providerName, or forgot to add the instance to customAuthProviders.
First social login returns 401 INVALID_CREDENTIALSNew user without createUserIfNotExists: true — use socialLogin() or pass the flag.
Social identity didn't link to the existing email accountemailVerified wasn't true, and social.requireVerifiedEmailForLinking is on (default). Set emailVerified when the provider proves it.
MISSING_REQUIRED_FIELDSThe client didn't send a field named in getRequiredFields().