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 がどのようにフレームワークに適合し接続されるかを理解する必要があります。公式は Express のサポートを提供しており、ソースコードから接続の手順を学ぶことができます。

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

ソースコードから、@auth/core は抽象層であり、私たちは二つのことを行う必要があります。第一に、元のフレームワークのリクエストを WebRequest に変換し、次に AuthCore が処理します。第二に、AuthCore が処理を終えた後の WebResponse を元のフレームワークのレスポンスに変換します。

原理が分かったので、次は簡単です。

変換器の作成#

まず、auth モジュールを作成します。例えば src/modules/auth.module.ts です。モジュールの内容は空のままにします。そして、AuthCore が必要とするリクエスト / レスポンスの変換器を作成します。ここでは 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 に処理させることができます。または、ミドルウェアを使用することもできます。

Controller は以下のようになります。

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

ここでは、ミドルウェアを使用します。なぜなら、ミドルウェアの優先度がすべてに優先するからです。

ミドルウェアを作成します。

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 でこのミドルウェアを使用します。

@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 ログインとユーザー情報をデータベースに記録することができます。

ユーザーセッション#

ログインが完了したら、セッションを取得してログイン状態を判断する必要があります。

サービスを作成します。

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 モジュールの規範により適合させることができます。

AuthModule を構成するための DynamicModule を作成します。

```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 はミドルウェアの中でも設定可能になりました。

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

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。