banner
innei

innei

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

在 Nest.js 中使用 Auth.js

Auth.js 是一个可以非常方便接入 OAuth 的一个身份验证库。他起初是为 Next.js 而设计。如今官方以为其供一些常用框架的集成,但是不幸的是,并没有 Nest.js 的官方支持。

这篇文章将从零开始构造一个适用于 Nest.js 的 AuthModule。那我们开始吧。

准备#

我们需要使用 @auth/core 的底层依赖,在此基础上进行封装。

npm i @auth/core

然后,我们需要知道一个 Auth 是如何适配并接入一个 Framework 的。官方提供了 Express 的支持,我们可以去源码中学习接入的步骤。

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

从源码中得知,@auth/core 是一个抽象层,我们需要做两件事:第一把原框架的 Request 转换到 WebRequest,然后由 AuthCore 处理;第二把 AuthCore 处理完成后的 WebResponse 转换到原框架的 Response。

知道了原理,那么接下来就好办了。

编写一个转换器#

首先,我们创建一个 auth 模块,例如 src/modules/auth.module.ts。模块内容展示为空。然后编写一个 AuthCore 需要的 Request/Response 转换器。这里我们创建为 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
}

拦截请求由 Auth.js 处理#

综上所述,现在我们已经实现了 AuthCore 的适配,接下来,我们就需要将请求转交给 AuthCore 处理。

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

在 Nest.js 中有两种方法可以捕获路由,我们可以使用 Controller 的正则匹配一个泛路径,然后由 authHandler 处理。或者用 middleware。

Controller 如下。

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

这里我们使用 Middleware 去做,因为 middleware 的优先级高于一切。

编写一个 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()
  }
}

在 AuthModule 中使用这个 Middleware。

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

那么,这样所有的 /auth/* 都会由 authHandler 接管了。那么到这里为止,已经可以使用了。例如下面的 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,
  }),
}

就可以实现 GitHub 的 OAuth 登录和记录 User 信息到 Database 中。

User Session#

登录完成之后,我们需要获取 Session 来判断登录状态。

编写一个 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)
        }
      })
    })
  }
}

模块化#

接下来我们可以让上面的代码更加符合 Nest 模块的规范。

我们创建一个 DynamicModule 用于配置 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)
        }
      })
    })
  }
}

现在,/auth 在 Middleware 中也是可配置的了。

在 AppModule 中注册:

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

认证守卫#

我们需要编写一个只有登录验证成功之后,才可以访问某些路由的守卫。

@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
  }
}

同时,我们会把 session isAuthenticated 附加到原始请求上。

大功告成。

示例#

最后,提供我开源的上述模板。

或者上面的内容,你看了之后还是一头雾水,没有关系,可以直接使用模板,或者从模板中获得灵感。

此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/use-auth-js-in-nestjs


加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。