# 外部ログイン（SSO）

> 信頼できる ID プロバイダーが、署名付きの JWT アサーション、または OIDC や SAML のオプションで、Nodaro のインストール環境にユーザーをログインさせます。アカウントのリンク方法も制御できます。

Source: https://nodaro.ai/ja/docs/developers/sso

**外部ログイン**（SSO）を使うと、信頼できる ID プロバイダー（IdP）が、ユーザーを Nodaro のインストール環境にログインさせられます。主な連携方法では、IdP が有効期間の短い JWT アサーションに署名し、Nodaro がそれを検証して、ブラウザーが通常の Nodaro セッションを受け取ります。インストール環境の管理者が少なくとも 1 つのプロバイダーを設定するまで、SSO はオフです。

ユーザーが最終的に使うアカウントは、通常のアカウントです。SSO は特別な認証情報を追加しません。セッションを開始する手段にすぎません。

## 2 つの連携方式
| 種類 | 対象 | 仕組み |
| --- | --- | --- |
| `assertion` | OIDC や SAML に対応していない発行元。たとえば、有効期間の短い署名付きトークンしか発行できない埋め込みホスト | IdP が JWT に署名し、Nodaro がそれを検証してセッションと交換します。この方式は、エンドツーエンドで動作します。 |
| `oidc`, `saml` | 標準の OpenID Connect や SAML に対応した IdP | ログインページが、インストール環境自身の Supabase Auth で標準のログインを開始し、Supabase Auth が IdP の応答を検証します。この方式には制限があります。[OIDC と SAML のプロバイダー](#oidc-and-saml-providers)を参照してください。 |

## プロバイダーを設定する
サーバーの `EXTERNAL_SSO_PROVIDERS` に、プロバイダーの JSON 配列を設定します。値は直接書くか、`@/path/to/providers.json` の形で指定します（先頭の `@` は、そのパスのファイルを読み込むことを表します）。値の形式が正しくないと、サーバーは起動時に停止します。そのため、入力ミスでプロバイダーが知らないうちに抜け落ちたり、ログインが中途半端に設定されたりすることはありません。

```json
[
{
"id": "acme-chat",
"label": "Acme Chat",
"kind": "assertion",
"secret": "a-dedicated-32-character-hmac-key-for-nodaro-only",
"audience": "nodaro",
"claimMap": { "email": "email", "emailVerified": "email_verified", "subject": "sub" },
"initiateUrl": "https://chat.example.com/oauth/nodaro",
"maxLifetimeSeconds": 300
},
{
"id": "keycloak",
"label": "Acme (Keycloak)",
"kind": "oidc",
"supabaseProvider": "keycloak"
}
]
```

| フィールド | 対象 | 意味 |
| --- | --- | --- |
| `id` | すべて | プロバイダーのスラッグです。ルート `/v1/sso/:id` で使われ、リンクされた各アカウントに保存されます。1〜63 文字で、先頭は小文字の英字か数字、その後は小文字の英字、数字、`_`、`-` です。ドットは使えません。一意である必要があります。 |
| `label` | すべて | プロバイダーの表示名です。 |
| `kind` | すべて | `assertion`、`oidc`、`saml` のいずれかです。 |
| `secret` | `assertion`、必須 | アサーションの署名を検証する HS256 の鍵で、16 文字以上です。専用のシークレットを使い、IdP 自身のセッション署名用のシークレットは決して使わないでください。そうすれば、Nodaro が侵害されても、IdP のセッションを偽造されることはありません。 |
| `audience` | `assertion`、必須 | アサーションの `aud` クレームと一致する必要がある値です。 |
| `claimMap` | `assertion`、任意 | メールアドレス、確認済みフラグ、サブジェクトを、それぞれどのクレームが持つかを指定します。デフォルトは `email`、`email_verified`、`sub` です。 |
| `initiateUrl` | `assertion`、任意 | SSO ボタンがユーザーを送る先です。指定しない場合、ボタンをクリックすると `400 no_assertion` が返されます。 |
| `initiateUrlByHost` | `assertion`、任意 | ポートを含まない、小文字だけのホスト名から、そのホストで `initiateUrl` の代わりに使うアドレスへのマップです。複数のホスト名で応答するデプロイメント向けです。 |
| `maxLifetimeSeconds` | `assertion`、任意 | Nodaro が受け付ける最長の有効期間（`exp` から `iat` を引いた値）です。デフォルトは 300 秒で、最大は 3,600 秒です。 |
| `domain` | `oidc`、`saml` | 起動時に検証されますが、使われません。[OIDC と SAML のプロバイダー](#oidc-and-saml-providers)を参照してください。 |
| `supabaseProvider` | `oidc` | 起動時に検証されますが、使われません。 |

- `initiateUrl` は、IdP に何も転送しません。ログイン後にユーザーを特定のページに移動させたい場合は、IdP がリダイレクトで戻すときに `&next=<path>` を追加します。
- `initiateUrlByHost` のキーは、起動時に検証されます。大文字やポートを含むキーがあると、サーバーは停止します。このマップが公開されることはなく、アカウントのリンクは、ユーザーがどのホストから来たかに左右されません。

## アサーションの要件
IdP は JWT を発行し、ブラウザーを `GET /v1/sso/:provider?assertion=<jwt>` に送ります。Nodaro がアサーションを受け付けるのは、次のすべての条件を満たす場合だけです。

- **アルゴリズム**：JWT が、`HS256` とプロバイダーの `secret` で署名されていること。
- **オーディエンス**：`aud` が、プロバイダーの `audience` と等しいこと。
- **有効期限**：`exp` があり、過去の時刻ではないこと。時計のずれは、5 秒まで許容されます。
- **有効期間**：`exp` から `iat` を引いた値が、`maxLifetimeSeconds` 以下であること。この値は、Nodaro の時計で計測されます。5 秒を超えて未来を指す `iat` は補正され、`iat` がない場合は現在時刻として扱われます。
- **1 回限りの使用**：`jti` があり、一意であること。Nodaro は、アサーション自体の有効期間が過ぎるまで各 `jti` を記憶し、2 回目は拒否します。
- **メールアドレス**：メールアドレスのクレームがあること。
- **確認済みのメールアドレス**：アカウントを作成またはリンクするには、確認済みフラグが `true` であること。フラグがない場合は、未確認として扱われます。

アサーションが要件を満たさない場合は、`401` とともに、`invalid_signature`、`invalid_claims`、`expired`、`too_long_lived`、`missing_jti`、`missing_email`、`assertion_replayed` のいずれかが返されます。このエンドポイントが受け付けるのは、1 つの IP アドレスから 60 秒あたり 20 リクエストまでです。それを超えると、`Retry-After` ヘッダーとともに `429 rate_limit_exceeded` が返されます。

次の例は IdP 側のコードで、Node.js 用の `jose` ライブラリを使って、有効なアサーションを発行します。

```ts

const secret = new TextEncoder().encode(process.env.NODARO_SSO_SECRET)

const assertion = await new SignJWT({ email: user.email, email_verified: true })
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setAudience("nodaro")
.setIssuedAt()
.setExpirationTime("2m")
.setJti(randomUUID())
.sign(secret)

const url = new URL("https://nodaro.example.com/v1/sso/acme-chat")
url.searchParams.set("assertion", assertion)
url.searchParams.set("next", "/projects")
res.redirect(url.toString())
```

## ログインの流れ
1. ユーザーが、Nodaro のログインページで SSO ボタンをクリックします。ボタンは、アサーションなしで `GET /v1/sso/:provider` を呼び出します。
2. Nodaro が、プロバイダーの `initiateUrl` にリダイレクト（`302`）します。
3. IdP がユーザーを認証し、ブラウザーを `GET /v1/sso/:provider?assertion=<jwt>` に戻します。任意で `&next=<path>` も付けます。
4. Nodaro がアサーションを検証し、再利用（リプレイ）を拒否して、[アカウントのリンクのルール](#how-accounts-are-linked)を適用します。
5. Nodaro が `/sso?sso_token=<one-time token>` にリダイレクトします。このページが、ワンタイムトークンをセッションと交換します。
6. ユーザーは、`/projects` か、`next` のパスに移動します。

埋め込みホストなどの IdP は、手順 3 から始めることもできます。その場合、IdP は新しいアサーションを付けて、ブラウザーを交換エンドポイントにリダイレクトします。ログインページ自身の `?redirect=` パラメーターは、SSO のリダイレクトでは引き継がれません。引き継がれるのは、IdP が追加した `next` だけです。

## ログインページに SSO ボタンを表示する
ログインページに SSO ボタンが表示されるのは、次の両方の条件を満たす場合です。

- デプロイメントのサーフェスプロファイルが、`auth.methods` に `sso` を含み、`auth.ssoLabel` を設定していること。`ssoLabel` のないプロファイルでは、メソッドから `sso` が外されます。ボタンのテキストは、`ssoLabel` です。
- 少なくとも 1 つのプロバイダーが設定されていること。ページは、`GET /v1/sso/providers` で確認します。

サーフェスプロファイルは、Business エディションと Cloud エディションの機能です。[エディションとサーフェスプロファイル](https://nodaro.ai/docs/self-hosting/editions-and-profiles)を参照してください。

`auth.methods` に `sso` **だけ**を含むプロファイルでは、サーバー側の制限もオンになります。ログインするすべてのアカウントは、SSO 経由で作成またはリンクされている必要があり、それ以外のセッションは、最初の API 呼び出しで `403 sso_required` によって拒否されます。唯一の例外は、後述するデプロイメントの請求アカウントです。メソッドに `email` を追加すると、インストール環境全体でこの制限がオフになります。

## アカウントのリンク方法
アサーションが有効な場合、Nodaro はルールに従って、アカウントを探すか作成します。このルールは、**メールアドレスが同じというだけの既存のアカウントを、アサーションが決して乗っ取れないように**作られています。

| 状況 | 結果 |
| --- | --- |
| そのメールアドレスのアカウントがなく、メールアドレスが確認済み | 新しいアカウントが作成され、プロバイダーにリンクされます。 |
| そのメールアドレスのアカウントがなく、メールアドレスが未確認 | `403 email_unverified` で拒否されます。そのため、未確認のクレームで実在のアドレスを占有することはできません。 |
| アカウントがすでにこのプロバイダーにリンクされている | ユーザーはログインします。 |
| アカウントが別のプロバイダーにリンクされている | `EXTERNAL_SSO_LINK_EXISTING` がオンでも、`403 account_linked_other_provider` で拒否されます。 |
| SSO なしのローカルアカウントがあり、`EXTERNAL_SSO_LINK_EXISTING` がオンで、メールアドレスが確認済み | アカウントがプロバイダーにリンクされ、ユーザーはログインします。 |
| SSO なしのローカルアカウントがあり、フラグがオフか、メールアドレスが未確認 | `403 account_exists` で拒否されます。 |

`EXTERNAL_SSO_LINK_EXISTING` はデフォルトでオフで、これが乗っ取りに対して安全な設定です。オンになるのは、大文字小文字を問わず、値が `true` または `1` の場合だけです。既存のアカウントに結び付けてもよいと思えるほど、IdP の確認済みメールアドレスのクレームを信頼できる場合にだけ、オンにしてください。

`account_exists` と `email_unverified` による拒否は、どのアカウントでも同じ文言を使います。そのため、ログインフォームを使って、どのアドレスが特別なロールを持っているかを調べることはできません。`account_exists` は、アドレスが複数のアカウントに一致した場合、検索が失敗した場合、2 つのログインが同じアカウントを同時に作成しようとした場合にも返されます。

### 請求アカウントがあるデプロイメントの場合
デプロイメントでは、インストール環境のすべてのユーザーの料金を支払う**請求アカウント**を 1 つ指定できます。[外部ウォレット](https://nodaro.ai/docs/developers/external-wallet)を参照してください。このアカウントはデプロイメントのクレジットを保持しているため、適用されるルールがより厳しくなっています。

- **最初の確認済みのログインでリンクされます**。`EXTERNAL_SSO_LINK_EXISTING` の設定は関係ありません。このアカウントに対する未確認のアサーションは `email_unverified` で、別のプロバイダーからのアサーションは `account_linked_other_provider` で拒否されます。
- **2 回目以降のログインも、毎回確認されます**。メールアドレスが確認済みで、サブジェクトが最初にアカウントをリンクしたものと同じである必要があります。そうでない場合、ログインは `403 account_linked_other_subject` で拒否されます。
- **緊急用の入口として、パスワードを保持します**。IdP がこのアカウントをアサートできなくなった場合に備えるものです。SSO のみのデプロイメントでは、そのフォームは `/login?billing=1` にあり、請求アカウントしか入れません。
- **インストール環境の管理者でも、利用停止や削除はできません**（`403 payer_account_protected`）。そのため、デプロイメント自身の IdP が管理者ロールを付与しても、そのロールでインストール環境をクレジットから締め出すことはできません。

IdP における請求アカウントのサブジェクトが変わった場合、そのアカウントの SSO ログインは、`account_linked_other_subject` で失敗し続けます。その場合でも、このアカウントはパスワードでログインできます。

プラットフォームのオペレーターのアドレス（`PLATFORM_OPERATOR_EMAILS`、それが空の場合は `PLATFORM_OWNER_EMAIL`）は、請求アカウントがあるデプロイメントでは、新たにリンクされることはありません。これらのアドレスは `account_exists` で拒否され、オペレーターのアカウントは SSO の対象外に保たれます。すでにリンクされているオペレーターのアカウントは、引き続きログインできます。

## OIDC と SAML のプロバイダー
`oidc` と `saml` の種類は、ログインをインストール環境自身の Supabase Auth に引き渡し、Supabase Auth が IdP の応答を検証します。どちらにも制限があります。

- **`oidc`**：ログインページは、プロバイダーの `id` を OAuth プロバイダー名として渡します。そのため、`id` 自体が、インストール環境の Supabase Auth に設定された OAuth プロバイダーの名前（`keycloak` や `azure` など）である必要があります。
- **`saml`**：ログインページは、プロバイダーの `id` を SAML ドメインとして渡します。`id` にはドットを含められないため、`acme.com` のようなドメインを表せません。そのため、`saml` はエンドツーエンドでは動作しません。
- `oidc` または `saml` のプロバイダーにアサーションを送ると、`400 not_assertion_provider` で拒否されます。

## エンドポイント
どちらのエンドポイントも公開されていて、トークンは不要です。`/v1/sso/` の下にあるルートは、この 2 つだけです。

| メソッド | パス | 内容 |
| --- | --- | --- |
| `GET` | `/v1/sso/providers` | ログインページ用に、プロバイダーを一覧表示します。返すのは `id`、`label`、`kind` だけで、シークレットは決して返しません。SSO がオフの場合は、空のリストを返します。 |
| `GET` | `/v1/sso/:provider` | 交換エンドポイントです。JSON API ではなく、ブラウザーのリダイレクト用のエンドポイントです。 |

| ステータス | コード | 発生する状況 |
| --- | --- | --- |
| `400` | `no_assertion` | ボタンがクリックされましたが、プロバイダーに `initiateUrl` がありません。 |
| `400` | `not_assertion_provider` | `oidc` または `saml` のプロバイダーに、アサーションが送られました。 |
| `401` | `invalid_signature`, `invalid_claims`, `expired`, `too_long_lived`, `missing_jti`, `missing_email`, `assertion_replayed` | アサーションが、要件のいずれかを満たしていません。 |
| `403` | `email_unverified`, `account_exists`, `account_linked_other_provider`, `account_linked_other_subject` | アカウントのリンクのルールによって、ログインが拒否されました。 |
| `403` | `sso_required` | SSO のみのデプロイメントが、SSO 経由ではないセッションを拒否しました。 |
| `404` | `unknown_provider` | その `id` のプロバイダーがないか、SSO がオフです。 |
| `429` | `rate_limit_exceeded` | 1 つの IP アドレスからのリクエストが多すぎます。 |

## セキュリティに関する注意
- **専用のシークレットを使う**：`secret` は、Nodaro 専用の検証用の鍵です。IdP 自身のセッション用のシークレットとは分けてください。
- **アサーションは 1 回だけ有効**：`jti` のチェックにより、一度送信されたアサーションは 2 回目には拒否されます。また、有効期間の上限によって、有効な時間帯は短く保たれます。
- **リダイレクトはインストール環境内にとどまる**：`next` が有効なのは、同じインストール環境内の相対パスで、`/` で始まり、`//` で始まらない場合だけです。それ以外の場合、ユーザーは `/projects` に移動します。
- **シークレットがサーバーの外に出ることはない**：`GET /v1/sso/providers` が返すのは、`id`、`label`、`kind` だけです。アサーションとワンタイムトークンは、リクエストログから削除されます。
- **交換にはレート制限がある**：IP アドレスごとに制限されます。

## Frequently asked questions

### Nodaro の SSO は、デフォルトでオンになっていますか？

いいえ。EXTERNAL_SSO_PROVIDERS に少なくとも 1 つのプロバイダーを指定するまで、ログインページに SSO ボタンは表示されず、GET /v1/sso/providers は空のリストを返し、プロバイダーのルートはすべて 404 を返します。

### SSO のアサーションは、どのように署名する必要がありますか？

HS256 と、そのプロバイダー用に設定し、ほかの用途には使わないシークレットで署名します。アサーションには、aud、exp、一意の jti、メールアドレスも含める必要があります。Nodaro は、各アサーションを 1 回だけ受け付けます。

### SSO のアサーションで、同じメールアドレスを持つ既存のアカウントを乗っ取れますか？

デフォルトでは、できません。既存のローカルアカウントがリンクされるのは、EXTERNAL_SSO_LINK_EXISTING が true で、かつアサーションがメールアドレスを確認済みと示している場合だけです。すでに別のプロバイダーにリンクされているアカウントが、改めてリンクされることはありません。

### SSO のユーザーは、特別な種類の Nodaro アカウントですか？

いいえ。交換の後、ユーザーは通常のセッションを持ちます。SSO は、そのセッションを開始する手段にすぎません。

### Nodaro は、OIDC と SAML の ID プロバイダーに対応していますか？

一部対応しています。oidc と saml の種類は、ログインをインストール環境自身の認証サービスに引き渡しますが、このページで説明している制限があります。エンドツーエンドで動作するのは、アサーションの交換です。
