DocumentationReference

HazelJS OAuth Package

npm downloads

@hazeljs/oauth provides OAuth 2.0 social login and native SAML SSO for HazelJS applications — Google, Microsoft, GitHub, Facebook, and Twitter out of the box, plus multi-IdP enterprise SSO.

Quick Reference

  • Purpose: @hazeljs/oauth provides OAuth 2.0 social login and SAML SP flows with authorization URL generation, state/PKCE validation, token exchange, SAML ACS handling, and user/profile extraction.
  • When to use: Use @hazeljs/oauth when a HazelJS application needs social login and/or enterprise SSO (SAML). Use @hazeljs/auth alongside it to issue and validate your app JWTs.
  • Key concepts: OAuth 2.0 flow, SAML SP flow, authorization URL, callback handler, state validation, PKCE, ACS callback, IdP metadata, OAuthModule, provider configuration.
  • Dependencies: @hazeljs/core, @hazeljs/auth (for JWT after OAuth).
  • Common patterns: Register OAuthModule with provider credentials → redirect user to authorization URL → handle callback → exchange code for tokens → fetch user profile → issue JWT.
  • Common mistakes: Not validating OAuth state parameter (CSRF vulnerability); not setting correct callback URLs; hardcoding client secrets instead of using environment variables.

Purpose

Implementing OAuth from scratch involves handling authorization URLs, state validation, PKCE for some providers, token exchange, and user profile fetching. The @hazeljs/oauth package simplifies this by providing:

  • Multi-Provider Support: Google, Microsoft, GitHub, Facebook, Twitter with a unified API
  • Native SAML SP: Multi-IdP SAML entry/callback/metadata support
  • PKCE Handling: Automatic code verifier generation and validation for Google, Microsoft, and Twitter
  • User Profile Fetching: Fetches user id, email, name, and picture from provider APIs
  • Optional Controller: Ready-made /auth/:provider and /auth/:provider/callback routes
  • callbackHandler hook: Inject your own handler class to issue a JWT, look up the user in your DB, and return { token, user } — all wired through the DI container

Architecture

flowchart TB
  subgraph UserFlow [OAuth Flow]
      A[User clicks Login] --> B[GET /auth/google]
      B --> C[Redirect to provider]
      C --> D[User authenticates]
      D --> E[Callback with code]
      E --> F[OAuthService.handleCallback]
      F --> G[OAuthCallbackHandler.handle]
      G --> H["Return { token, user }"]
  end

  subgraph Package [@hazeljs/oauth]
      OAuthModule
      OAuthService
      OAuthController
      OAuthCallbackHandler
  end

  subgraph App [Your Application]
      Handler[YourCallbackHandler]
      DB[(Database)]
      JWT[JwtService]
  end

  OAuthService --> Arctic[Arctic Library]
  OAuthModule --> OAuthService
  G --> Handler
  Handler --> DB
  Handler --> JWT

Key Components

  1. OAuthService: getAuthorizationUrl(), handleCallback(), executeCallback(), validateState(), plus SAML helpers
  2. OAuthController: Optional OAuth routes and SAML routes (/auth/saml/:idp, callback, metadata)
  3. OAuthCallbackHandler: Interface — implement this to bridge the OAuth result into your JWT system
  4. OAuthStateGuard: Validates state parameter on callback (CSRF protection)

Installation

npm install @hazeljs/oauth

Or with the CLI:

hazel add oauth

Quick Start

Basic Setup

Configure providers and register the module. Pass a callbackHandler class to receive a { token, user } response instead of raw OAuth tokens:

import { HazelModule } from '@hazeljs/core';
import { OAuthModule } from '@hazeljs/oauth';
import { MyOAuthCallbackHandler } from './auth/oauth-callback.handler';

@HazelModule({
  imports: [
    OAuthModule.forRoot({
      providers: {
        google: {
          clientId:     process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
          redirectUri:  process.env.GOOGLE_REDIRECT_URI!,
        },
        github: {
          clientId:     process.env.GITHUB_CLIENT_ID!,
          clientSecret: process.env.GITHUB_CLIENT_SECRET!,
          redirectUri:  process.env.GITHUB_REDIRECT_URI!,
        },
      },
      // Optional — when set, OAuthController calls handler.handle() before responding.
      // The handler is resolved from the DI container so it can inject any service.
      callbackHandler: MyOAuthCallbackHandler,
    }),
  ],
})
export class AppModule {}

Using the Built-in Controller

The OAuthController is included by default. It provides:

  • GET /auth/:provider — Redirects user to the OAuth provider (google, microsoft, github, facebook, twitter)
  • GET /auth/:provider/callback — Handles the callback, returns JSON with accessToken, user, etc.
  • GET /auth/saml/:idp — Initiates SAML login and redirects to IdP
  • POST /auth/saml/:idp/callback — Handles SAML ACS callback (SAMLResponse)
  • GET /auth/saml/:idp/metadata — Returns SP metadata XML

Example flow:

  1. User visits GET /auth/google → redirected to Google
  2. After auth, Google redirects to GET /auth/google/callback?code=...&state=...
  3. Callback returns { accessToken, refreshToken?, expiresAt?, user: { id, email, name, picture } }

For redirect-based flows, use query params:

  • ?successRedirect=https://yourapp.com/dashboard — redirect after success
  • ?errorRedirect=https://yourapp.com/login — redirect on error

Using OAuthService Directly

For custom flows, inject OAuthService:

import { Service } from '@hazeljs/core';
import { OAuthService } from '@hazeljs/oauth';

@Service()
export class CustomAuthController {
  constructor(private readonly oauthService: OAuthService) {}

  @Get('login/google')
  async loginGoogle(@Res() res: Response) {
    const { url, state, codeVerifier } = this.oauthService.getAuthorizationUrl('google');
    // Store state and codeVerifier in cookies/session
    res.redirect(url);
  }

  @Get('oauth/callback')
  async callback(
    @Query() query: { code: string; state: string },
    @Req() req: Request
  ) {
    const storedState = getStoredState(req); // from cookie/session
    const codeVerifier = getStoredCodeVerifier(req); // for Google, Microsoft, Twitter

    if (!this.oauthService.validateState(query.state, storedState)) {
      throw new UnauthorizedError('Invalid state');
    }

    const result = await this.oauthService.handleCallback(
      'google',
      query.code,
      query.state,
      codeVerifier
    );

    // result: { accessToken, refreshToken?, expiresAt?, user }
    // Create/update user in DB, issue JWT via JwtService, etc.
    return result;
  }
}

Provider Configuration

Google

google: {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}

Default scopes: openid, profile, email

Microsoft Entra ID

microsoft: {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
  tenant?: string; // default: 'common' (multi-tenant)
}

Default scopes: openid, profile, email

GitHub

github: {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}

Default scopes: user:email

Facebook

facebook: {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}

Default scopes: email, public_profile

Twitter

twitter: {
  clientId: string;
  clientSecret?: string | null; // optional for public clients (PKCE-only)
  redirectUri: string;
}

Default scopes: users.read, tweet.read

Note: Twitter API v2 does not provide user email. Add offline.access scope for refresh tokens.

SAML (Multi-IdP)

saml: {
  oktaMain: {
    idpKey: 'okta-main',
    ssoUrl: 'https://your-org.okta.com/app/abc/sso/saml',
    issuer: 'https://api.example.com',
    acsUrl: 'https://api.example.com/auth/saml/okta-main/callback',
    audience: 'https://api.example.com',
    relayState: 'app=dashboard',
  },
  azureMain: {
    idpKey: 'azure-main',
    ssoUrl: 'https://login.microsoftonline.com/.../saml2',
    issuer: 'https://api.example.com',
    acsUrl: 'https://api.example.com/auth/saml/azure-main/callback',
  },
}

SAML flow:

  1. GET /auth/saml/:idp generates SAMLRequest and redirects to IdP
  2. IdP posts SAMLResponse to POST /auth/saml/:idp/callback
  3. Package parses assertion and invokes optional callback handler

Custom Scopes

Override default scopes via defaultScopes or pass to getAuthorizationUrl:

OAuthModule.forRoot({
  providers: { google: {...}, github: {...} },
  defaultScopes: {
    google: ['openid', 'profile', 'email', 'https://www.googleapis.com/auth/calendar.readonly'],
    github: ['user:email', 'repo'],
  },
});

// Or per-request:
const { url } = this.oauthService.getAuthorizationUrl('google', undefined, [
  'openid',
  'profile',
  'email',
  'https://www.googleapis.com/auth/drive.readonly',
]);

Integration with @hazeljs/auth

Implement the OAuthCallbackHandler interface to handle OAuth or SAML callback results in a class that can inject any service via the DI container. The built-in OAuthController calls handler.handle() automatically after successful callbacks.

import { Injectable } from '@hazeljs/core';
import { JwtService } from '@hazeljs/auth';
import type {
  OAuthCallbackHandler,
  OAuthProtocolCallbackResult,
  SupportedProvider,
} from '@hazeljs/oauth';

@Injectable()
export class MyOAuthCallbackHandler implements OAuthCallbackHandler {
  constructor(
    private readonly jwtService: JwtService,
    private readonly usersRepo: UsersRepository,
    private readonly orgsRepo: OrganizationsRepository,
  ) {}

  async handle(
    result: OAuthProtocolCallbackResult,
    _provider: SupportedProvider | `saml:${string}`,
  ) {
    const oauthUser = result.user;

    // 1. Find or create the application user
    let user = await this.usersRepo.findByEmail(oauthUser.email);
    const isNewUser = !user;

    if (!user) {
      const org = await this.orgsRepo.findOrCreate(oauthUser.email.split('@')[1]);
      user = await this.usersRepo.create({
        email:          oauthUser.email,
        name:           oauthUser.name ?? '',
        role:           'user',
        organizationId: org.id,
      });
    }

    // 2. Issue a HazelJS JWT — same shape JwtAuthGuard, RoleGuard, TenantGuard all expect
    const token = this.jwtService.sign({
      sub:      user.id,
      email:    user.email,
      role:     user.role,
      tenantId: user.organizationId,
    });

    return { token, user, isNewUser };
  }
}

Register the handler as a provider and pass it to OAuthModule.forRoot():

// auth.module.ts
@Module({
  providers: [MyOAuthCallbackHandler],
  exports:   [MyOAuthCallbackHandler],
})
export class AuthModule {}

// app.module.ts
OAuthModule.forRoot({
  providers: { google: {...}, github: {...} },
  callbackHandler: MyOAuthCallbackHandler,
})

After this, OAuth and SAML callbacks can both return { token, user, isNewUser } (or your own shape) — using the same JWT payload consumed by JwtAuthGuard, RoleGuard, and TenantGuard.

Using OAuthService directly

For complete control, skip the built-in OAuthController and call OAuthService from your own controller:

import { OAuthService } from '@hazeljs/oauth';
import { JwtService } from '@hazeljs/auth';

@Controller('/auth')
export class AuthController {
  constructor(
    private readonly oauthService: OAuthService,
    private readonly jwtService: JwtService,
  ) {}

  @Get('/google')
  loginWithGoogle(@Res() res: Response) {
    const { url, state, codeVerifier } = this.oauthService.getAuthorizationUrl('google');
    // store state + codeVerifier in httpOnly cookies
    res.status(302);
    res.setHeader('Location', url);
    res.end();
  }

  @Get('/google/callback')
  async googleCallback(
    @Query() query: { code: string; state: string },
    @Req()   req: { headers?: Record<string, string> },
    @Res()   res: Response,
  ) {
    const storedState   = getCookie(req, 'oauth_state');
    const codeVerifier  = getCookie(req, 'oauth_code_verifier');

    if (query.state !== storedState) {
      res.status(400).json({ error: 'Invalid state' });
      return;
    }

    const result = await this.oauthService.handleCallback(
      'google', query.code, storedState, codeVerifier,
    );

    // result: { accessToken, user: { id, email, name, picture } }
    // → look up user in DB, issue JWT, etc.
    const token = this.jwtService.sign({ sub: result.user.id, email: result.user.email });
    res.status(200).json({ token });
  }
}

Environment Variables

GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
MICROSOFT_CLIENT_ID=your-microsoft-client-id
MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
FACEBOOK_CLIENT_ID=your-facebook-client-id
FACEBOOK_CLIENT_SECRET=your-facebook-client-secret
TWITTER_CLIENT_ID=your-twitter-client-id
TWITTER_CLIENT_SECRET=your-twitter-client-secret
OAUTH_REDIRECT_URI=http://localhost:3000/auth/google/callback

Best Practices

  1. State validation: Always validate the state parameter on callback to prevent CSRF.
  2. HTTPS in production: Use HTTPS for redirect URIs in production.
  3. Secure cookie storage: Store state and codeVerifier in httpOnly, SameSite cookies.
  4. Token storage: Store access/refresh tokens securely (encrypted if in DB). The package returns them; storage is your responsibility.
  5. Combine with JWT: Use OAuth for login, then issue your own JWT for API authentication.

What's Next?

  • Learn about Auth for JWT, role guards, and tenant isolation
  • Explore Config for managing OAuth secrets
  • Check out TypeORM or Prisma for user storage

Recipes

Recipe: Google OAuth Login

// File: src/app.module.ts
import { HazelModule } from '@hazeljs/core';
import { OAuthModule } from '@hazeljs/oauth';

@HazelModule({
  imports: [
    OAuthModule.register({
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET,
        callbackUrl: '/auth/google/callback',
        scope: ['email', 'profile'],
      },
    }),
  ],
})
export class AppModule {}
// File: src/auth/oauth.controller.ts
import { Controller, Get, UseGuards, Req, Res } from '@hazeljs/core';
import { OAuthGuard, OAuthCallback } from '@hazeljs/oauth';
import { AuthService } from '@hazeljs/auth';
import { Response } from 'express';

@Controller('auth')
export class OAuthController {
  constructor(private readonly auth: AuthService) {}

  @Get('google')
  @UseGuards(OAuthGuard('google'))
  googleLogin() {}

  @Get('google/callback')
  @OAuthCallback('google')
  async googleCallback(@Req() req: any, @Res() res: Response) {
    const jwt = await this.auth.login(req.user);
    res.redirect(`/dashboard?token=${jwt.accessToken}`);
  }
}