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
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
}
综上所述,现在我们已经实现了 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 中。
登录完成之后,我们需要获取 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
附加到原始请求上。
大功告成。
最后,提供我开源的上述模板。
https://github.com/innei-template/nest-drizzle-authjs
或者上面的内容,你看了之后还是一头雾水,没有关系,可以直接使用模板,或者从模板中获得灵感。
此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/use-auth-js-in-nestjs