Auth0 × Next.js 15 セッション有効期限と "謎の期限切れ" にハマらないための備忘録

Auth0 を利用して Web アプリケーションを開発していますが、セッションの有効期限についてわかったことがいろいろとあるので今後のためにも忘れないようにまとめておきたいと思います。 本記事では、Next.js 15 と @auth0/nextjs-auth0 を利用する前提で、意識すべきセッションの構造と、それぞれの有効期限について備忘録としてまとめます。

1. 意識すべきセッション

  • 場所: ブラウザの appSession Cookie(暗号化)。
  • 役割: 「Next.js サーバーから見て、このブラウザが誰であるか」を識別するもの。
  • 期限切れの影響: 画面をリロードした際などに「未ログイン」と判定されます。
  • Tips: Cookie の 4KB 制限に引っかかる場合やその他の要件次第では ストア先をDBに変更 することも可能です。

② アクセストークン(Access Token: AT)とリフレッシュトークン(Refresh Token: RT)

  • 場所: Next.js のサーバーサイドセッション内に保存。
  • 重要ポイント:
    • 外部 API サーバにて AT を検証するために jose などのライブラリを使って検証する場合、Auth0 で API を作成し、その識別子を audience に指定しないと Opaque Token (不透明なトークン)となり、検証することができません。
    • AT が切れても getAccessToken() によって RT があれば SDK が裏で更新してくれます。しかし、更新された AT については Next.js 15 の App Router 環境では Middleware で getAccessToken(req, res) を実行して明示的にセッションを更新しない限り、Cookie は自動的に保存されません(公式のBest Practice参照)。
  • 場所: Auth0 側のドメイン(xxx.auth0.com)の Cookie
  • 役割: 「Auth0(認証基盤)自体にログインしているか」の状態。
  • 期限切れの影響: アプリのセッションが切れて Auth0 へ飛ばされた際、ここが生きていれば ID/パスワード入力なしでアプリに戻れますが、ここも切れていると再度ログイン画面で入力を求められます。

なぜ Google 認証は毎回アカウント選択が出るのか?

Auth0 のセッションが生きていても、例えばソーシャルログインで Google 認証を選択した際に画面が出るのは 「上流のプロバイダー(Google)のセッション」 が別途存在するからです。 特に、Auth0 からのリクエストに prompt=select_account が含まれていると、Google は必ずアカウント選択画面を表示します。これは意図しないアカウントでの自動ログインを防ぐための挙動です。

2. 各期限の設定と作用の詳細

それぞれの有効期限をどう設定すべきか、Auth0 ダッシュボード上の項目と照らし合わせて確認します。

Auth0 セッションの期限(Tenant Settings / Applications)

Auth0 には「Inactivity Timeout(非活動タイムアウト)」と「Require Log In After(絶対タイムアウト)」の2つがあります。 これらはテナント共通の設定になります。

  • Inactivity Timeout: 最後にアクセスしてから何日間有効か(例: 3日間)。
  • Absolute Timeout: ログインしてから最大何日間有効か(例: 30日間)。
  • ポイント: これがアプリのセッションより短いと、アプリ側で「セッションを維持したい」と思っても、Auth0 側で弾かれる原因になります。

アクセストークンとリフレッシュトークンの期限(API Settings)

以下は API で設定します

  • Access Token Lifetime: 通常は 3600秒(1時間) 程度が推奨されます。短く保ち、漏洩リスクを抑えます。 以下はアプリケーションで設定します
  • Refresh Token Rotation: 有効化を強く推奨。RTを使用するたびに新しいRTを発行する仕組みです。
  • RTの期限: 数日間〜数週間。さらに、セキュリティ要件に応じて「Absolute Lifetime」を設定します。

Next.js セッションの期限(SDK構成)

セッションの期限の設定はこちらを参照してください。

  • rolling: アクセスがあるたびにセッションの有効期限を更新するかどうか。
  • inactivityDuration: 最後にアクセスしてから「何秒間」セッションを維持するか。これを短くすると、放置したユーザーがすぐにログアウトされます。
  • absoluteDuration: アクティビティに関係なく、最初のログインから最大で「何秒間」セッションを維持するか。これを過ぎると強制的に再認証が必要です。

3. 有効期限のバランス

これら3つの期限は、以下の関係性を保つとなぜかログアウトしている、といった事態を防ぎ、一貫性のあるユーザー体験を提供できます。

Auth0セッション ≧ アプリセッション ≧ リフレッシュトーク

  1. RTはアプリセッションより短くしない: RTが先に切れると、アプリはログイン中(Cookieがある)なのにAPIが叩けない、という「中途半端な状態」が発生します。
  2. Auth0セッションは長めに: アプリのセッションが不慮の事態で切れても、Auth0側のセッションが生きていれば、ユーザーは「ログインボタン」を押すだけで、パスワード入力を省略して復帰できます。また、アプリAでログイン状態中にアプリBを訪問した際にサイレントログインによって自然とログイン状態になっている、という体験も提供できるようになります。

まとめ

これら3つのセッションを意識できていると、ログイン周りのトラブルシューティングがスムーズになります。

ファイルを含む集約ルートをリポジトリにストアする際の工夫

DDD を実践していると、集約ルートの中にファイルが含まれるケースに遭遇することがあります。例えば、ユーザープロフィールに画像がある場合や、記事に添付ファイルがある場合などです。この記事では、ファイルを含む集約ルートをリポジトリに保存する際の課題と、その解決策について考えていきます。

前提

  • DDD の集約ルートの詳細な説明は割愛します
  • ファイルの保存先は Amazon S3 とします
  • tsoa を用いて OpenAPI Specification を自動生成します

課題

ファイルを含む集約ルートをリポジトリに保存する際、以下が悩ましいポイントです。

multipart リクエストとスキーマ定義の再利用性

ファイルを含む Command を実行する場合、HTTP 通信では multipart リクエストになります。これにより、tsoa のスキーマ定義の再利用が難しくなります。 例えば、 title, content, attachments という項目がある場合 tsoa では application/jsonmultipart/form-data では以下のような違いがあります:

application/json の場合、モデルとして定義できます

interface CreateCommand {
  title: string
  content: string
  attachments: AttachmentFile[] // AttachmentFile の定義は割愛
}

multipart/form-data の場合、コントローラーに定義します

export class ArticleController extends Controller {
  @Post("post")
  public async post(
      @FormField() title: string,
      @FormField() content: string,
      @UploadedFiles() attachments: Multer.File[],
  ): Promise<void> {
      ...
  }
}

例えば、Controller から Application 層にリクエストの内容を渡すときにドメインオブジェクトを構築して転送するような DTO がある場合、 モデルで定義していると DTO の引数の型はそのまま再利用することができますが、multipart/form-data の場合パラメータデコレーターになっているため型が再利用することができません。 今回の例のようなパラメータ数だと割り切ることもできますがもう少し数が多いと割り切るのも難しくなってきますね。

メモリが不足する可能性

特に複数のファイルを扱う場合、サーバーのメモリが圧迫されるためサーバー側で OutOfMemory のリスクが気になります。

集約境界の問題

ファイルを別の集約ルートとして扱う方法も考えられますが、これでは集約を跨いだ更新が必要になります。集約ルートは強い整合性を維持する範囲にしたいため、ファイルが集約の一部である場合はできるだけ同じ集約内で管理したいところです。

解決策

これらの課題に対処するため、以下のようなアプローチを考えてみました。

1. ファイル単位のアップロードコマンドのエンドポイントを用意

まず、ファイルを1つずつアップロードするための専用 Command のエンドポイントを用意します:

export class ArticleController extends Controller {
    @Post('file/upload')
    public async uploadFile(
        @UploadedFile() file: Multer.File,
    ): Promise<{file_id: string}> {
      ...
    }

2. メタデータの活用

ファイルアップロード時に、ドメインオブジェクトの内容をメタデータとしてS3に保存します。これにより、後でファイルを取得する際にドメインオブジェクトを再構築できます。

    public async uploadFile(
        file: AttachmentFile, // アップロードされた添付ファイルの Entity
    ): Promise<void> {
        const sourceKey = keys.temp(file.id);

        try {
            await this.s3.putObject(sourceKey, file.body.buffer, {
                filename: encodeURIComponent(file.body.filename),
                size: file.body.size.toString(),
                mimetype: file.body.mimetype,
            });
        } catch (error) {
          ...
        }
    }

メタデータを利用することでアップロードしたファイルの情報を集約ルートのリポジトリで保存する必要がなくなります。 集約ルートのリポジトリドメインオブジェクト全体をストアするために使いたいので、今回のような集約ルート全体のオブジェクトが構築できない段階での一部のドメインオブジェクトだけのストアはリポジトリに不要な複雑さをもたらすために避けました。 また、アップロードされたファイルは、まず一時ディレクトリにS3で保存します。この一時ディレクトリのファイルは、S3のライフサイクルポリシーにより定期的に削除されるよう設定します(例:24時間後に削除)。

3. 集約ルート登録時のプロセス

集約ルートを登録する際のプロセスは以下のようになります:

  • 事前にファイルをアップロードし、各ファイルのエンティティID(fileId)を取得しておく
  • 集約ルート登録のCommandでは、ファイルの実体ではなくエンティティIDを送信する
interface CreateArticleCommand {
  title: string;
  content: string;
  attachmentIds: string[]; // ファイルのエンティティID
}
    public async execute({
        id,
        title,
        content,
        attachmentIds,
    }: CreateCommand): Promise<void> {
        const attachments = attachmentIds.map(
            // 一時ディレクトリから正式なディレクトリに昇格
            async (id) => await this.storageAdapter.promoteAttachment(fileId)
        );

        const model = Article.create({
            id,
            title,
            content,
            attachments,
        });

        await this.articleRepository.store(model);

        ...
    }

まとめ

もしファイルが集約ルートの一部になる場合はこのような方法をすると上記で挙げたような課題はクリアするかなと思いました。

今回は省略していますが、例で挙げたドメインオブジェクトが特定のユーザーにのみ関連づけられているものであれば S3 のオブジェクトキーにも user_id をつけたり、AttachmentId の値オブジェクトのプロパティに user_id を持たせて Article を生成する際に user_id の同一チェックをすることで誤って他のユーザーのファイルを関連づけさせるリスクを減らすことができると思います。

僕が現在所属する株式会社 HERP の HERP Careers (β版) の開発チーム ではこのように DDD としてどういう設計が良いのか議論しながら お仕事をしていますのでもし僕たちに興味を持っていただいた方は会話の機会をいただけると嬉しいです!

新規事業開発における効率的な E2E テスト戦略 - スピードと品質のバランスを取る

はじめに

新規事業のプロダクト開発において、開発速度と品質保証は常にトレードオフの関係にあります。 特に 0→1 の探索フェーズでは、限られたリソースを製品価値の検証に集中させたいという要求がある一方で、将来の成長を見据えた技術的な健全性も維持する必要があります。

本記事では、Playwright と Cucumber を活用して、開発速度を落とすことなく必要な品質を担保する取り組みに一定の成果を感じましたので1つの事例として紹介します。

新規事業の状況

新規事業におけるプロダクト開発は以下のような状況だと思います。

1. 製品価値の検証が最優先

開発リソースの大部分を以下に投資する必要があります。

  • 製品として市場に価値を提供できるかの検証
  • ユーザーフィードバックに基づく機能改善
  • 新しい仮説に基づく機能開発

2. 頻繁な仕様変更

探索フェーズならではの特徴として

  • 機能やデータモデルの頻繁な変更
  • ユーザーフィードバックに基づく機能の追加・改修・削除

3. 継続的な技術的改善

製品の成長に備えて以下のことも必要になります。

  • 新しい知見の導入に伴うリファクタリング
  • renovateを活用した定期的なライブラリアップデート

状況から見える課題

このような状況下では、短期的な成果へのプレッシャーやチーム体制、プロジェクトマネジメントなど様々な要因から継続的な技術改善が軽視され、それにより以下のリスクが発生しやすくなると考えています。

1. リファクタリングを保留することのリスク

発生頻度や影響度から意図的にリスクを受容しリファクタリングをしないということもありますが、もし考慮不足のままリファクタリングを保留すると以下のようなリスクがあると考えています。

コードベースが徐々に複雑化・肥大化し、新機能の追加や既存機能の修正に多大な時間とコストがかかるようになります。 また、コードの可読性が低下することで、バグの混入リスクが高まり、それを発見・修正することも困難になります。 開発効率が低下することで、チームメンバーのモチベーションにも悪影響を及ぼす可能性があります。 最終的には、システム全体の再構築が必要になるほど深刻な状況に陥るリスクがあり、事業継続性にも影響を与えかねません。

2. ライブラリアップデートを保留することのリスク

既知のセキュリティ脆弱性にさらされ続けるリスクが高まります。 また、パッケージのバージョン差が徐々に広がり、将来的に大規模なアップデートが必要になった際、互換性の問題や予期せぬエラーが発生する可能性が高くなります。 結果として、開発チームは多大な時間とリソースを費やしてアップデート作業を行う必要に迫られ、本来の開発業務に支障をきたす恐れがあります。

課題解決の方針

これらの課題に対して、「製品全体のシナリオが正常動作していることを E2E テストで効率的に確認できる状態を作る」という方針を定めました。

1. E2E テストによる製品全体の検証

E2E テストでは、実際のユーザーの利用シナリオに沿って製品全体の動作を検証します。 重要な業務フローを自動テストで実行することで、効率的に品質確認を行います。 これにより、手動での確認作業を最小限に抑えながら、エンドツーエンドでの動作保証を実現します。

2. スモークテストとしての位置づけ

E2E テストは、製品が「壊れていない」ことを確認するスモークテストとして位置づけています。 テストの範囲は致命的な問題の早期発見に焦点を当て、表示崩れなどの軽微な問題は許容することで、効率的なテスト運用を実現します。

3. テストコードの保守性重視

テストコードの保守性を重視し、「テストコードだけ壊れた」ということが少なくなるよう Page Object パターンや DOM 構造に依存しないなどの配慮をすることでリファクタリングへの耐性を確保します。

具体的なソリューション

E2E テストの実装

E2E テストの実装には、Cucumber と Playwright を組み合わせたアプローチを採用しています。 Cucumber を用いたシナリオテストでは、独立した一連のユースケースをシナリオとして定義しています。 Gherkin 記法を使用することで、非エンジニアでも理解しやすいテストシナリオを作成できます。 シナリオの実行には、Playwright を使用してテストスクリプトを実装しています。

Feature: 商品検索
  Scenario: 商品を検索して詳細を確認
    Given ログイン済みのユーザーとして
    When 商品一覧ページを表示する
    And "テスト商品"で検索する
    Then 検索結果が表示される
Given('ログイン済みのユーザーとして', async () => {
    const session = await generateSession(subject);
    page = await signin(browser, session);
});

When('商品一覧ページを表示する', async () => {
    const po = ProductListPage.goto(page);
    page = po.page
});

When('"テスト商品"で検索する', async () => {
    const po = ProductListPage.of(page);
    await po.search("テスト商品")
    page = po.page
});

Then('検索結果が1件以上表示される', async () => {
    const po = ProductListPage.of(page);
    expect(await po.getSearchResults()).toBeGreaterThan(0);
});

また、シナリオテストでカバーしきれない個別のユースケースについては、Playwright を直接使用したテストを作成しています。 これにより、より細かな品質担保が必要な部分もカバーできています。 テストコードは以下のような構成で管理しています。

e2e/
├── features/     # Cucumberシナリオ
├── page-object/  # 再利用可能なPage Object
└── test/         # 個別のユースケーステスト

E2E テストはスモークテストとして、リファクタリングや renovate によるライブラリ更新後に製品が壊れていないことを確認する目的で実行します。 テストはローカル開発環境でのコード変更時や、CI 環境でのプルリクエスト作成時に自動的に実行され、開発者は早い段階で問題を検知することができます。

Page Object パターンの活用

テストコードのリファクタリングへの耐性を高めるため、Page Object パターンを採用し、要素の特定方法(Locator)を隠蔽して、振る舞いのみを公開するインターフェースを提供しています。

export class ProductListPage {
  constructor(private page: Page) {}

  async search(keyword: string) {
    await this.page.getByRole('searchbox').fill(keyword);
    await this.page.getByRole('button', { name: '検索' }).click();
  }

  async getSearchResults() {
    return this.page.getByRole('listitem').count();
  }
}

この Page Object は、Cucumber のシナリオテストと Playwright の個別ユースケーステストの両方から利用され、テストコードの重複を防いでいます。 また、DOM 構造への依存度を下げるため、単純な Locator の代わりに、getByRolegetByLabel などのセマンティックな要素特定方法を優先して使用しています。 これにより、HTML の構造変更に強いテストコードを実現しています。

現状の課題

E2E テストを運用していく中で、二つの重要な課題を抱えています。

一つ目は、テストケースの増加に伴う実行時間の長期化です。 新機能の追加や既存機能の改善のたびに、それを E2E テストで検証すべきか慎重に判断する必要があります。 特に、Unit Test や Integration Test で十分カバーできるものまで E2E テストに含めてしまうと、テスト実行時間が不必要に長くなってしまいます。

二つ目は、本番環境特有の問題への対応です。 開発環境や検証環境では再現できない問題が本番環境で発生するリスクは常に存在します。 現在まで重大な障害は発生していないものの、実際のユーザー環境での予期せぬ挙動や、大規模アクセス時の問題などを、事前に検知できない可能性があります。

まとめ:スピードと品質のバランスを取る

1年間の運用を通じて、Playwright と Cucumber を組み合わせた E2E テスト戦略は、新規事業開発において効果的に機能していることが確認できました。 特に、大規模なリファクタリングやライブラリの継続的なアップデートを、品質を担保しながら実施できている点は大きな成果です。

Page Object パターンの採用により、テストコードの保守性も高く保てており、製品の変更に柔軟に対応できています。 「壊れていないことの確認」に焦点を絞ったスモークテストとしての位置づけも、新規事業開発におけるスピードと品質のバランスを取る上で適切でした。

しかし、この成功は E2E テストだけによるものではありません。 プログラムの結合度を下げ、凝集度を高めるよう設計することで、変更の影響範囲を局所化しています。 また、機能の複雑度や重要度に応じて適切な Unit Test を実装し、プログラムの品質を多層的に担保しています。 これらの取り組みが相互に補完し合うことで、品質の安定化が図れたのかと考えています。

E2E テストについてはテストケースの増加に伴う実行時間の管理や、本番環境特有の課題への対応など、改善の余地は残されています。 今後は、これらの課題に取り組みながら、より効率的なテスト戦略を追求していきたいと考えています。

新規事業開発に興味を持っていただけましたら、ぜひ HERP の 採用ページ をご覧ください。 プロダクト開発の裏側についても、もっと詳しくお話できればと思います。

循環的複雑度の計測

背景

テストコードもない複雑なレガシーコードに対して変更する場合、常にデグレードのリスクが伴います。エンジニアの知見や経験によってそれを回避することも可能ですが、チームとして取り組む上では仕組みでそのリスクを軽減する必要があります。

以前、チームでレガシーコードに対して修正したとき、PR でレビューしたにも関わらず、デグレードが発生しました。原因を分析していくと、対象のメソッドは変更前から循環的複雑度が50を超えており、変更後はさらに数値が上がっていましたが、そのことをチームとして認識できていない状況でした。その他にも因子はありましたが、実装中からバグの混入確率を減らすことが期待できる仕組みの1つとして循環的複雑度に着目しました。

実際にバグ分析からプロセスを改善したときの方針と内容を以下に記載します。

方針

内部品質が低下することにより、バグの混入確率が上昇するため、内部品質を定量的に評価できる指標の1つとして循環的複雑度を用いる。 変更したコード、新しく追加したコードに対してバグの混入確率を循環的複雑度で評価し、定量的な指標をもとに適切に対処する仕組みを構築する。

循環的複雑度が低いことは内部品質が高いことの必要条件である。しかし、必要十分条件ではないことを留意する

期待する効果

循環的複雑度を把握することにより、現在より以下の効果(=バグの混入確率低下)を期待する

  • レビュイー自身がレビュー前に循環的複雑度が高いコードであることを認識することにより、事前に循環的複雑度を下げるためのリファクタリング単体テストとして網羅率が十分であるか確認できる
  • レビュアーが循環的複雑度が高いコードであることを認識することにより、循環的複雑度を下げるための内部設計として改善の余地がないか、バグが混入していることを前提としたコードの確認ができる

改善するプロセス

今後、循環的複雑度を把握し、バグの混入確率を下げるための取り組みとして以下を実施する。

  • Pull Request 時に修正前後のメソッド、新規のメソッドに対して循環的複雑度を計測する
  • Merge する条件に循環的複雑度を追加する
    • 修正前が30未満の場合、修正後が30以上の場合は NG とする (修正後が30未満であれば前回より増加しても需要する)
    • 修正前が30以上の場合、修正後が前回を超える場合は理由を記載する
      • x.x.x 以降は NG とする
  • 新規が30以上の場合は NG とする

技術的負債の種類と対処

技術的負債の種類と対処

開発チームの会話の中で 技術的負債 というワードを口に出したり、聞いたりすることがよくあるかと思います。 その技術的負債がイコール悪というコンテキストで会話していることに違和感を持っていました。

ある日 More Effective Agile ~“ソフトウェアリーダー"になるための28の道標 を読んでいると、技術的負債にはさまざまな分類法があり、その中で著者が有益と考えている以下の分類法が紹介されており、持っていた違和感の正体がわかりました。

意図的な技術的負債(短期)

戦術的または戦略的な理由による技術的負債。たとえば、時間的制約のあるリリースを期限までにデプロイするなど。

意図的な技術的負債(長期)

戦略的な理由による技術的負債。たとえば、最初からマルチプラットフォーム対応の設計と構築を行うのではなく、最初は1つのプラットフォームだけをサポートするなど。

不慮の技術的負債(悪意)

いいかげんなソフトウェア開発の習慣のせいで意図せずに発生する技術的負債。この種の技術的負債は将来と現在の作業を失速させるため、回避すべきである。

不慮の技術的負債(善意)

ソフトウェア開発がエラーと隣り合わせの性質であるために意図せずに発生する技術的負債。たとえば、「私たちの設計アプローチは思っていたほどうまくいかなかった」、「プラットフォームの新しいバージョンのせいで設計の重要な部分が無効になってしまった」など。

レガシーな技術的負債

新しいチームに引き継がれた古いコードベースの技術的負債。

推奨されるアプローチ

技術的負債の種類 推奨される対処法
意図的な技術的負債(短期) ビジネス上やむを得ない場合は技術的負債を引き受け、すぐに返済する
意図的な技術的負債(長期) 必要であれば技術的負債を引き受け、返済を開始する条件を定義する
不慮の技術的負債(悪意) 作業習慣の質を高めることで、最初から技術的負債を作らないようにする
不慮の技術的負債(善意) この技術的負債は本質的に避けようがない。技術的負債の影響を監視し、「利子の支払い」が高くなりすぎたら返済する
レガシーな技術的負債 技術的負債を徐々に減らす計画を立てる

チームとしてこの種類と対処法を共通理解し、技術的負債という言葉を使っていこうと思いました。

ブログはじめました

ブログはじめました

大学を卒業してからエンジニアになって早いもので13年経ちました。エンジニア、リードエンジニア、プロジェクトマネージャー、エンジニアリングマネージャーといろいろな役割を経験しました。これまでは、その経験の中で実践し、学習したこと、感じたエモい話は会社のナレッジツールに貯めていました。

21年の年の瀬でこれまでを振り返っていると、あれ?個人として学び、感じたことは、個人のアウトプットとして書いた方が良くね?と今更ながら思い、今回からブログとして残していくことに決めました。

なぜはてなブログ

候補として、Qiita, Note, Zenn がありましたが、技術的な話に限らず、個人的なことも書き残す可能性があり、収益につなげたいやバズらせたいという思いもないため、はてなブログで始めてみることにしました。 Gist にすることも考えましたが、カテゴライズして後でふりかえりたいのもあったので候補から外しました。 そんなこんなで、はてなブログで私がやったことやわかったことをオープンにしていく活動をゆるく初めていこうと思います。