@maronn-openid-connect/google-login 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/dist/account-resolver.d.ts +6 -0
- package/dist/account-resolver.d.ts.map +1 -0
- package/dist/account-resolver.js +9 -0
- package/dist/account-resolver.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +44 -0
- package/dist/errors.js.map +1 -0
- package/dist/id-token.d.ts +24 -0
- package/dist/id-token.d.ts.map +1 -0
- package/dist/id-token.js +79 -0
- package/dist/id-token.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/login-redirect.d.ts +20 -0
- package/dist/login-redirect.d.ts.map +1 -0
- package/dist/login-redirect.js +29 -0
- package/dist/login-redirect.js.map +1 -0
- package/dist/nonce.d.ts +18 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +25 -0
- package/dist/nonce.js.map +1 -0
- package/dist/redirect-credential.d.ts +15 -0
- package/dist/redirect-credential.d.ts.map +1 -0
- package/dist/redirect-credential.js +60 -0
- package/dist/redirect-credential.js.map +1 -0
- package/dist/sign-in.d.ts +29 -0
- package/dist/sign-in.d.ts.map +1 -0
- package/dist/sign-in.js +70 -0
- package/dist/sign-in.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 maronnjapan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# @maronn-openid-connect/google-login
|
|
2
|
+
|
|
3
|
+
Sign in with Google(Google Identity Services、以下 GIS)の redirect mode を、`@maronn-openid-connect/core` で組んだ OpenID Provider のログイン手段として使うための拡張パッケージ。
|
|
4
|
+
|
|
5
|
+
## 位置づけ
|
|
6
|
+
|
|
7
|
+
- **core と組み合わせて使う。単体では使わない。** Google のログイン結果を core の認証トランザクション(`getAuthTransaction` → 認証セッションの確立 → 同意 → `completeAuthTransaction`)へ接続するための部品で、OP 本体の機能は持たない
|
|
8
|
+
- **CLI 生成コードから呼び出す想定で API を切っている。** core と同じく HTTP の配線(ルーティング・本文解析・Cookie の発行)は呼び出し側の責務とし、このパッケージは検証と設定のステップ関数だけを提供する。`@maronn-openid-connect/cli` の `--enable google-login` で生成コードに組み込まれる([CLI での利用](#cli-での利用))。CLI を使わない場合の配線例は [生成コードへの配線](#生成コードへの配線)
|
|
9
|
+
- **UI は生成しない。** ログイン画面に何を置くかは [フロント側の設定](#フロント側の設定ログイン画面に置くもの) に書いてあるとおりで、描画はプレーン HTML / React / Vue など利用側の方法で行う。パッケージが提供するのは、忘れると動かない・安全でなくなる設定(`data-ux_mode="redirect"` / `data-login_uri` / `data-nonce`)を必ず含んだ `g_id_onload` の属性オブジェクトを組み立てる `buildGoogleSignInAttributes` だけで、Node 非依存のサブパス `@maronn-openid-connect/google-login/sign-in` から import する(ブラウザ向けバンドルや React の client component に google-auth-library を引き込ませないため)
|
|
10
|
+
- **ID トークンの検証は Google 公式の [`google-auth-library`](https://github.com/googleapis/google-auth-library-nodejs) に委ねる。** 公開鍵の取得とローテーション追随、署名・`iss`・`aud`・`exp` の検証はライブラリが行い、Google 側の仕様変更にはライブラリの更新で追随する。このパッケージが自前で持つのは、redirect mode の POST の読み取り、Double Submit Cookie の検証、core の認証トランザクションへの束縛、ログイン画面のボタン生成だけ
|
|
11
|
+
- **Node.js 22 以上限定。** `google-auth-library` が Node.js の API を前提にするため、core / experimental と違い Cloudflare Workers などのエッジランタイムでは動かない。production 依存に外部ライブラリを持つのはモノレポ内でこのパッケージだけ
|
|
12
|
+
- **core は peerDependency**(`>=0.3.0 <1.0.0`)。experimental と同じ理由で `dependencies` には置かない(アプリ内の core のインスタンスを 1 つに保つため。[RELEASE.md](../../RELEASE.md)「バージョニング方針」)
|
|
13
|
+
|
|
14
|
+
## 参照ドキュメント
|
|
15
|
+
|
|
16
|
+
実装は次の 2 つの Google のドキュメントに従う。
|
|
17
|
+
|
|
18
|
+
- [Google ログインからの移行 — redirect mode](https://developers.google.com/identity/gsi/web/guides/migration?hl=ja#redirect-mode_1): `ux_mode: 'redirect'` では、ユーザーがアカウントを選ぶとブラウザが `login_uri` へ ID トークンを POST する
|
|
19
|
+
- [サーバーサイドで Google ID トークンを検証する](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token?hl=ja): 検証条件(署名・`aud`・`iss`・`exp`、任意で `hd`)、Node.js では `google-auth-library` の `verifyIdToken` を使うこと、`g_csrf_token` による Double Submit Cookie の CSRF 対策
|
|
20
|
+
|
|
21
|
+
## 全体の流れ
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
RP ──(認可リクエスト)──> OP /authorize core: validateAuthorizationRequest → createAuthTransaction
|
|
25
|
+
│
|
|
26
|
+
└─> OP /login?transaction_id=…(GET)
|
|
27
|
+
issueGoogleLoginNonce … nonce → transaction_id をストアに保存(サーバー側)
|
|
28
|
+
buildGoogleSignInAttributes … g_id_onload の属性(data-ux_mode="redirect" / data-login_uri / data-nonce)
|
|
29
|
+
画面側がその属性で g_id_onload と g_id_signin を描画(HTML / React / Vue)
|
|
30
|
+
│
|
|
31
|
+
ユーザーが Google でアカウントを選択(GIS クライアントが g_csrf_token Cookie を設定)
|
|
32
|
+
│
|
|
33
|
+
Google ──(POST credential, g_csrf_token, select_by)──> OP login_uri(例: /login/google)
|
|
34
|
+
handleGoogleLoginRedirect
|
|
35
|
+
1. parseGoogleCsrfTokenCookie / parseGoogleRedirectCredential / validateGoogleCsrfToken
|
|
36
|
+
2. verifyGoogleIdToken … google-auth-library(署名 → aud → iss → exp)+ 任意の hd / email_verified
|
|
37
|
+
3. consumeGoogleLoginNonce … ID トークンの nonce から transaction_id を復元(単回使用)
|
|
38
|
+
core: getAuthTransaction(transactionId)
|
|
39
|
+
resolveGoogleLoginSubject … Google アカウント → OP の subject
|
|
40
|
+
以降はユーザー名 + パスワードのログインと同じ(セッション確立 → /consent)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Google の POST には `credential` と `g_csrf_token` しか入らず、`login_uri` は Google Cloud コンソールに登録した URI と完全一致させる必要がある(クエリでトランザクション ID を運べない)。そこで GIS の `data-nonce` に載せた nonce が ID トークンの `nonce` クレームとして返ることを使い、検証を通ったトークンの `nonce` で認証トランザクションを引く。nonce は CSPRNG 由来(32 バイト)で、引いた時点で削除する。
|
|
44
|
+
|
|
45
|
+
## インストール
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pnpm add @maronn-openid-connect/core @maronn-openid-connect/google-login
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`google-auth-library` は本パッケージの `dependencies` に入っているので、別途追加する必要はない。
|
|
52
|
+
|
|
53
|
+
エントリポイントは 2 つある。
|
|
54
|
+
|
|
55
|
+
| import 元 | 役割 | 動く環境 |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `@maronn-openid-connect/google-login` | サーバー側: `login_uri` に届いた POST の処理、ID トークン検証、nonce の発行と消費 | Node.js 22 以上 |
|
|
58
|
+
| `@maronn-openid-connect/google-login/sign-in` | フロント側: `g_id_onload` の属性の組み立てと HTML 文字列化、GIS の定数 | どこでも(依存なし。ブラウザ / React / Vue / エッジ可) |
|
|
59
|
+
|
|
60
|
+
## Google Cloud コンソール側の設定
|
|
61
|
+
|
|
62
|
+
1. OAuth 2.0 クライアント ID(種類: ウェブ アプリケーション)を作成する
|
|
63
|
+
2. **承認済みの JavaScript 生成元**に、ログイン画面を配信するオリジン(例: `https://op.example.com`)を登録する
|
|
64
|
+
3. **承認済みのリダイレクト URI**に、ID トークンの POST 先(`loginUri` に渡す値。例: `https://op.example.com/login/google`)を登録する。GIS はこの値と `data-login_uri` の完全一致を要求する
|
|
65
|
+
|
|
66
|
+
`login_uri` はログイン画面と同一サイトに置く。GIS クライアントが `g_csrf_token` Cookie をログイン画面のドメインに設定し、同じ値を POST 本文にも入れるため、別サイトでは Cookie が届かず Double Submit Cookie の検証に失敗する。
|
|
67
|
+
|
|
68
|
+
## フロント側の設定(ログイン画面に置くもの)
|
|
69
|
+
|
|
70
|
+
Google のドキュメント(redirect mode)どおり、ログイン画面には次の 3 つを置く。これだけで、ユーザーがアカウントを選ぶとブラウザが `login_uri` へ ID トークンを POST する。
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<!-- 1. GIS クライアント -->
|
|
74
|
+
<script src="https://accounts.google.com/gsi/client" async></script>
|
|
75
|
+
|
|
76
|
+
<!-- 2. 設定。id は g_id_onload 固定 -->
|
|
77
|
+
<div id="g_id_onload"
|
|
78
|
+
data-client_id="<OAuth クライアント ID>"
|
|
79
|
+
data-ux_mode="redirect"
|
|
80
|
+
data-login_uri="https://op.example.com/login/google"
|
|
81
|
+
data-nonce="<サーバーで issueGoogleLoginNonce が発行した nonce>"
|
|
82
|
+
data-login_hint="<任意: OIDC の login_hint>"
|
|
83
|
+
data-hd="<任意: Google Workspace のドメイン>"></div>
|
|
84
|
+
|
|
85
|
+
<!-- 3. ボタン。class は g_id_signin 固定。見た目は GIS のボタン属性で自由に -->
|
|
86
|
+
<div class="g_id_signin" data-type="standard" data-theme="outline" data-size="large"></div>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| 属性 | 値 | 忘れると |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `data-ux_mode` | `redirect` 固定 | popup mode になり、ID トークンが `login_uri` に届かない |
|
|
92
|
+
| `data-login_uri` | Google Cloud コンソールに登録したリダイレクト URI と完全一致 | Google が POST を拒否する |
|
|
93
|
+
| `data-nonce` | リクエストごとにサーバーで発行した値 | ID トークンに `nonce` が入らず、`login_uri` 側でどの認証トランザクションか分からない(`consumeGoogleLoginNonce` が 400 にする) |
|
|
94
|
+
|
|
95
|
+
これらを手で書くと `nonce` の埋め忘れが起きやすいので、`@maronn-openid-connect/google-login/sign-in` の `buildGoogleSignInAttributes` で `g_id_onload` の属性オブジェクトを組み立てる。3 つの必須属性は必ず含まれ、キーは GIS の属性名そのものなので、描画方法を問わずそのまま渡せる。
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { buildGoogleSignInAttributes, GOOGLE_GSI_CLIENT_SCRIPT_URL } from '@maronn-openid-connect/google-login/sign-in';
|
|
99
|
+
|
|
100
|
+
// サーバー側で nonce を発行してから(issueGoogleLoginNonce)、画面へ渡す
|
|
101
|
+
const googleSignIn = buildGoogleSignInAttributes({
|
|
102
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
103
|
+
loginUri: 'https://op.example.com/login/google',
|
|
104
|
+
nonce,
|
|
105
|
+
loginHint: transaction.loginHint, // 任意
|
|
106
|
+
});
|
|
107
|
+
// => { id: 'g_id_onload', 'data-client_id': '…', 'data-ux_mode': 'redirect', 'data-login_uri': '…', 'data-nonce': '…', 'data-login_hint': '…' }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
プレーン HTML(文字列テンプレート):
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { googleSignInAttributesToHtml } from '@maronn-openid-connect/google-login/sign-in';
|
|
114
|
+
|
|
115
|
+
const html = `
|
|
116
|
+
<script src="${GOOGLE_GSI_CLIENT_SCRIPT_URL}" async></script>
|
|
117
|
+
<div ${googleSignInAttributesToHtml(googleSignIn)}></div>
|
|
118
|
+
<div class="g_id_signin" data-type="standard"></div>
|
|
119
|
+
`;
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
React(Next.js の App Router なら `<Script>` は `next/script`):
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
<script src={GOOGLE_GSI_CLIENT_SCRIPT_URL} async />
|
|
126
|
+
<div {...googleSignIn} />
|
|
127
|
+
<div className="g_id_signin" data-type="standard" />
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Vue:
|
|
131
|
+
|
|
132
|
+
```vue
|
|
133
|
+
<div v-bind="googleSignIn" />
|
|
134
|
+
<div class="g_id_signin" data-type="standard"></div>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`data-auto_select` / `data-context` などの他の GIS 属性は要素側で足す(`{ ...googleSignIn, 'data-context': 'signin' }`)。ログイン画面に Content-Security-Policy を設定している場合は `GOOGLE_SIGN_IN_CSP_SOURCES` の値を各ディレクティブに追加する。
|
|
138
|
+
|
|
139
|
+
## 提供機能(API 概要)
|
|
140
|
+
|
|
141
|
+
### フロント側(`@maronn-openid-connect/google-login/sign-in`)
|
|
142
|
+
|
|
143
|
+
| API | 役割 |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `buildGoogleSignInAttributes` | `g_id_onload` 要素の属性オブジェクトを組み立てる。`data-ux_mode="redirect"` / `data-login_uri` / `data-nonce` を必ず含め、`data-login_hint`(OIDC の `login_hint`)/ `data-hd` は指定時のみ |
|
|
146
|
+
| `googleSignInAttributesToHtml` | 属性オブジェクトを HTML 文字列テンプレート用の `name="value"` 列にする(値はエスケープ、`undefined` は省く) |
|
|
147
|
+
| `assertGoogleLoginUri` | `loginUri` が GIS の条件(絶対 URL・HTTPS・fragment 無し。localhost のみ HTTP 可)を満たすことを検証する |
|
|
148
|
+
| `GOOGLE_GSI_CLIENT_SCRIPT_URL` / `GOOGLE_SIGN_IN_ONLOAD_ID` / `GOOGLE_SIGN_IN_BUTTON_CLASS` | GIS のスクリプト URL と、`g_id_onload` / `g_id_signin` の要素名 |
|
|
149
|
+
| `GOOGLE_SIGN_IN_CSP_SOURCES` | ログイン画面に Content-Security-Policy を設定している場合に各ディレクティブへ足す GIS のソース |
|
|
150
|
+
|
|
151
|
+
### ログイン画面のサーバー側
|
|
152
|
+
|
|
153
|
+
| API | 役割 |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `issueGoogleLoginNonce` | 認証トランザクション ID に対応する nonce を発行し、`GoogleLoginNonceStore` に保存する(TTL はトランザクションの `expiresAt` まで)。画面を描画するたびに発行し、`buildGoogleSignInAttributes` の `nonce` に渡す |
|
|
156
|
+
|
|
157
|
+
### login_uri(redirect mode の POST 先)
|
|
158
|
+
|
|
159
|
+
| API | 役割 |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `handleGoogleLoginRedirect` | 下記ステップ関数を Google のドキュメントの順序で呼び出す合成関数。`transactionId`(core の `getAuthTransaction` に渡す)と検証済みの Google アカウントを返す |
|
|
162
|
+
| `parseGoogleCsrfTokenCookie` | `Cookie` ヘッダーから `g_csrf_token` を取り出す |
|
|
163
|
+
| `parseGoogleRedirectCredential` | POST 本文(`Record` / `URLSearchParams` / `FormData`)から `credential` / `g_csrf_token` / `select_by` を取り出す |
|
|
164
|
+
| `validateGoogleCsrfToken` | Double Submit Cookie 検証(Cookie 無し → 本文無し → 不一致の順で拒否。比較は constant-time) |
|
|
165
|
+
| `consumeGoogleLoginNonce` | 検証済み ID トークンの `nonce` からログイン試行の記録を引き、同時に削除する(単回使用) |
|
|
166
|
+
| `resolveGoogleLoginSubject` | `GoogleAccountResolver` で Google アカウントを OP の subject に対応付ける。未連携なら `account_not_linked` |
|
|
167
|
+
|
|
168
|
+
### ID トークンの検証
|
|
169
|
+
|
|
170
|
+
| API | 役割 |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `verifyGoogleIdToken` | verifier で検証(下記)したあと、任意の `hostedDomain` / `requireVerifiedEmail` / `expectedNonce` を確認して `GoogleIdTokenPayload`(`google-auth-library` の `TokenPayload`)を返す |
|
|
173
|
+
| `createGoogleIdTokenVerifier` | `google-auth-library` の `OAuth2Client.verifyIdToken` で検証する verifier を作る。ライブラリが Google の公開鍵を取得して `Cache-Control` の間キャッシュし、署名・`aud`(複数可)・`iss`・`exp` / `iat` を検証する。`OAuth2Client` のオプション(`transporter` / `endpoints` など)や既存のインスタンスを渡せる |
|
|
174
|
+
| `getDefaultGoogleIdTokenVerifier` | プロセスで共有する既定の verifier(初回に生成)。`verifier` を省略したときに使われる。公開鍵のキャッシュは `OAuth2Client` が持つので、検証のたびに作り直さない |
|
|
175
|
+
| `validateGoogleHostedDomain` | 任意。`hd` が許可した Google Workspace のドメイン(のいずれか)と一致する |
|
|
176
|
+
| `validateGoogleEmailVerified` | 任意。`email_verified` が `true`(メールでユーザーを対応付ける構成向け) |
|
|
177
|
+
| `validateGoogleIdTokenNonce` | 任意。`nonce` が期待値と一致する(nonce をストアではなく Cookie やセッションで持つ構成向け) |
|
|
178
|
+
|
|
179
|
+
`GoogleIdTokenVerifier` はインターフェースなので、テストや特殊な環境では差し替えられる。
|
|
180
|
+
|
|
181
|
+
### エラー
|
|
182
|
+
|
|
183
|
+
`GoogleLoginError`(`code: GoogleLoginErrorCode`、`httpStatusCode`)。core の `AuthTransactionError` と同じ形なので、生成コードは同じ catch 節で扱える。
|
|
184
|
+
|
|
185
|
+
| `httpStatusCode` | 対象 |
|
|
186
|
+
|---|---|
|
|
187
|
+
| 400 | `credential` 無し、`g_csrf_token` の Cookie 無し / 本文無し / 不一致、`nonce` 無し・未発行・使用済み・期限切れ |
|
|
188
|
+
| 401 | `invalid_id_token`: `google-auth-library` が ID トークンを受け入れなかった(形式・署名・`iss`・`aud`・`exp` / `iat`)。ライブラリのエラーは `cause` に入り、`message` はそのまま |
|
|
189
|
+
| 403 | トークンは正しいが受け入れない(`hd` 不一致、`email_verified` でない、OP のユーザーに未連携) |
|
|
190
|
+
| 503 | `signing_key_unavailable`: Google の公開鍵を取得できない(ライブラリのエラーは `cause`) |
|
|
191
|
+
|
|
192
|
+
## CLI での利用
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
maronn-oidc generate <hono|express|fastify|nextjs> --enable google-login
|
|
196
|
+
pnpm add @maronn-openid-connect/core @maronn-openid-connect/google-login
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`google-login` は CLI の**拡張機能**(Optional / Experimental とは別カテゴリ。既定では無効)で、有効にすると生成コードに次が加わる。それ以外の生成物は無効時と同じ。
|
|
200
|
+
|
|
201
|
+
| 生成物 | 内容 |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `config.ts` | `ProviderConfig.googleLogin?: GoogleLoginConfig`(`clientId` / 任意の `hostedDomain` / `requireVerifiedEmail`)。未設定ならボタンは出ず、`/login/google` は 404 |
|
|
204
|
+
| `views.ts` | `LoginPageParams.googleSignIn`(`g_id_onload` の属性)。既定のログイン画面はパスワードフォームの下に GIS の 3 要素(スクリプト / `g_id_onload` / `g_id_signin`)を書き出す。UI は生成コード側にあるので自由に変えられる |
|
|
205
|
+
| `routes/login.ts` | GET `/login` でトランザクションに束縛した nonce を発行してボタンを描画。`POST /login/google`(`login_uri`)で `handleGoogleLoginRedirect` → `resolveGoogleLoginSubject` → パスワードログインと同じセッション確立 → `/consent` |
|
|
206
|
+
| `store.ts` | `googleLoginNonceStore`(インメモリ / `JsonStoreBackend` 両方)と、Google アカウントを `google:<sub>` の subject で JIT 登録する `userStore.linkGoogleAccount()` |
|
|
207
|
+
| `app.ts` | `googleIdTokenVerifier`(既定は `getDefaultGoogleIdTokenVerifier()`)と `googleAccountResolver`(既定は `linkGoogleAccount`)を差し替えられるオプション |
|
|
208
|
+
| `conformance.test.ts` | 偽の `GoogleIdTokenVerifier` を注入してボタン描画・CSRF・nonce・JIT 登録・トークン発行までを固定する契約テスト |
|
|
209
|
+
| Next.js: `login/page.tsx`, `login/google/route.ts`, `_oidc-provider/runtime.ts` | ページ側で `<div {...googleSignIn} />` と `next/script` による描画、`login_uri` の Route Handler(Node.js ランタイム)、`GOOGLE_CLIENT_ID` / `GOOGLE_HOSTED_DOMAIN` の読み取り |
|
|
210
|
+
|
|
211
|
+
生成コードは `config.googleLogin` が無いときはボタンを描画せず `/login/google` を 404 で閉じるので、まず生成だけしておき、Google Cloud コンソールの準備ができてから `clientId` を渡す、という順でも動く。`login_uri` は `new URL('/login/google', config.issuer)` で組み立てるため、Google 側には `<issuer>/login/google` を登録する。
|
|
212
|
+
|
|
213
|
+
Google アカウントはパスワードのユーザーとは別に扱われ、subject は `google:<Google の sub>` になる(`email` は変わりうるので識別子にしない)。`name` / `given_name` / `family_name` / `picture` / `locale` / `email` / `email_verified` は ID トークンからそのままクレームに写す。既存ユーザーと紐付けたい場合は `applyOidc(app, { googleAccountResolver })` で差し替える。
|
|
214
|
+
|
|
215
|
+
配線済みの実例は本リポジトリの `samples/express-flyio` / `samples/fastify-flyio` / `samples/nextjs-vercel`(いずれも `GOOGLE_CLIENT_ID` を設定すると有効化)を参照。`samples/hono-cloudflare` は Cloudflare Workers 向けで `google-auth-library` が動かないため有効にしていない。
|
|
216
|
+
|
|
217
|
+
## 生成コードへの配線
|
|
218
|
+
|
|
219
|
+
CLI を使わずに組み込む場合(または `--enable google-login` が `routes/login.ts` に生成するものを知りたい場合)の例。CLI が生成するログインルートと同じ材料(`transactionStore` / `authSessionStore` / `browserSessionStore` / `views`)を使って、GET のログイン画面にボタンを足し、`login_uri` のルートを 1 つ追加する。以下は Hono の例。
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
import { Hono } from 'hono';
|
|
223
|
+
import { getAuthTransaction, generateRandomString } from '@maronn-openid-connect/core';
|
|
224
|
+
import {
|
|
225
|
+
handleGoogleLoginRedirect,
|
|
226
|
+
issueGoogleLoginNonce,
|
|
227
|
+
resolveGoogleLoginSubject,
|
|
228
|
+
GoogleLoginError,
|
|
229
|
+
type GoogleAccountResolver,
|
|
230
|
+
type GoogleLoginNonceRecord,
|
|
231
|
+
type GoogleLoginNonceStore,
|
|
232
|
+
} from '@maronn-openid-connect/google-login';
|
|
233
|
+
import {
|
|
234
|
+
buildGoogleSignInAttributes,
|
|
235
|
+
type GoogleSignInAttributes,
|
|
236
|
+
} from '@maronn-openid-connect/google-login/sign-in';
|
|
237
|
+
import { transactionStore, authSessionStore, browserSessionStore, buildSessionCookie, userStore } from './store.js';
|
|
238
|
+
import { defaultProviderConfig } from './config.js';
|
|
239
|
+
|
|
240
|
+
const googleLoginConfig = {
|
|
241
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
242
|
+
// Google Cloud コンソールの「承認済みのリダイレクト URI」と完全一致させる
|
|
243
|
+
loginUri: new URL('/login/google', defaultProviderConfig.issuer).toString(),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// nonce → transaction_id の記録。core の AuthTransactionStore と同じ KV 契約
|
|
247
|
+
// (ここではインメモリ。TTL の扱いは consumeGoogleLoginNonce が期限を見るので省いてよい)
|
|
248
|
+
const nonceRecords = new Map<string, GoogleLoginNonceRecord>();
|
|
249
|
+
const googleLoginNonceStore: GoogleLoginNonceStore = {
|
|
250
|
+
async get(key) {
|
|
251
|
+
return nonceRecords.get(key) ?? null;
|
|
252
|
+
},
|
|
253
|
+
async put(key, record) {
|
|
254
|
+
nonceRecords.set(key, record);
|
|
255
|
+
},
|
|
256
|
+
async delete(key) {
|
|
257
|
+
nonceRecords.delete(key);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Google アカウント → OP の subject。sub をキーにし、email はキーにしない。
|
|
262
|
+
// findByGoogleSubject はアプリ側のユーザーストアに用意する(生成コードの userStore には無い)
|
|
263
|
+
const googleAccountResolver: GoogleAccountResolver = {
|
|
264
|
+
async resolveSubject(account) {
|
|
265
|
+
return (await userStore.findByGoogleSubject(account.sub))?.sub ?? null;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export const googleLoginApp = new Hono();
|
|
270
|
+
|
|
271
|
+
// ログイン画面(GET /login)の描画に渡す g_id_onload の属性。描画そのもの
|
|
272
|
+
// (HTML / React / Vue)は views 側で行う(「フロント側の設定」を参照)
|
|
273
|
+
export async function googleSignInFor(transactionId: string): Promise<GoogleSignInAttributes> {
|
|
274
|
+
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
275
|
+
const nonce = await issueGoogleLoginNonce({
|
|
276
|
+
transactionId,
|
|
277
|
+
expiresAt: transaction.expiresAt,
|
|
278
|
+
store: googleLoginNonceStore,
|
|
279
|
+
});
|
|
280
|
+
return buildGoogleSignInAttributes({
|
|
281
|
+
...googleLoginConfig,
|
|
282
|
+
nonce,
|
|
283
|
+
loginHint: transaction.loginHint, // OIDC Core 1.0 §3.1.2.1
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// login_uri(POST /login/google)
|
|
288
|
+
googleLoginApp.post('/', async (c) => {
|
|
289
|
+
let login;
|
|
290
|
+
try {
|
|
291
|
+
// verifier を省略すると、プロセス共有の google-auth-library 版が使われる
|
|
292
|
+
login = await handleGoogleLoginRedirect({
|
|
293
|
+
params: await c.req.parseBody(),
|
|
294
|
+
cookieHeader: c.req.header('Cookie') ?? null,
|
|
295
|
+
clientId: googleLoginConfig.clientId,
|
|
296
|
+
nonceStore: googleLoginNonceStore,
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (!(error instanceof GoogleLoginError)) throw error;
|
|
300
|
+
return c.text(error.message, error.httpStatusCode as 400);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const transaction = await getAuthTransaction(login.transactionId, transactionStore);
|
|
304
|
+
const subject = await resolveGoogleLoginSubject(login.account, googleAccountResolver);
|
|
305
|
+
|
|
306
|
+
// ここから先はユーザー名 + パスワードのログイン成功時と同じ
|
|
307
|
+
const authTime = Math.floor(Date.now() / 1000);
|
|
308
|
+
const sessionId = generateRandomString(32);
|
|
309
|
+
await browserSessionStore.set(sessionId, { subject, authTime });
|
|
310
|
+
c.header('Set-Cookie', buildSessionCookie(sessionId));
|
|
311
|
+
await authSessionStore.set(login.transactionId, { subject, authTime, sessionId });
|
|
312
|
+
|
|
313
|
+
const consentUrl = new URL('/consent', defaultProviderConfig.issuer);
|
|
314
|
+
consentUrl.searchParams.set('transaction_id', login.transactionId);
|
|
315
|
+
return c.redirect(consentUrl.toString());
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`prompt=login` / `select_account` の扱い、`max_age` による再認証、同意ステップは既存のログインルートの実装をそのまま使う。`GoogleLoginError` を `AuthTransactionError` と同じ catch 節で処理する場合は `error.httpStatusCode` をそのまま使える。
|
|
320
|
+
|
|
321
|
+
プロキシ経由でしか Google に到達できない環境では、`createGoogleIdTokenVerifier({ clientOptions: { transporterOptions: { ... } } })` で作った verifier を `handleGoogleLoginRedirect` の `verifier` に渡す(`OAuth2Client` のオプションはそのまま通る)。
|
|
322
|
+
|
|
323
|
+
### `transaction-binding` を有効にした生成コードとの併用
|
|
324
|
+
|
|
325
|
+
`--enable transaction-binding` の束縛 Cookie は `SameSite=Lax` で発行される。Google からの POST はクロスサイトの遷移なので、ブラウザは Lax の Cookie をこの POST に付けない。`login_uri` のルートで `validateTransactionBinding` を呼ぶ場合は、束縛 Cookie を `SameSite=None; Secure` で発行し直すか、このルートでは束縛検証の代わりに nonce の単回使用を束縛とみなす、のどちらかを選ぶ。
|
|
326
|
+
|
|
327
|
+
## 検証内容とドキュメントの対応
|
|
328
|
+
|
|
329
|
+
| Google のドキュメントの条件 | 実装 |
|
|
330
|
+
|---|---|
|
|
331
|
+
| Google の公開鍵で署名を検証する。鍵はローテーションされるため `Cache-Control` を見て再取得する | `google-auth-library`(`OAuth2Client.verifyIdToken`) |
|
|
332
|
+
| `aud` がアプリのクライアント ID と同じ | `google-auth-library`(`verifyIdToken` の `audience`) |
|
|
333
|
+
| `iss` が `accounts.google.com` または `https://accounts.google.com` | `google-auth-library` |
|
|
334
|
+
| `exp` が経過していない | `google-auth-library`(`iat` が未来でないことも含む) |
|
|
335
|
+
| (任意)`hd` でホストされたドメインを確認する | `validateGoogleHostedDomain` |
|
|
336
|
+
| `g_csrf_token` の Cookie と POST 本文を突き合わせる(Double Submit Cookie) | `validateGoogleCsrfToken` |
|
|
337
|
+
| ユーザーの識別子には `sub` を使う(`email` は変わりうる) | `GoogleAccountResolver` に `sub` を含む検証済みペイロードを渡す |
|
|
338
|
+
|
|
339
|
+
tokeninfo エンドポイント(`https://oauth2.googleapis.com/tokeninfo`)は使わない。ドキュメントが本番環境での利用を推奨していない(レイテンシとネットワークエラーの可能性)ため、ライブラリがローカルで署名を検証する。
|
|
340
|
+
|
|
341
|
+
## テスト
|
|
342
|
+
|
|
343
|
+
`google-auth-library` が公開鍵を取りに行くエンドポイント(`OAuth2Client` の `endpoints` オプション)をローカルの HTTP サーバーに向け、テスト用の RSA 鍵で署名した ID トークンをライブラリ本体に検証させる。ライブラリの拒否が `GoogleLoginError` に写ること、検証を通ったペイロードがそのまま返ること、Double Submit Cookie と nonce の束縛が仕様どおりに動くことを確認する。
|
|
344
|
+
|
|
345
|
+
## 制限事項
|
|
346
|
+
|
|
347
|
+
- **Node.js 22 以上のみ。** `google-auth-library` の要件。エッジランタイムで動かしたい場合は `GoogleIdTokenVerifier` を Web 標準 API だけで実装して差し替える必要がある(このパッケージは提供しない)
|
|
348
|
+
- **redirect mode のみ。** popup mode / One Tap の JavaScript コールバック(`callback`)で受け取った credential をブラウザから別途送る構成は対象外。ただし `verifyGoogleIdToken` 単体は、どの経路で受け取った Google の ID トークンにも使える
|
|
349
|
+
- **エッジ向けの生成コードでは有効化しない。** `--enable google-login` の生成物は Node.js 22 以上を前提にする。`samples/hono-cloudflare`(Cloudflare Workers)で有効にしていないのはこのため
|
|
350
|
+
- `login_uri` へ届く POST の本文解析と Cookie の発行は行わない(core と同じく呼び出し側の責務)
|
|
351
|
+
- **ログイン画面の UI は生成しない。** 置くべき要素と属性は「フロント側の設定」のとおりで、`buildGoogleSignInAttributes` は属性オブジェクトを返すだけ。ボタンの見た目(`g_id_signin` の GIS 属性)も利用側で決める
|
|
352
|
+
|
|
353
|
+
## ライセンス
|
|
354
|
+
|
|
355
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GoogleIdTokenPayload } from './id-token.js';
|
|
2
|
+
export interface GoogleAccountResolver {
|
|
3
|
+
resolveSubject(account: GoogleIdTokenPayload): Promise<string | null>;
|
|
4
|
+
}
|
|
5
|
+
export declare function resolveGoogleLoginSubject(account: GoogleIdTokenPayload, resolver: GoogleAccountResolver): Promise<string>;
|
|
6
|
+
//# sourceMappingURL=account-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"account-resolver.d.ts","sourceRoot":"","sources":["../src/account-resolver.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAQ1D,MAAM,WAAW,qBAAqB;IAEpC,cAAc,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACvE;AASD,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,oBAAoB,EAC7B,QAAQ,EAAE,qBAAqB,GAC9B,OAAO,CAAC,MAAM,CAAC,CASjB"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
2
|
+
export async function resolveGoogleLoginSubject(account, resolver) {
|
|
3
|
+
const subject = await resolver.resolveSubject(account);
|
|
4
|
+
if (subject === null || subject === '') {
|
|
5
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.AccountNotLinked, 'This Google account is not linked to a user of this provider.');
|
|
6
|
+
}
|
|
7
|
+
return subject;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=account-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"account-resolver.js","sourceRoot":"","sources":["../src/account-resolver.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAqBrE,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,OAA6B,EAC7B,QAA+B;IAE/B,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;IACvD,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,EAAE,EAAE,CAAC;QACvC,MAAM,IAAI,gBAAgB,CACxB,oBAAoB,CAAC,gBAAgB,EACrC,+DAA+D,CAChE,CAAC;IACJ,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare enum GoogleLoginErrorCode {
|
|
2
|
+
MissingCredential = "missing_credential",
|
|
3
|
+
CsrfTokenMissingInCookie = "csrf_token_missing_in_cookie",
|
|
4
|
+
CsrfTokenMissingInBody = "csrf_token_missing_in_body",
|
|
5
|
+
CsrfTokenMismatch = "csrf_token_mismatch",
|
|
6
|
+
InvalidIdToken = "invalid_id_token",
|
|
7
|
+
SigningKeyUnavailable = "signing_key_unavailable",
|
|
8
|
+
InvalidHostedDomain = "invalid_hosted_domain",
|
|
9
|
+
EmailNotVerified = "email_not_verified",
|
|
10
|
+
InvalidNonce = "invalid_nonce",
|
|
11
|
+
LoginNonceNotFound = "login_nonce_not_found",
|
|
12
|
+
LoginNonceExpired = "login_nonce_expired",
|
|
13
|
+
AccountNotLinked = "account_not_linked"
|
|
14
|
+
}
|
|
15
|
+
export declare class GoogleLoginError extends Error {
|
|
16
|
+
readonly code: GoogleLoginErrorCode;
|
|
17
|
+
constructor(code: GoogleLoginErrorCode, message: string, options?: {
|
|
18
|
+
cause?: unknown;
|
|
19
|
+
});
|
|
20
|
+
get httpStatusCode(): number;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAOA,oBAAY,oBAAoB;IAG9B,iBAAiB,uBAAuB;IAMxC,wBAAwB,iCAAiC;IAEzD,sBAAsB,+BAA+B;IAErD,iBAAiB,wBAAwB;IAOzC,cAAc,qBAAqB;IAEnC,qBAAqB,4BAA4B;IAEjD,mBAAmB,0BAA0B;IAE7C,gBAAgB,uBAAuB;IAEvC,YAAY,kBAAkB;IAI9B,kBAAkB,0BAA0B;IAE5C,iBAAiB,wBAAwB;IAIzC,gBAAgB,uBAAuB;CACxC;AAKD,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,IAAI,EAAE,oBAAoB,CAAC;IAE3C,YAAY,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAIrF;IAaD,IAAI,cAAc,IAAI,MAAM,CAmB3B;CACF"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export var GoogleLoginErrorCode;
|
|
2
|
+
(function (GoogleLoginErrorCode) {
|
|
3
|
+
GoogleLoginErrorCode["MissingCredential"] = "missing_credential";
|
|
4
|
+
GoogleLoginErrorCode["CsrfTokenMissingInCookie"] = "csrf_token_missing_in_cookie";
|
|
5
|
+
GoogleLoginErrorCode["CsrfTokenMissingInBody"] = "csrf_token_missing_in_body";
|
|
6
|
+
GoogleLoginErrorCode["CsrfTokenMismatch"] = "csrf_token_mismatch";
|
|
7
|
+
GoogleLoginErrorCode["InvalidIdToken"] = "invalid_id_token";
|
|
8
|
+
GoogleLoginErrorCode["SigningKeyUnavailable"] = "signing_key_unavailable";
|
|
9
|
+
GoogleLoginErrorCode["InvalidHostedDomain"] = "invalid_hosted_domain";
|
|
10
|
+
GoogleLoginErrorCode["EmailNotVerified"] = "email_not_verified";
|
|
11
|
+
GoogleLoginErrorCode["InvalidNonce"] = "invalid_nonce";
|
|
12
|
+
GoogleLoginErrorCode["LoginNonceNotFound"] = "login_nonce_not_found";
|
|
13
|
+
GoogleLoginErrorCode["LoginNonceExpired"] = "login_nonce_expired";
|
|
14
|
+
GoogleLoginErrorCode["AccountNotLinked"] = "account_not_linked";
|
|
15
|
+
})(GoogleLoginErrorCode || (GoogleLoginErrorCode = {}));
|
|
16
|
+
export class GoogleLoginError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message, options) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.name = 'GoogleLoginError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
get httpStatusCode() {
|
|
24
|
+
switch (this.code) {
|
|
25
|
+
case GoogleLoginErrorCode.MissingCredential:
|
|
26
|
+
case GoogleLoginErrorCode.CsrfTokenMissingInCookie:
|
|
27
|
+
case GoogleLoginErrorCode.CsrfTokenMissingInBody:
|
|
28
|
+
case GoogleLoginErrorCode.CsrfTokenMismatch:
|
|
29
|
+
case GoogleLoginErrorCode.InvalidNonce:
|
|
30
|
+
case GoogleLoginErrorCode.LoginNonceNotFound:
|
|
31
|
+
case GoogleLoginErrorCode.LoginNonceExpired:
|
|
32
|
+
return 400;
|
|
33
|
+
case GoogleLoginErrorCode.InvalidIdToken:
|
|
34
|
+
return 401;
|
|
35
|
+
case GoogleLoginErrorCode.InvalidHostedDomain:
|
|
36
|
+
case GoogleLoginErrorCode.EmailNotVerified:
|
|
37
|
+
case GoogleLoginErrorCode.AccountNotLinked:
|
|
38
|
+
return 403;
|
|
39
|
+
case GoogleLoginErrorCode.SigningKeyUnavailable:
|
|
40
|
+
return 503;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAOA,MAAM,CAAN,IAAY,oBAuCX;AAvCD,WAAY,oBAAoB;IAG9B,gEAAwC,CAAA;IAMxC,iFAAyD,CAAA;IAEzD,6EAAqD,CAAA;IAErD,iEAAyC,CAAA;IAOzC,2DAAmC,CAAA;IAEnC,yEAAiD,CAAA;IAEjD,qEAA6C,CAAA;IAE7C,+DAAuC,CAAA;IAEvC,sDAA8B,CAAA;IAI9B,oEAA4C,CAAA;IAE5C,iEAAyC,CAAA;IAIzC,+DAAuC,CAAA;AACzC,CAAC,EAvCW,oBAAoB,KAApB,oBAAoB,QAuC/B;AAKD,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzB,IAAI,CAAuB;IAE3C,YAAY,IAA0B,EAAE,OAAe,EAAE,OAA6B;QACpF,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;IAaD,IAAI,cAAc;QAChB,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;YAClB,KAAK,oBAAoB,CAAC,iBAAiB,CAAC;YAC5C,KAAK,oBAAoB,CAAC,wBAAwB,CAAC;YACnD,KAAK,oBAAoB,CAAC,sBAAsB,CAAC;YACjD,KAAK,oBAAoB,CAAC,iBAAiB,CAAC;YAC5C,KAAK,oBAAoB,CAAC,YAAY,CAAC;YACvC,KAAK,oBAAoB,CAAC,kBAAkB,CAAC;YAC7C,KAAK,oBAAoB,CAAC,iBAAiB;gBACzC,OAAO,GAAG,CAAC;YACb,KAAK,oBAAoB,CAAC,cAAc;gBACtC,OAAO,GAAG,CAAC;YACb,KAAK,oBAAoB,CAAC,mBAAmB,CAAC;YAC9C,KAAK,oBAAoB,CAAC,gBAAgB,CAAC;YAC3C,KAAK,oBAAoB,CAAC,gBAAgB;gBACxC,OAAO,GAAG,CAAC;YACb,KAAK,oBAAoB,CAAC,qBAAqB;gBAC7C,OAAO,GAAG,CAAC;QACf,CAAC;IACH,CAAC;CACF"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { OAuth2Client } from 'google-auth-library';
|
|
2
|
+
import type { OAuth2ClientOptions, TokenPayload } from 'google-auth-library';
|
|
3
|
+
export type GoogleIdTokenPayload = TokenPayload;
|
|
4
|
+
export interface GoogleIdTokenVerifier {
|
|
5
|
+
verify(idToken: string, clientId: string | readonly string[]): Promise<GoogleIdTokenPayload>;
|
|
6
|
+
}
|
|
7
|
+
export interface GoogleIdTokenVerifierOptions {
|
|
8
|
+
client?: OAuth2Client;
|
|
9
|
+
clientOptions?: OAuth2ClientOptions;
|
|
10
|
+
}
|
|
11
|
+
export declare function createGoogleIdTokenVerifier(options?: GoogleIdTokenVerifierOptions): GoogleIdTokenVerifier;
|
|
12
|
+
export declare function getDefaultGoogleIdTokenVerifier(): GoogleIdTokenVerifier;
|
|
13
|
+
export declare function validateGoogleHostedDomain(payload: GoogleIdTokenPayload, hostedDomain?: string | readonly string[]): void;
|
|
14
|
+
export declare function validateGoogleEmailVerified(payload: GoogleIdTokenPayload): void;
|
|
15
|
+
export declare function validateGoogleIdTokenNonce(payload: GoogleIdTokenPayload, expectedNonce?: string): void;
|
|
16
|
+
export interface VerifyGoogleIdTokenOptions {
|
|
17
|
+
clientId: string | readonly string[];
|
|
18
|
+
verifier?: GoogleIdTokenVerifier;
|
|
19
|
+
hostedDomain?: string | readonly string[];
|
|
20
|
+
requireVerifiedEmail?: boolean;
|
|
21
|
+
expectedNonce?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function verifyGoogleIdToken(idToken: string, options: VerifyGoogleIdTokenOptions): Promise<GoogleIdTokenPayload>;
|
|
24
|
+
//# sourceMappingURL=id-token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"id-token.d.ts","sourceRoot":"","sources":["../src/id-token.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAU,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAU7E,MAAM,MAAM,oBAAoB,GAAG,YAAY,CAAC;AAQhD,MAAM,WAAW,qBAAqB;IAMpC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CAC9F;AAED,MAAM,WAAW,4BAA4B;IAK3C,MAAM,CAAC,EAAE,YAAY,CAAC;IAMtB,aAAa,CAAC,EAAE,mBAAmB,CAAC;CACrC;AAuCD,wBAAgB,2BAA2B,CACzC,OAAO,GAAE,4BAAiC,GACzC,qBAAqB,CAuBvB;AAUD,wBAAgB,+BAA+B,IAAI,qBAAqB,CAGvE;AAWD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,oBAAoB,EAC7B,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GACxC,IAAI,CASN;AAUD,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,oBAAoB,GAAG,IAAI,CAO/E;AAWD,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,oBAAoB,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAQtG;AAED,MAAM,WAAW,0BAA0B;IAKzC,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAErC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IAEjC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAE1C,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAE/B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAWD,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,oBAAoB,CAAC,CAW/B"}
|
package/dist/id-token.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { gaxios, OAuth2Client } from 'google-auth-library';
|
|
2
|
+
import { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
3
|
+
function isNonEmptyString(value) {
|
|
4
|
+
return typeof value === 'string' && value.length > 0;
|
|
5
|
+
}
|
|
6
|
+
function normalizeList(value, label) {
|
|
7
|
+
const list = typeof value === 'string' ? [value] : [...value];
|
|
8
|
+
if (list.length === 0 || !list.every(isNonEmptyString)) {
|
|
9
|
+
throw new TypeError(`${label} must be a non-empty string or a non-empty array of non-empty strings`);
|
|
10
|
+
}
|
|
11
|
+
return list;
|
|
12
|
+
}
|
|
13
|
+
function toGoogleLoginError(error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
if (error instanceof gaxios.GaxiosError ||
|
|
16
|
+
message.startsWith('Failed to retrieve verification certificates')) {
|
|
17
|
+
return new GoogleLoginError(GoogleLoginErrorCode.SigningKeyUnavailable, message, { cause: error });
|
|
18
|
+
}
|
|
19
|
+
return new GoogleLoginError(GoogleLoginErrorCode.InvalidIdToken, message, { cause: error });
|
|
20
|
+
}
|
|
21
|
+
export function createGoogleIdTokenVerifier(options = {}) {
|
|
22
|
+
const client = options.client ?? new OAuth2Client(options.clientOptions);
|
|
23
|
+
return {
|
|
24
|
+
async verify(idToken, clientId) {
|
|
25
|
+
const audience = normalizeList(clientId, 'clientId');
|
|
26
|
+
if (!isNonEmptyString(idToken)) {
|
|
27
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.InvalidIdToken, 'ID token is empty');
|
|
28
|
+
}
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
const ticket = await client.verifyIdToken({ idToken, audience });
|
|
32
|
+
payload = ticket.getPayload();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw toGoogleLoginError(error);
|
|
36
|
+
}
|
|
37
|
+
if (payload === undefined) {
|
|
38
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.InvalidIdToken, 'ID token has no payload');
|
|
39
|
+
}
|
|
40
|
+
return payload;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
let defaultVerifier;
|
|
45
|
+
export function getDefaultGoogleIdTokenVerifier() {
|
|
46
|
+
defaultVerifier ??= createGoogleIdTokenVerifier();
|
|
47
|
+
return defaultVerifier;
|
|
48
|
+
}
|
|
49
|
+
export function validateGoogleHostedDomain(payload, hostedDomain) {
|
|
50
|
+
if (hostedDomain === undefined)
|
|
51
|
+
return;
|
|
52
|
+
const allowed = normalizeList(hostedDomain, 'hostedDomain');
|
|
53
|
+
if (!isNonEmptyString(payload.hd) || !allowed.includes(payload.hd)) {
|
|
54
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.InvalidHostedDomain, 'ID token hd does not match the allowed hosted domain');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function validateGoogleEmailVerified(payload) {
|
|
58
|
+
if (payload.email_verified !== true) {
|
|
59
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.EmailNotVerified, 'ID token email is not verified');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function validateGoogleIdTokenNonce(payload, expectedNonce) {
|
|
63
|
+
if (expectedNonce === undefined)
|
|
64
|
+
return;
|
|
65
|
+
if (!isNonEmptyString(payload.nonce) || payload.nonce !== expectedNonce) {
|
|
66
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.InvalidNonce, 'ID token nonce does not match the expected value');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function verifyGoogleIdToken(idToken, options) {
|
|
70
|
+
const verifier = options.verifier ?? getDefaultGoogleIdTokenVerifier();
|
|
71
|
+
const payload = await verifier.verify(idToken, options.clientId);
|
|
72
|
+
validateGoogleHostedDomain(payload, options.hostedDomain);
|
|
73
|
+
if (options.requireVerifiedEmail) {
|
|
74
|
+
validateGoogleEmailVerified(payload);
|
|
75
|
+
}
|
|
76
|
+
validateGoogleIdTokenNonce(payload, options.expectedNonce);
|
|
77
|
+
return payload;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=id-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"id-token.js","sourceRoot":"","sources":["../src/id-token.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
2
|
+
export { issueGoogleLoginNonce, consumeGoogleLoginNonce, } from './nonce.js';
|
|
3
|
+
export type { GoogleLoginNonceRecord, GoogleLoginNonceStore, IssueGoogleLoginNonceOptions, } from './nonce.js';
|
|
4
|
+
export { parseGoogleRedirectCredential, parseGoogleCsrfTokenCookie, validateGoogleCsrfToken, readGoogleLoginParam, GOOGLE_CREDENTIAL_PARAM, GOOGLE_CSRF_TOKEN_PARAM, GOOGLE_CSRF_TOKEN_COOKIE, GOOGLE_SELECT_BY_PARAM, } from './redirect-credential.js';
|
|
5
|
+
export type { GoogleLoginParamsSource, GoogleRedirectCredential, } from './redirect-credential.js';
|
|
6
|
+
export { verifyGoogleIdToken, createGoogleIdTokenVerifier, getDefaultGoogleIdTokenVerifier, validateGoogleHostedDomain, validateGoogleEmailVerified, validateGoogleIdTokenNonce, } from './id-token.js';
|
|
7
|
+
export type { GoogleIdTokenPayload, GoogleIdTokenVerifier, GoogleIdTokenVerifierOptions, VerifyGoogleIdTokenOptions, } from './id-token.js';
|
|
8
|
+
export { resolveGoogleLoginSubject, } from './account-resolver.js';
|
|
9
|
+
export type { GoogleAccountResolver, } from './account-resolver.js';
|
|
10
|
+
export { handleGoogleLoginRedirect, } from './login-redirect.js';
|
|
11
|
+
export type { GoogleLoginRedirectContext, GoogleLoginRedirectResult, } from './login-redirect.js';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAErE,OAAO,EAEL,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAEpB,YAAY,EACV,sBAAsB,EACtB,qBAAqB,EACrB,4BAA4B,GAC7B,MAAM,YAAY,CAAC;AAEpB,OAAO,EAEL,6BAA6B,EAC7B,0BAA0B,EAC1B,uBAAuB,EACvB,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EACV,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAEL,mBAAmB,EACnB,2BAA2B,EAC3B,+BAA+B,EAC/B,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,eAAe,CAAC;AAEvB,YAAY,EACV,oBAAoB,EACpB,qBAAqB,EACrB,4BAA4B,EAC5B,0BAA0B,GAC3B,MAAM,eAAe,CAAC;AAEvB,OAAO,EAEL,yBAAyB,GAC1B,MAAM,uBAAuB,CAAC;AAE/B,YAAY,EACV,qBAAqB,GACtB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAEL,yBAAyB,GAC1B,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EACV,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
2
|
+
export { issueGoogleLoginNonce, consumeGoogleLoginNonce, } from './nonce.js';
|
|
3
|
+
export { parseGoogleRedirectCredential, parseGoogleCsrfTokenCookie, validateGoogleCsrfToken, readGoogleLoginParam, GOOGLE_CREDENTIAL_PARAM, GOOGLE_CSRF_TOKEN_PARAM, GOOGLE_CSRF_TOKEN_COOKIE, GOOGLE_SELECT_BY_PARAM, } from './redirect-credential.js';
|
|
4
|
+
export { verifyGoogleIdToken, createGoogleIdTokenVerifier, getDefaultGoogleIdTokenVerifier, validateGoogleHostedDomain, validateGoogleEmailVerified, validateGoogleIdTokenNonce, } from './id-token.js';
|
|
5
|
+
export { resolveGoogleLoginSubject, } from './account-resolver.js';
|
|
6
|
+
export { handleGoogleLoginRedirect, } from './login-redirect.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAErE,OAAO,EAEL,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAQpB,OAAO,EAEL,6BAA6B,EAC7B,0BAA0B,EAC1B,uBAAuB,EACvB,oBAAoB,EACpB,uBAAuB,EACvB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,0BAA0B,CAAC;AAOlC,OAAO,EAEL,mBAAmB,EACnB,2BAA2B,EAC3B,+BAA+B,EAC/B,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,eAAe,CAAC;AASvB,OAAO,EAEL,yBAAyB,GAC1B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAEL,yBAAyB,GAC1B,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GoogleIdTokenPayload, GoogleIdTokenVerifier } from './id-token.js';
|
|
2
|
+
import type { GoogleLoginNonceStore } from './nonce.js';
|
|
3
|
+
import type { GoogleLoginParamsSource } from './redirect-credential.js';
|
|
4
|
+
export interface GoogleLoginRedirectContext {
|
|
5
|
+
params: GoogleLoginParamsSource;
|
|
6
|
+
cookieHeader: string | null | undefined;
|
|
7
|
+
clientId: string | readonly string[];
|
|
8
|
+
nonceStore: GoogleLoginNonceStore;
|
|
9
|
+
verifier?: GoogleIdTokenVerifier;
|
|
10
|
+
hostedDomain?: string | readonly string[];
|
|
11
|
+
requireVerifiedEmail?: boolean;
|
|
12
|
+
now?: Date;
|
|
13
|
+
}
|
|
14
|
+
export interface GoogleLoginRedirectResult {
|
|
15
|
+
transactionId: string;
|
|
16
|
+
account: GoogleIdTokenPayload;
|
|
17
|
+
selectBy?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function handleGoogleLoginRedirect(context: GoogleLoginRedirectContext): Promise<GoogleLoginRedirectResult>;
|
|
20
|
+
//# sourceMappingURL=login-redirect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-redirect.d.ts","sourceRoot":"","sources":["../src/login-redirect.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAEjF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAMxD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAExE,MAAM,WAAW,0BAA0B;IAEzC,MAAM,EAAE,uBAAuB,CAAC;IAEhC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAExC,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAErC,UAAU,EAAE,qBAAqB,CAAC;IAElC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IAEjC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAE1C,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAE/B,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,yBAAyB;IAExC,aAAa,EAAE,MAAM,CAAC;IAEtB,OAAO,EAAE,oBAAoB,CAAC;IAE9B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAOD,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC,yBAAyB,CAAC,CA6BpC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
2
|
+
import { verifyGoogleIdToken } from './id-token.js';
|
|
3
|
+
import { consumeGoogleLoginNonce } from './nonce.js';
|
|
4
|
+
import { parseGoogleCsrfTokenCookie, parseGoogleRedirectCredential, validateGoogleCsrfToken, } from './redirect-credential.js';
|
|
5
|
+
export async function handleGoogleLoginRedirect(context) {
|
|
6
|
+
const cookieToken = parseGoogleCsrfTokenCookie(context.cookieHeader);
|
|
7
|
+
const credential = parseGoogleRedirectCredential(context.params);
|
|
8
|
+
validateGoogleCsrfToken(credential.csrfToken, cookieToken);
|
|
9
|
+
const account = await verifyGoogleIdToken(credential.credential, {
|
|
10
|
+
clientId: context.clientId,
|
|
11
|
+
verifier: context.verifier,
|
|
12
|
+
hostedDomain: context.hostedDomain,
|
|
13
|
+
requireVerifiedEmail: context.requireVerifiedEmail,
|
|
14
|
+
});
|
|
15
|
+
const nonce = account.nonce;
|
|
16
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
17
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.InvalidNonce, 'ID token does not carry the nonce issued for this login page');
|
|
18
|
+
}
|
|
19
|
+
const record = await consumeGoogleLoginNonce(nonce, context.nonceStore, context.now);
|
|
20
|
+
const result = {
|
|
21
|
+
transactionId: record.transactionId,
|
|
22
|
+
account,
|
|
23
|
+
};
|
|
24
|
+
if (credential.selectBy !== undefined) {
|
|
25
|
+
result.selectBy = credential.selectBy;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=login-redirect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login-redirect.js","sourceRoot":"","sources":["../src/login-redirect.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEpD,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,EACL,0BAA0B,EAC1B,6BAA6B,EAC7B,uBAAuB,GACxB,MAAM,0BAA0B,CAAC;AAoClC,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,OAAmC;IAEnC,MAAM,WAAW,GAAG,0BAA0B,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IACrE,MAAM,UAAU,GAAG,6BAA6B,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACjE,uBAAuB,CAAC,UAAU,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC;IAE3D,MAAM,OAAO,GAAG,MAAM,mBAAmB,CAAC,UAAU,CAAC,UAAU,EAAE;QAC/D,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,YAAY,EAAE,OAAO,CAAC,YAAY;QAClC,oBAAoB,EAAE,OAAO,CAAC,oBAAoB;KACnD,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpD,MAAM,IAAI,gBAAgB,CACxB,oBAAoB,CAAC,YAAY,EACjC,8DAA8D,CAC/D,CAAC;IACJ,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,uBAAuB,CAAC,KAAK,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;IAErF,MAAM,MAAM,GAA8B;QACxC,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,OAAO;KACR,CAAC;IACF,IAAI,UAAU,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QACtC,MAAM,CAAC,QAAQ,GAAG,UAAU,CAAC,QAAQ,CAAC;IACxC,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/dist/nonce.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface GoogleLoginNonceRecord {
|
|
2
|
+
transactionId: string;
|
|
3
|
+
expiresAt: number;
|
|
4
|
+
}
|
|
5
|
+
export interface GoogleLoginNonceStore {
|
|
6
|
+
get(key: string): Promise<GoogleLoginNonceRecord | null>;
|
|
7
|
+
put(key: string, record: GoogleLoginNonceRecord, ttlSeconds: number): Promise<void>;
|
|
8
|
+
delete(key: string): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface IssueGoogleLoginNonceOptions {
|
|
11
|
+
transactionId: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
store: GoogleLoginNonceStore;
|
|
14
|
+
now?: Date;
|
|
15
|
+
}
|
|
16
|
+
export declare function issueGoogleLoginNonce(options: IssueGoogleLoginNonceOptions): Promise<string>;
|
|
17
|
+
export declare function consumeGoogleLoginNonce(nonce: string, store: GoogleLoginNonceStore, now?: Date): Promise<GoogleLoginNonceRecord>;
|
|
18
|
+
//# sourceMappingURL=nonce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,sBAAsB;IAErC,aAAa,EAAE,MAAM,CAAC;IAEtB,SAAS,EAAE,MAAM,CAAC;CACnB;AAQD,MAAM,WAAW,qBAAqB;IACpC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAAC;IACzD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,sBAAsB,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,4BAA4B;IAE3C,aAAa,EAAE,MAAM,CAAC;IAEtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,qBAAqB,CAAC;IAE7B,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AASD,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,OAAO,CAAC,MAAM,CAAC,CAYlG;AAUD,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,qBAAqB,EAC5B,GAAG,CAAC,EAAE,IAAI,GACT,OAAO,CAAC,sBAAsB,CAAC,CAoBjC"}
|
package/dist/nonce.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { generateRandomString } from '@maronn-openid-connect/core';
|
|
2
|
+
import { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
3
|
+
const STORE_KEY_PREFIX = 'google_login_nonce:';
|
|
4
|
+
const NONCE_BYTE_LENGTH = 32;
|
|
5
|
+
export async function issueGoogleLoginNonce(options) {
|
|
6
|
+
const now = (options.now ?? new Date()).getTime();
|
|
7
|
+
const nonce = generateRandomString(NONCE_BYTE_LENGTH);
|
|
8
|
+
const remainingTtlMs = options.expiresAt - now;
|
|
9
|
+
const ttlSeconds = Math.max(1, Math.ceil(remainingTtlMs / 1000));
|
|
10
|
+
await options.store.put(`${STORE_KEY_PREFIX}${nonce}`, { transactionId: options.transactionId, expiresAt: options.expiresAt }, ttlSeconds);
|
|
11
|
+
return nonce;
|
|
12
|
+
}
|
|
13
|
+
export async function consumeGoogleLoginNonce(nonce, store, now) {
|
|
14
|
+
const key = `${STORE_KEY_PREFIX}${nonce}`;
|
|
15
|
+
const record = await store.get(key);
|
|
16
|
+
if (!record) {
|
|
17
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.LoginNonceNotFound, 'Google login attempt not found. The login page may have expired or the credential was already used.');
|
|
18
|
+
}
|
|
19
|
+
await store.delete(key);
|
|
20
|
+
if (record.expiresAt <= (now ?? new Date()).getTime()) {
|
|
21
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.LoginNonceExpired, 'Google login attempt has expired. Please start the authorization flow again.');
|
|
22
|
+
}
|
|
23
|
+
return record;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=nonce.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nonce.js","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAEnE,OAAO,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAGrE,MAAM,gBAAgB,GAAG,qBAAqB,CAAC;AAG/C,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAuC7B,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,OAAqC;IAC/E,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;IAClD,MAAM,KAAK,GAAG,oBAAoB,CAAC,iBAAiB,CAAC,CAAC;IACtD,MAAM,cAAc,GAAG,OAAO,CAAC,SAAS,GAAG,GAAG,CAAC;IAC/C,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC,CAAC;IAEjE,MAAM,OAAO,CAAC,KAAK,CAAC,GAAG,CACrB,GAAG,gBAAgB,GAAG,KAAK,EAAE,EAC7B,EAAE,aAAa,EAAE,OAAO,CAAC,aAAa,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,EACtE,UAAU,CACX,CAAC;IACF,OAAO,KAAK,CAAC;AACf,CAAC;AAUD,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,KAAa,EACb,KAA4B,EAC5B,GAAU;IAEV,MAAM,GAAG,GAAG,GAAG,gBAAgB,GAAG,KAAK,EAAE,CAAC;IAC1C,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,gBAAgB,CACxB,oBAAoB,CAAC,kBAAkB,EACvC,qGAAqG,CACtG,CAAC;IACJ,CAAC;IAGD,MAAM,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAExB,IAAI,MAAM,CAAC,SAAS,IAAI,CAAC,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QACtD,MAAM,IAAI,gBAAgB,CACxB,oBAAoB,CAAC,iBAAiB,EACtC,8EAA8E,CAC/E,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const GOOGLE_CREDENTIAL_PARAM = "credential";
|
|
2
|
+
export declare const GOOGLE_CSRF_TOKEN_PARAM = "g_csrf_token";
|
|
3
|
+
export declare const GOOGLE_CSRF_TOKEN_COOKIE = "g_csrf_token";
|
|
4
|
+
export declare const GOOGLE_SELECT_BY_PARAM = "select_by";
|
|
5
|
+
export type GoogleLoginParamsSource = Record<string, unknown> | URLSearchParams | FormData;
|
|
6
|
+
export interface GoogleRedirectCredential {
|
|
7
|
+
credential: string;
|
|
8
|
+
csrfToken?: string;
|
|
9
|
+
selectBy?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function readGoogleLoginParam(source: GoogleLoginParamsSource, name: string): string | undefined;
|
|
12
|
+
export declare function parseGoogleRedirectCredential(source: GoogleLoginParamsSource): GoogleRedirectCredential;
|
|
13
|
+
export declare function parseGoogleCsrfTokenCookie(cookieHeader: string | null | undefined): string | undefined;
|
|
14
|
+
export declare function validateGoogleCsrfToken(bodyToken: string | undefined, cookieToken: string | undefined): void;
|
|
15
|
+
//# sourceMappingURL=redirect-credential.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect-credential.d.ts","sourceRoot":"","sources":["../src/redirect-credential.ts"],"names":[],"mappings":"AAqBA,eAAO,MAAM,uBAAuB,eAAe,CAAC;AAEpD,eAAO,MAAM,uBAAuB,iBAAiB,CAAC;AAEtD,eAAO,MAAM,wBAAwB,iBAAiB,CAAC;AAEvD,eAAO,MAAM,sBAAsB,cAAc,CAAC;AAQlD,MAAM,MAAM,uBAAuB,GAC/B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACvB,eAAe,GACf,QAAQ,CAAC;AAGb,MAAM,WAAW,wBAAwB;IAEvC,UAAU,EAAE,MAAM,CAAC;IAEnB,SAAS,CAAC,EAAE,MAAM,CAAC;IAMnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAKD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,uBAAuB,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMtG;AAOD,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,uBAAuB,GAAG,wBAAwB,CAoBvG;AAKD,wBAAgB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAYtG;AA2BD,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,WAAW,EAAE,MAAM,GAAG,SAAS,GAC9B,IAAI,CAmBN"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { GoogleLoginError, GoogleLoginErrorCode } from './errors.js';
|
|
3
|
+
export const GOOGLE_CREDENTIAL_PARAM = 'credential';
|
|
4
|
+
export const GOOGLE_CSRF_TOKEN_PARAM = 'g_csrf_token';
|
|
5
|
+
export const GOOGLE_CSRF_TOKEN_COOKIE = 'g_csrf_token';
|
|
6
|
+
export const GOOGLE_SELECT_BY_PARAM = 'select_by';
|
|
7
|
+
export function readGoogleLoginParam(source, name) {
|
|
8
|
+
const value = source instanceof URLSearchParams || source instanceof FormData
|
|
9
|
+
? source.get(name)
|
|
10
|
+
: source[name];
|
|
11
|
+
return typeof value === 'string' ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
export function parseGoogleRedirectCredential(source) {
|
|
14
|
+
const credential = readGoogleLoginParam(source, GOOGLE_CREDENTIAL_PARAM);
|
|
15
|
+
if (!credential) {
|
|
16
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.MissingCredential, 'No credential in post body.');
|
|
17
|
+
}
|
|
18
|
+
const result = { credential };
|
|
19
|
+
const csrfToken = readGoogleLoginParam(source, GOOGLE_CSRF_TOKEN_PARAM);
|
|
20
|
+
if (csrfToken) {
|
|
21
|
+
result.csrfToken = csrfToken;
|
|
22
|
+
}
|
|
23
|
+
const selectBy = readGoogleLoginParam(source, GOOGLE_SELECT_BY_PARAM);
|
|
24
|
+
if (selectBy) {
|
|
25
|
+
result.selectBy = selectBy;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
export function parseGoogleCsrfTokenCookie(cookieHeader) {
|
|
30
|
+
if (!cookieHeader)
|
|
31
|
+
return undefined;
|
|
32
|
+
for (const part of cookieHeader.split(';')) {
|
|
33
|
+
const trimmed = part.trim();
|
|
34
|
+
const eq = trimmed.indexOf('=');
|
|
35
|
+
if (eq === -1)
|
|
36
|
+
continue;
|
|
37
|
+
if (trimmed.slice(0, eq).trim() === GOOGLE_CSRF_TOKEN_COOKIE) {
|
|
38
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
39
|
+
return value.length > 0 ? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
function constantTimeEqual(a, b) {
|
|
45
|
+
const digestA = createHash('sha256').update(a).digest();
|
|
46
|
+
const digestB = createHash('sha256').update(b).digest();
|
|
47
|
+
return timingSafeEqual(digestA, digestB);
|
|
48
|
+
}
|
|
49
|
+
export function validateGoogleCsrfToken(bodyToken, cookieToken) {
|
|
50
|
+
if (!cookieToken) {
|
|
51
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.CsrfTokenMissingInCookie, 'No CSRF token in Cookie.');
|
|
52
|
+
}
|
|
53
|
+
if (!bodyToken) {
|
|
54
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.CsrfTokenMissingInBody, 'No CSRF token in post body.');
|
|
55
|
+
}
|
|
56
|
+
if (!constantTimeEqual(bodyToken, cookieToken)) {
|
|
57
|
+
throw new GoogleLoginError(GoogleLoginErrorCode.CsrfTokenMismatch, 'Failed to verify double submit cookie.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=redirect-credential.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect-credential.js","sourceRoot":"","sources":["../src/redirect-credential.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const GOOGLE_GSI_CLIENT_SCRIPT_URL = "https://accounts.google.com/gsi/client";
|
|
2
|
+
export declare const GOOGLE_SIGN_IN_ONLOAD_ID = "g_id_onload";
|
|
3
|
+
export declare const GOOGLE_SIGN_IN_BUTTON_CLASS = "g_id_signin";
|
|
4
|
+
export declare const GOOGLE_SIGN_IN_CSP_SOURCES: {
|
|
5
|
+
readonly scriptSrc: 'https://accounts.google.com/gsi/client';
|
|
6
|
+
readonly frameSrc: 'https://accounts.google.com/gsi/';
|
|
7
|
+
readonly connectSrc: 'https://accounts.google.com/gsi/';
|
|
8
|
+
readonly styleSrc: 'https://accounts.google.com/gsi/style';
|
|
9
|
+
};
|
|
10
|
+
export interface GoogleSignInOptions {
|
|
11
|
+
clientId: string;
|
|
12
|
+
loginUri: string;
|
|
13
|
+
nonce: string;
|
|
14
|
+
loginHint?: string;
|
|
15
|
+
hostedDomain?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface GoogleSignInAttributes {
|
|
18
|
+
id: typeof GOOGLE_SIGN_IN_ONLOAD_ID;
|
|
19
|
+
'data-client_id': string;
|
|
20
|
+
'data-ux_mode': 'redirect';
|
|
21
|
+
'data-login_uri': string;
|
|
22
|
+
'data-nonce': string;
|
|
23
|
+
'data-login_hint'?: string;
|
|
24
|
+
'data-hd'?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function assertGoogleLoginUri(loginUri: string): void;
|
|
27
|
+
export declare function buildGoogleSignInAttributes(options: GoogleSignInOptions): GoogleSignInAttributes;
|
|
28
|
+
export declare function googleSignInAttributesToHtml(attributes: GoogleSignInAttributes | Readonly<Record<string, string | number | boolean | undefined>>): string;
|
|
29
|
+
//# sourceMappingURL=sign-in.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign-in.d.ts","sourceRoot":"","sources":["../src/sign-in.ts"],"names":[],"mappings":"AAsBA,eAAO,MAAM,4BAA4B,2CAA2C,CAAC;AAGrF,eAAO,MAAM,wBAAwB,gBAAgB,CAAC;AAGtD,eAAO,MAAM,2BAA2B,gBAAgB,CAAC;AAOzD,eAAO,MAAM,0BAA0B;aACrC,SAAS,EAAE,wCAAwC;aACnD,QAAQ,EAAE,kCAAkC;aAC5C,UAAU,EAAE,kCAAkC;aAC9C,QAAQ,EAAE,uCAAuC;CACzC,CAAC;AAEX,MAAM,WAAW,mBAAmB;IAElC,QAAQ,EAAE,MAAM,CAAC;IAKjB,QAAQ,EAAE,MAAM,CAAC;IAEjB,KAAK,EAAE,MAAM,CAAC;IAEd,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAQD,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,OAAO,wBAAwB,CAAC;IACpC,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,UAAU,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAaD,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAa3D;AAaD,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,mBAAmB,GAAG,sBAAsB,CAuBhG;AAuBD,wBAAgB,4BAA4B,CAC1C,UAAU,EAAE,sBAAsB,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAC,GACnG,MAAM,CAUR"}
|
package/dist/sign-in.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const GOOGLE_GSI_CLIENT_SCRIPT_URL = 'https://accounts.google.com/gsi/client';
|
|
2
|
+
export const GOOGLE_SIGN_IN_ONLOAD_ID = 'g_id_onload';
|
|
3
|
+
export const GOOGLE_SIGN_IN_BUTTON_CLASS = 'g_id_signin';
|
|
4
|
+
export const GOOGLE_SIGN_IN_CSP_SOURCES = {
|
|
5
|
+
scriptSrc: 'https://accounts.google.com/gsi/client',
|
|
6
|
+
frameSrc: 'https://accounts.google.com/gsi/',
|
|
7
|
+
connectSrc: 'https://accounts.google.com/gsi/',
|
|
8
|
+
styleSrc: 'https://accounts.google.com/gsi/style',
|
|
9
|
+
};
|
|
10
|
+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
11
|
+
export function assertGoogleLoginUri(loginUri) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(loginUri);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new TypeError(`loginUri must be an absolute URL: ${loginUri}`);
|
|
18
|
+
}
|
|
19
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_HOSTNAMES.has(url.hostname))) {
|
|
20
|
+
throw new TypeError(`loginUri must use https (http is allowed only for localhost): ${loginUri}`);
|
|
21
|
+
}
|
|
22
|
+
if (url.hash !== '') {
|
|
23
|
+
throw new TypeError(`loginUri must not contain a fragment: ${loginUri}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function buildGoogleSignInAttributes(options) {
|
|
27
|
+
if (!options.clientId) {
|
|
28
|
+
throw new TypeError('clientId must not be empty');
|
|
29
|
+
}
|
|
30
|
+
if (!options.nonce) {
|
|
31
|
+
throw new TypeError('nonce must not be empty');
|
|
32
|
+
}
|
|
33
|
+
assertGoogleLoginUri(options.loginUri);
|
|
34
|
+
const attributes = {
|
|
35
|
+
id: GOOGLE_SIGN_IN_ONLOAD_ID,
|
|
36
|
+
'data-client_id': options.clientId,
|
|
37
|
+
'data-ux_mode': 'redirect',
|
|
38
|
+
'data-login_uri': options.loginUri,
|
|
39
|
+
'data-nonce': options.nonce,
|
|
40
|
+
};
|
|
41
|
+
if (options.loginHint !== undefined) {
|
|
42
|
+
attributes['data-login_hint'] = options.loginHint;
|
|
43
|
+
}
|
|
44
|
+
if (options.hostedDomain !== undefined) {
|
|
45
|
+
attributes['data-hd'] = options.hostedDomain;
|
|
46
|
+
}
|
|
47
|
+
return attributes;
|
|
48
|
+
}
|
|
49
|
+
const HTML_ATTRIBUTE_NAME = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;
|
|
50
|
+
function escapeHtmlAttribute(value) {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/'/g, ''');
|
|
57
|
+
}
|
|
58
|
+
export function googleSignInAttributesToHtml(attributes) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
continue;
|
|
63
|
+
if (!HTML_ATTRIBUTE_NAME.test(name)) {
|
|
64
|
+
throw new TypeError(`Invalid HTML attribute name: ${name}`);
|
|
65
|
+
}
|
|
66
|
+
parts.push(`${name}="${escapeHtmlAttribute(String(value))}"`);
|
|
67
|
+
}
|
|
68
|
+
return parts.join(' ');
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=sign-in.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign-in.js","sourceRoot":"","sources":["../src/sign-in.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maronn-openid-connect/google-login",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Sign in with Google (Google Identity Services, redirect mode) as an upstream login method for maronn-openid-connect providers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./sign-in": {
|
|
17
|
+
"types": "./dist/sign-in.d.ts",
|
|
18
|
+
"import": "./dist/sign-in.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"openid-connect",
|
|
31
|
+
"oauth2.1",
|
|
32
|
+
"google",
|
|
33
|
+
"sign-in-with-google",
|
|
34
|
+
"google-identity-services"
|
|
35
|
+
],
|
|
36
|
+
"author": "maronnjapan",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/maronnjapan/maronn-openid-connect.git",
|
|
41
|
+
"directory": "packages/google-login"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/maronnjapan/maronn-openid-connect/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/maronnjapan/maronn-openid-connect/tree/main/packages/google-login#readme",
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@maronn-openid-connect/core": ">=0.4.0 <1.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^26.2.0",
|
|
52
|
+
"typescript": "^7.0.2",
|
|
53
|
+
"vitest": "^4.1.11",
|
|
54
|
+
"@maronn-openid-connect/core": "0.4.0"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"google-auth-library": "^11.1.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:ci": "vitest run",
|
|
64
|
+
"test:watch": "vitest"
|
|
65
|
+
}
|
|
66
|
+
}
|