banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Using Auth.js in Nest.js

Auth.js is a very convenient authentication library for integrating OAuth. It was initially designed for Next.js. Now the official team provides integrations for some commonly used frameworks, but unfortunately, there is no official support for Nest.js.

This article will construct an AuthModule suitable for Nest.js from scratch. Let's get started.

Preparation#

We need to use the underlying dependency of @auth/core and wrap it.

npm i @auth/core

Next, we need to understand how an Auth adapts and integrates with a Framework. The official support for Express is provided, and we can learn the integration steps from the source code.

https://github.com/nextauthjs/next-auth/blob/main/packages/frameworks-express/src/index.ts

From the source code, we learn that @auth/core is an abstraction layer, and we need to do two things: first, convert the original framework's Request to WebRequest, and then let AuthCore handle it; second, convert the WebResponse processed by AuthCore back to the original framework's Response.

Now that we understand the principle, we can proceed.

Writing a Converter#

First, we create an auth module, for example, src/modules/auth.module.ts. The module content is initially empty. Then we write a Request/Response converter needed by AuthCore. Here we create it as src/module/auth/auth.implement.ts.

```ts filename="src/module/auth/auth.implement.ts" export type ServerAuthConfig = Omit & { basePath: string }

export function CreateAuth(config: ServerAuthConfig) {
return async (req: IncomingMessage, res: ServerResponse) => {
try {
setEnvDefaults(process.env, config)

    const auth = await Auth(await toWebRequest(req), config)

    await toServerResponse(req, auth, res)
  } catch (error) {
    console.error(error)
    // throw error
    res.end(error.message)
  }
}

}

async function toWebRequest(req: IncomingMessage) {
const host = req.headers.host || 'localhost'
const protocol = req.headers['x-forwarded-proto'] || 'http'
const base = ${protocol}://${host}

return getRequest(base, req)

}

async function toServerResponse(
req: IncomingMessage,
response: Response,
res: ServerResponse,
) {
response.headers.forEach((value, key) => {
if (value) {
res.setHeader(key, value)
}
})

res.setHeader('Content-Type', response.headers.get('content-type') || '')
res.setHeader('access-control-allow-methods', 'GET, POST')
res.setHeader('access-control-allow-headers', 'content-type')
res.setHeader(
  'access-control-allow-origin',
  req.headers.origin || req.headers.referer || req.headers.host || '*',
)
res.setHeader('access-control-allow-credentials', 'true')

const text = await response.text()
res.writeHead(response.status, response.statusText)
res.end(text)

}


</Tab>

<Tab label="getRequest">
```ts filename="src/module/auth/req.transformer.ts"
import { PayloadTooLargeException } from '@nestjs/common'
import type { IncomingMessage } from 'node:http'

/**
 * @param {import('http').IncomingMessage} req

 */
function get_raw_body(req) {
  const h = req.headers

  if (!h['content-type']) {
    return null
  }

  const content_length = Number(h['content-length'])

  // check if no request body
  if (
    (req.httpVersionMajor === 1 &&
      isNaN(content_length) &&
      h['transfer-encoding'] == null) ||
    content_length === 0
  ) {
    return null
  }

  if (req.destroyed) {
    const readable = new ReadableStream()
    readable.cancel()
    return readable
  }

  let size = 0
  let cancelled = false

  return new ReadableStream({
    start(controller) {
      req.on('error', (error) => {
        cancelled = true
        controller.error(error)
      })

      req.on('end', () => {
        if (cancelled) return
        controller.close()
      })

      req.on('data', (chunk) => {
        if (cancelled) return

        size += chunk.length
        if (size > content_length) {
          cancelled = true

          const constraint = content_length
            ? 'content-length'
            : 'BODY_SIZE_LIMIT'
          const message = `request body size exceeded ${constraint} of ${content_length}`

          const error = new PayloadTooLargeException(message)
          controller.error(error)

          return
        }

        controller.enqueue(chunk)

        if (controller.desiredSize === null || controller.desiredSize <= 0) {
          req.pause()
        }
      })
    },

    pull() {
      req.resume()
    },

    cancel(reason) {
      cancelled = true
      req.destroy(reason)
    },
  })
}

export async function getRequest(
  base: string,
  req: IncomingMessage,
): Promise<Request> {
  const headers = req.headers as Record<string, string>

  // @ts-expect-error
  const request = new Request(base + req.originalUrl, {
    method: req.method,
    headers,
    body: get_raw_body(req),
    credentials: 'include',
    // @ts-expect-error
    duplex: 'half',
  })
  return request
}

Intercept Requests for Auth.js Processing#

In summary, we have now implemented the adaptation of AuthCore, and next, we need to pass the request to AuthCore for processing.

// Create an auth handler
const authHandler = CreateAuth(config) // your auth config

In Nest.js, there are two ways to capture routes. We can use a regex match in the Controller for a generic path and then let authHandler handle it, or use middleware.

The Controller is as follows.

@Controller('auth')
export class AuthController {
  @Get('/*')
  @Post('/*')
  async handle(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
    return authHandler(req, res)
  }
}

Here we use Middleware because the priority of middleware is higher than anything else.

Write a Middleware.

export class AuthMiddleware implements NestMiddleware {
  async use(req: IncomingMessage, res: ServerResponse, next: () => void) {
    if (req.method !== 'GET' && req.method !== 'POST') {
      next()
      return
    }

    await authHandler(req, res)

    next()
  }
}

Use this Middleware in AuthModule.

@Module({})
export class AuthModule {
 configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthMiddleware)
      .forRoutes(`/auth/(.*)`)
  }
}

Now, all /auth/* will be handled by authHandler. At this point, it can already be used. For example, the following authConfig.

export const authConfig: ServerAuthConfig = {
  basePath: isDev ? '/auth' : `/api/v${API_VERSION}/auth`,
  secret: AUTH.secret,
  callbacks: {
    redirect({ url }) {
      return url
    },
  },
  providers: [
    GitHub({
      clientId: AUTH.github.clientId,
      clientSecret: AUTH.github.clientSecret,
    }),
  ],
  adapter: DrizzleAdapter(db, {
    usersTable: users,
    accountsTable: accounts,
    sessionsTable: sessions,
    verificationTokensTable: verificationTokens,
    authenticatorsTable: authenticators,
  }),
}

This allows for GitHub OAuth login and recording User information in the Database.

User Session#

After logging in, we need to obtain the Session to determine the login status.

Write a Service.

export interface SessionUser {
  sessionToken: string
  userId: string
  expires: string
}
@Injectable()
export class AuthService {

  private async getSessionBase(req: IncomingMessage, config: ServerAuthConfig) {
    setEnvDefaults(process.env, config)

    const protocol = (req.headers['x-forwarded-proto'] || 'http') as string
    const url = createActionURL(
      'session',
      protocol,
      // @ts-expect-error

      new Headers(req.headers),
      process.env,
      config.basePath,
    )

    const response = await Auth(
      new Request(url, { headers: { cookie: req.headers.cookie ?? '' } }),
      config,
    )

    const { status = 200 } = response

    const data = await response.json()

    if (!data || !Object.keys(data).length) return null
    if (status === 200) return data
  }

  getSessionUser(req: IncomingMessage) {
    return new Promise<SessionUser | null>((resolve) => {
      this.getSessionBase(req, {
        ...authConfig,
        callbacks: {
          ...authConfig.callbacks,
          async session(...args) {
            resolve(args[0].session as SessionUser)

            const session =
              (await authConfig.callbacks?.session?.(...args)) ??
              args[0].session
            const user = args[0].user ?? args[0].token
            return { user, ...session } satisfies Session
          },
        },
      }).then((session) => {
        if (!session) {
          resolve(null)
        }
      })
    })
  }
}

Modularization#

Next, we can make the above code more compliant with Nest module specifications.

We create a DynamicModule for configuring AuthModule.

```ts filename="auth.module.ts" const AuthConfigInjectKey = Symbol()

@Module({})
@Global()
export class AuthModule implements NestModule {
constructor(
@Inject(AuthConfigInjectKey) private readonly config: ServerAuthConfig,
) {}

static forRoot(config: ServerAuthConfig): DynamicModule {
  return {
    module: AuthModule,
    global: true,
    exports: [AuthService],
    providers: [
      {
        provide: AuthService,
        useFactory() {
          return new AuthService(config)
        },
      },
      {
        provide: AuthConfigInjectKey,
        useValue: config,
      },
    ],
  }
}

configure(consumer: MiddlewareConsumer) {
  const config = this.config

  consumer
    .apply(AuthMiddleware)
    .forRoutes(`${config.basePath || '/auth'}/(.*)`)
}

}

</Tab>
<Tab label="Service">
```ts filename="auth.service.ts" {19}
import { Injectable } from '@nestjs/common'
import { ServerAuthConfig } from './auth.implement'

import {
  Auth,
  createActionURL,
  setEnvDefaults,
  type Session,
} from '@meta-muse/complied'
import { IncomingMessage } from 'http'

export interface SessionUser {
  sessionToken: string
  userId: string
  expires: string
}
@Injectable()
export class AuthService {
  constructor(private readonly authConfig: ServerAuthConfig) {}

  private async getSessionBase(req: IncomingMessage, config: ServerAuthConfig) {
    setEnvDefaults(process.env, config)

    const protocol = (req.headers['x-forwarded-proto'] || 'http') as string
    const url = createActionURL(
      'session',
      protocol,
      // @ts-expect-error

      new Headers(req.headers),
      process.env,
      config.basePath,
    )

    const response = await Auth(
      new Request(url, { headers: { cookie: req.headers.cookie ?? '' } }),
      config,
    )

    const { status = 200 } = response

    const data = await response.json()

    if (!data || !Object.keys(data).length) return null
    if (status === 200) return data
  }

  getSessionUser(req: IncomingMessage) {
    const { authConfig } = this
    return new Promise<SessionUser | null>((resolve) => {
      this.getSessionBase(req, {
        ...authConfig,
        callbacks: {
          ...authConfig.callbacks,
          async session(...args) {
            resolve(args[0].session as SessionUser)

            const session =
              (await authConfig.callbacks?.session?.(...args)) ??
              args[0].session
            const user = args[0].user ?? args[0].token
            return { user, ...session } satisfies Session
          },
        },
      }).then((session) => {
        if (!session) {
          resolve(null)
        }
      })
    })
  }
}

Now, /auth is also configurable in Middleware.

Register in AppModule:

@Module({
  imports: [
    AuthModule.forRoot(authConfig),
  ],
  controllers: [],
  providers: []
})
export class AppModule {}

Authentication Guard#

We need to write a guard that only allows access to certain routes after successful login verification.

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    @Inject(AuthService)
    private readonly authService: AuthService,
  ) {}
  async canActivate(context: ExecutionContext): Promise<any> {
    const req = context.switchToHttp().getRequest()
    const session = await this.authService.getSessionUser(req.raw)

    req.raw['session'] = session
    req.raw['isAuthenticated'] = !!session

    if (!session) {
      throw new UnauthorizedException()
    }

    return !!session
  }
}

At the same time, we will attach session and isAuthenticated to the original request.

Great job.

Example#

Finally, here is the open-source template mentioned above.

Or if you still feel confused after reading the above content, no worries, you can directly use the template or draw inspiration from it.

This article is updated by Mix Space to xLog. The original link is https://innei.in/posts/tech/use-auth-js-in-nestjs

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.