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
}
攔截請求由 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