@h-ai/iam 0.1.0-alpha.67 → 0.1.0-alpha.69
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/README.en.md +55 -30
- package/README.ja.md +56 -31
- package/README.md +57 -32
- package/dist/browser.d.ts +2320 -0
- package/dist/browser.js +3 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-VCE74BFW.js +243 -0
- package/dist/chunk-VCE74BFW.js.map +1 -0
- package/dist/index.d.ts +19 -1623
- package/dist/index.js +789 -1173
- package/dist/index.js.map +1 -1
- package/package.json +13 -10
package/README.en.md
CHANGED
|
@@ -31,7 +31,7 @@ pnpm add @h-ai/iam
|
|
|
31
31
|
### Dependencies
|
|
32
32
|
|
|
33
33
|
- `@h-ai/reldb`: persistence for users/roles/permissions; **initialize before iam.init()**.
|
|
34
|
-
- `@h-ai/cache`: sessions/OTP/
|
|
34
|
+
- `@h-ai/cache`: sessions/OTP/one-time tickets; **initialize before iam.init()**.
|
|
35
35
|
- `@h-ai/crypto`: password hashing, used internally and initialized automatically.
|
|
36
36
|
- `@h-ai/audit`: audit records for key RBAC operations.
|
|
37
37
|
|
|
@@ -62,7 +62,7 @@ ldap:
|
|
|
62
62
|
connectTimeout: 5000
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
`ldaps://` uses TLS; `ldap://` with `useTls: true` uses StartTLS. For Active Directory use `searchFilter: "(sAMAccountName={{username}})"` and `usernameAttribute: sAMAccountName`; the username placeholder is escaped before searching. First login synchronizes a local user and assigns the default role; disabled accounts and lockouts still apply.
|
|
65
|
+
`ldaps://` uses TLS; `ldap://` with `useTls: true` uses StartTLS. For Active Directory use `searchFilter: "(sAMAccountName={{username}})"` and `usernameAttribute: sAMAccountName`; the username placeholder is escaped before searching. First login synchronizes a local user and assigns the default role; disabled accounts and lockouts still apply. `ldapSyncUser: false` requires an existing enabled local user with a database credential generation; no virtual DN user is created. You may inject a `ldapClientFactory` returning `bind/search/unbind` methods with HaiResult results. An injected factory does not require ldapts.
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
68
|
import type { IamConfigSettingsInput } from '@h-ai/iam'
|
|
@@ -149,19 +149,19 @@ import { mailService, smsService } from './notifications.js'
|
|
|
149
149
|
// 调用方须检查服务投递结果,失败时拒绝 Promise,不能吞掉错误。
|
|
150
150
|
const initialized = await iam.init({
|
|
151
151
|
otp: { email: true, sms: true },
|
|
152
|
-
onOtpSendEmail: async (email, code) => {
|
|
153
|
-
await mailService.send({ to: email, subject: 'Verification code', text: code })
|
|
152
|
+
onOtpSendEmail: async (email, code, context) => {
|
|
153
|
+
await mailService.send({ to: email, subject: 'Verification code', text: code, idempotencyKey: context.idempotencyKey })
|
|
154
154
|
},
|
|
155
|
-
onOtpSendSms: async (phone, code) => {
|
|
156
|
-
await smsService.send({ to: phone, code })
|
|
155
|
+
onOtpSendSms: async (phone, code, context) => {
|
|
156
|
+
await smsService.send({ to: phone, code, idempotencyKey: context.idempotencyKey })
|
|
157
157
|
},
|
|
158
158
|
passwordReset: { mode: 'email' },
|
|
159
|
-
onPasswordResetRequest: async (user, token,
|
|
159
|
+
onPasswordResetRequest: async (user, token, context) => {
|
|
160
160
|
if (!user.email)
|
|
161
161
|
throw new Error('Reset recipient unavailable')
|
|
162
162
|
const url = new URL('https://app.example.com/auth/reset-password')
|
|
163
163
|
url.searchParams.set('token', token)
|
|
164
|
-
await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt })
|
|
164
|
+
await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt: context.expiresAt, idempotencyKey: context.idempotencyKey })
|
|
165
165
|
},
|
|
166
166
|
})
|
|
167
167
|
if (!initialized.success)
|
|
@@ -234,14 +234,14 @@ Before initialization, every subsystem method returns `NOT_INITIALIZED` through
|
|
|
234
234
|
|
|
235
235
|
#### Data storage
|
|
236
236
|
|
|
237
|
-
| Data
|
|
238
|
-
|
|
|
239
|
-
| Users, roles, permissions, associations, API keys
|
|
240
|
-
| Sessions (accessToken → Session)
|
|
241
|
-
|
|
|
242
|
-
| OTP codes
|
|
243
|
-
| Password reset tokens
|
|
244
|
-
| API Key
|
|
237
|
+
| Data | Storage | Description |
|
|
238
|
+
| ------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| Users, roles, permissions, associations, API keys | DB (5 base tables, 1 optional API Key table) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
|
|
240
|
+
| Sessions (accessToken → Session) | Cache | TTL, optional sliding renewal |
|
|
241
|
+
| Refresh state | Redis user partition | Atomic with session/index |
|
|
242
|
+
| OTP codes | Cache | TTL + attempt limit |
|
|
243
|
+
| Password reset tokens | User database row | SM3 hash, DB expiry, attempts and conditional password commit |
|
|
244
|
+
| API Key | DB | `hai_iam_api_keys`; store hashes only, never plaintext; prefix narrows candidates |
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
@@ -278,9 +278,10 @@ login(credentials)
|
|
|
278
278
|
```
|
|
279
279
|
verifyToken(accessToken)
|
|
280
280
|
→ session.get(accessToken) → cache lookup
|
|
281
|
+
→ Read the persistent user and validate enabled status and the session-bound credentialVersion
|
|
281
282
|
→ Extend TTL automatically when sliding renewal is enabled
|
|
282
|
-
→ Read current user status, role JOIN and
|
|
283
|
-
→ Reject disabled
|
|
283
|
+
→ Read the current user again, recheck enabled status and credentialVersion, then read the role JOIN and batched permissions (skip permissions for no roles)
|
|
284
|
+
→ Reject any persistent read failure, missing/disabled user or credential-version mismatch; intersect API Key scopes again
|
|
284
285
|
→ Return current Session (userId, roles, permissions)
|
|
285
286
|
```
|
|
286
287
|
|
|
@@ -323,6 +324,8 @@ async function registerWithSession(username: string, password: string) {
|
|
|
323
324
|
- **Independent strategy toggles**: `login.password` / `login.otp` / `login.ldap` / `login.apikey`.
|
|
324
325
|
- **OTP security**: rejection sampling avoids modulo bias, constant-time comparison mitigates timing attacks, and `resendInterval` limits frequency.
|
|
325
326
|
|
|
327
|
+
`iam.auth.logout(accessToken)` uses the complete opaque token to revoke its access and refresh credentials in one user-partition CAS, including when access has expired. Invalid or absent tokens are idempotent successes. Cache read/commit failures return failure and can be retried with the same token after recovery. The HTTP endpoint revokes only a possessed token, accepts no userId target and does not require a still-authenticated access token. Callers must inspect `success`; clearing local cookies or secure storage is not confirmation of server revocation and failures must be shown.
|
|
328
|
+
|
|
326
329
|
---
|
|
327
330
|
|
|
328
331
|
#### Sessions (session) — `iam.session`
|
|
@@ -339,21 +342,19 @@ async function registerWithSession(username: string, password: string) {
|
|
|
339
342
|
|
|
340
343
|
```
|
|
341
344
|
refresh(refreshToken)
|
|
342
|
-
→ Read
|
|
343
|
-
→
|
|
344
|
-
→
|
|
345
|
-
→
|
|
346
|
-
→ Create a new session with the old user context
|
|
347
|
-
→ Return new TokenPair
|
|
345
|
+
→ Read the user partition and session version; validate authorization
|
|
346
|
+
→ Build candidate tokens without publishing them
|
|
347
|
+
→ Single-key CAS rechecks version/refresh and atomically replaces the old session
|
|
348
|
+
→ Return the unique successor only after commit
|
|
348
349
|
```
|
|
349
350
|
|
|
350
351
|
**Single-device login:**
|
|
351
352
|
|
|
352
353
|
```
|
|
353
354
|
create(options) [singleDevice=true]
|
|
354
|
-
→
|
|
355
|
-
→
|
|
356
|
-
→
|
|
355
|
+
→ Build candidate accessToken + refreshToken
|
|
356
|
+
→ Single-key CAS replaces all old sessions and their index together
|
|
357
|
+
→ Failed commits preserve the previous session
|
|
357
358
|
```
|
|
358
359
|
|
|
359
360
|
##### Usage
|
|
@@ -398,6 +399,14 @@ async function forceSignOut(userId: string) {
|
|
|
398
399
|
- **Refresh rotation**: old refreshToken expires after one use; replay detection indicates token leakage.
|
|
399
400
|
- **Token strength**: 256-bit cryptographic randomness (`crypto.getRandomValues(new Uint8Array(32))`), base64url encoded.
|
|
400
401
|
|
|
402
|
+
`create({ maxAge })` overrides the configured default with a positive safe integer in seconds, persisted as `Session.maxAge`. Issuance and refresh return that duration in `expiresIn`. Sliding renewal uses this duration and returns committed `lastActiveAt/expiresAt`; delayed requests cannot shorten a newer renewal. Explicit `update` applies valid date patches and creates a new content version. Clients schedule refresh from the returned `expiresIn` rather than a hardcoded default.
|
|
403
|
+
|
|
404
|
+
Access and refresh expire independently. The user partition remains until the latest live access/refresh deadline, while access authentication checks only its own `expiresAt`. An expired access token can still be rotated with a live refresh token. Each rotation starts a new `refreshTokenMaxAge` period and invalidates both old credentials. Access deletion, logoutAll and single-device replacement also revoke refresh-only records; revokeRefresh preserves a still-live access token. Refresh routes validate refresh credentials without requiring a live access token first.
|
|
405
|
+
|
|
406
|
+
Session cache read/commit failures return failed HaiResult values, never absence/expiry or cleanup compensation. Sessions and their index share one document, so batch updates no longer use per-token get/ttl calls or a separate index; cleanup uses confirmed absolute deadlines only. After recovery, re-read and retry updates/logout; logoutAll still covers every original record. Malformed state fails closed and is preserved for operator repair instead of being overwritten with an empty document.
|
|
407
|
+
|
|
408
|
+
Session.credentialVersion binds the database credential generation at creation. Only trusted authenticated strategies supply CreateSessionOptions.expectedCredentialVersion, and stale generations fail. Low-level create without that option still reads a real enabled local user and binds its current generation; it cannot create an authenticated identity without a database record.
|
|
409
|
+
|
|
401
410
|
---
|
|
402
411
|
|
|
403
412
|
#### Authorization (authz) — `iam.authz`
|
|
@@ -487,7 +496,7 @@ async function currentAuthorization(userId: string) {
|
|
|
487
496
|
- **Wildcard matching**: `admin:*` matches `admin:read`, `admin:write:detail`, etc.; one `*` matches everything after the colon.
|
|
488
497
|
- **Super-admin role**: `rbac.superAdminRole` (default `super_admin`) automatically grants all permissions.
|
|
489
498
|
- **Transactions**: `deleteRole` / `deletePermission` clear associations before deleting entities in a DB transaction.
|
|
490
|
-
- **Session synchronization**: cache updates are best-effort. Protected requests use `iam.auth.verifyToken` to
|
|
499
|
+
- **Session synchronization**: cache updates are best-effort. Protected requests use `iam.auth.verifyToken` to validate the session-bound persistent credential version, then recheck current user status and credential version and read fresh authorization. Failure at either read rejects authentication. `iam.session.get/verifyToken` also checks persistent user enabled status and `credentialVersion`, but its role/permission fields remain a session snapshot and do not replace current authorization. `iam.authz.getUserAuthorization(userId)` shares the role JOIN and batched permission query.
|
|
491
500
|
- **Seed data**: `seedDefaultData: true` idempotently creates admin/user/guest roles and basic permissions.
|
|
492
501
|
|
|
493
502
|
---
|
|
@@ -749,6 +758,8 @@ await apiClient.iam.auth.changePassword({
|
|
|
749
758
|
})
|
|
750
759
|
```
|
|
751
760
|
|
|
761
|
+
The browser entry exports configuration schemas, types and HaiIamError, without the server `iam` instance. Authenticate through the HTTP/typed client.
|
|
762
|
+
|
|
752
763
|
### Password reset
|
|
753
764
|
|
|
754
765
|
Password reset requires `passwordReset.enabled` and a working delivery callback/Reach template. Success does not prove that the account exists or the recipient received a message. Handle delivery/internal failures through HaiResult. Reset tokens expire, limit attempts, and permit one successful consumption; success revokes old sessions. Administrator resets do not require the old password, but applications must authorize the administrator.
|
|
@@ -772,6 +783,8 @@ async function resetByAdministrator(userId: string, newPassword: string) {
|
|
|
772
783
|
}
|
|
773
784
|
```
|
|
774
785
|
|
|
786
|
+
Cache cleanup may return a failure after the new password and credential generation have committed; they are not rolled back. Sign in again instead of automatically retrying with old credentials.
|
|
787
|
+
|
|
775
788
|
### One-time tickets — `iam.ticket`
|
|
776
789
|
|
|
777
790
|
Short-lived capability tickets for channels that cannot carry Bearer tokens, such as WebSocket URLs. An authenticated HTTP request issues a ticket bound to a subject, purpose, and operation. The server **atomically consumes** it on first validation, making it valid only once.
|
|
@@ -807,6 +820,8 @@ Features: cryptographically secure randomness, TTL, atomic single use (only one
|
|
|
807
820
|
|
|
808
821
|
### Configuration driven authentication
|
|
809
822
|
|
|
823
|
+
Password login submits only `identifier` and `password`; `AuthPanel` maps the `LoginForm` field `username` to `identifier`. Server settings `session.maxAge` / `session.refreshTokenMaxAge` control session lifetimes. The login form has no per-login persistent-session option.
|
|
824
|
+
|
|
810
825
|
`iam.publicConfig` and the client's `client.iam.auth.publicConfig()` HTTP contract expose a safe configuration whitelist and only advertise configured, available methods. `login.password/otp/ldap`, `otp.email/sms`, `register.enabled/defaultEnabled`, `passwordReset.enabled/mode/resetUrl` and agreement display flags are independent. `ui` configures card/split layout, title, subtitle, brand text, Logo and illustration URLs. Browser links only accept HTTP(S). LDAP is optional and disabled by default; install ldapts only for the default client, or inject a custom factory.
|
|
811
826
|
|
|
812
827
|
When using optional Reach, initialize it after reldb/cache and call `iam.init({ ...settings, reach })`. Standard `otp_email`, `otp_sms`, `password_reset` and `password_reset_sms` templates supply notification callbacks. Explicit callbacks override these defaults. Configure a real SMTP, SMS or HTTP provider for delivery; Console records metadata only. Delivery failures return errors.
|
|
@@ -815,7 +830,7 @@ SvelteKit routes export handlers from `kit.auth.createApi(iam)`; use `kit.auth.p
|
|
|
815
830
|
|
|
816
831
|
Agreements open in a Modal without losing form input. URL pages must allow iframe embedding; scripts are disabled and a new-window fallback is available. LoginForm/RegisterForm expose `header/extraFields/footer` snippets. AuthPanel forwards `loginHeader/loginFields/loginFooter` and `registerHeader/registerFields/registerFooter`; application state and actions handle additional fields. See [UI usage](../ui/README.en.md#configuration-driven-authentication).
|
|
817
832
|
|
|
818
|
-
All nodes
|
|
833
|
+
All nodes share the same database and Redis; Memory is for single-process tests. OTP code, attempts and cooldown share one identity key with CAS publication and consumption. Conflicts reject stale generations and consumption preserves cooldown. Password reset hash, expiry, attempts and resetVersion live in the user database row. Policy failures count, and one conditional SQL statement changes the password, consumes the token and advances credentialVersion. New tokens, later attempts, disablement or expiry reject delayed commits. Requesting a reset only advances resetVersion; successful password changes advance credentialVersion. Authentication captures the verified credential generation; session creation, authentication and refresh check the current database generation, so failed cache cleanup cannot authorize old sessions. Notification callbacks run after publication and receive { expiresAt, idempotencyKey } as their third argument. Applications and Reach providers must durably deduplicate that key. Exceptions/timeouts mean delivery may have succeeded: the result is unknown, with no automatic resend or rollback. Exactly-once external effects require receiver-side idempotency.
|
|
819
834
|
|
|
820
835
|
Standard endpoints: `GET /auth/config`, `POST /auth/login/ldap`, `POST /auth/password-reset/request`, `POST /auth/password-reset/confirm`. Security errors: `SECURITY_BUSY (hai:iam:023)` and `PASSWORD_RESET_DISABLED (hai:iam:024)`. Declare optional fields such as `ldap` in YAML before overriding them, or replace the module configuration with a JSON value in `HAI_IAM`. Existing fields support overrides such as `HAI_IAM_LOGIN_OTP`.
|
|
821
836
|
|
|
@@ -863,10 +878,20 @@ if (!result.success) {
|
|
|
863
878
|
|
|
864
879
|
Role lists aggregate actual member counts for the current page with one `iam.authz.getRoleUserCounts(roleIds)` call, returning `HaiResult<Map<string, number>>`. Unassigned roles get 0; failed queries show loading errors rather than fabricated zero counts.
|
|
865
880
|
|
|
881
|
+
Sessions and refresh consumption share one versioned `hai:iam:sessions:{SHA256(userId)}` document, replacing separate mappings/indexes. Bearer tokens contain an untrusted routing hint plus 256 random bits; clients must treat them as opaque strings. Rotation checks `Session.version` at commit. Memory is for single-process tests; production nodes share Redis, including Cluster. Conflicts retry from the latest state and return CONFLICT when exhausted; one refresh cannot publish two successors. An ambiguous lost response requires failing closed and signing in again, without assuming the old token remains valid. All records remain covered by the same user index.
|
|
882
|
+
|
|
883
|
+
`iam.session.update(token, patch, expectedVersion)` requires the Session.version from the read. Stale versions return CONFLICT; revoked tokens return SESSION_NOT_FOUND. Timestamp-only sliding updates keep the content version, allowing normal parallel requests. Batch permission updates bind the initial token/version set and never apply delayed snapshots to later sign-ins; newer content versions require re-reading authorization before retry.
|
|
884
|
+
|
|
866
885
|
## Development and testing
|
|
867
886
|
|
|
887
|
+
Public examples import from `@h-ai/iam` and distinguish defaultable `IamConfigInput` from parsed `IamConfig`. Initialize server dependencies, check `HaiResult.success`, update sessions with their observed `version`, and reuse persistent notification idempotency keys. Strategy/repository examples marked as internal context do not define application deep imports. The checker verifies types only; Redis/database, LDAP and delivery behavior still require their integration tests. See the [JSDoc conventions (Chinese)](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证).
|
|
888
|
+
|
|
868
889
|
```bash
|
|
869
|
-
pnpm
|
|
890
|
+
pnpm --filter @h-ai/iam typecheck
|
|
891
|
+
pnpm --filter @h-ai/iam lint
|
|
892
|
+
pnpm --filter @h-ai/iam build
|
|
893
|
+
pnpm --filter @h-ai/iam test
|
|
894
|
+
node scripts/check-module-jsdoc.mjs --package iam
|
|
870
895
|
```
|
|
871
896
|
|
|
872
897
|
## License
|
package/README.ja.md
CHANGED
|
@@ -31,7 +31,7 @@ pnpm add @h-ai/iam
|
|
|
31
31
|
### 依存関係
|
|
32
32
|
|
|
33
33
|
- `@h-ai/reldb`:ユーザー・ロール・権限の永続化。**iam.init() より先に初期化**。
|
|
34
|
-
- `@h-ai/cache`:セッション・OTP
|
|
34
|
+
- `@h-ai/cache`:セッション・OTP・一度限りのチケット。**iam.init() より先に初期化**。
|
|
35
35
|
- `@h-ai/crypto`:内部で使用するパスワードハッシュ。自動初期化。
|
|
36
36
|
- `@h-ai/audit`:主要な RBAC 操作の監査記録。
|
|
37
37
|
|
|
@@ -62,7 +62,7 @@ ldap:
|
|
|
62
62
|
connectTimeout: 5000
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
`ldaps://` は TLS、`ldap://` と `useTls: true` は StartTLS を使用します。Active Directory では `searchFilter: "(sAMAccountName={{username}})"` と `usernameAttribute: sAMAccountName` を指定できます。検索前に入力をエスケープします。初回ログインはローカルユーザーを同期して既定ロールを割り当て、無効化とロックも適用します。`ldapSyncUser: false`
|
|
65
|
+
`ldaps://` は TLS、`ldap://` と `useTls: true` は StartTLS を使用します。Active Directory では `searchFilter: "(sAMAccountName={{username}})"` と `usernameAttribute: sAMAccountName` を指定できます。検索前に入力をエスケープします。初回ログインはローカルユーザーを同期して既定ロールを割り当て、無効化とロックも適用します。`ldapSyncUser: false` は DB 資格情報世代を持つ有効なローカルユーザーを必要とし、仮想 DN ユーザーは作りません。 `bind/search/unbind` を HaiResult で返す `ldapClientFactory` を注入できます。独自ファクトリでは ldapts は不要です。
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
68
|
import type { IamConfigSettingsInput } from '@h-ai/iam'
|
|
@@ -149,19 +149,19 @@ import { mailService, smsService } from './notifications.js'
|
|
|
149
149
|
// 调用方须检查服务投递结果,失败时拒绝 Promise,不能吞掉错误。
|
|
150
150
|
const initialized = await iam.init({
|
|
151
151
|
otp: { email: true, sms: true },
|
|
152
|
-
onOtpSendEmail: async (email, code) => {
|
|
153
|
-
await mailService.send({ to: email, subject: 'Verification code', text: code })
|
|
152
|
+
onOtpSendEmail: async (email, code, context) => {
|
|
153
|
+
await mailService.send({ to: email, subject: 'Verification code', text: code, idempotencyKey: context.idempotencyKey })
|
|
154
154
|
},
|
|
155
|
-
onOtpSendSms: async (phone, code) => {
|
|
156
|
-
await smsService.send({ to: phone, code })
|
|
155
|
+
onOtpSendSms: async (phone, code, context) => {
|
|
156
|
+
await smsService.send({ to: phone, code, idempotencyKey: context.idempotencyKey })
|
|
157
157
|
},
|
|
158
158
|
passwordReset: { mode: 'email' },
|
|
159
|
-
onPasswordResetRequest: async (user, token,
|
|
159
|
+
onPasswordResetRequest: async (user, token, context) => {
|
|
160
160
|
if (!user.email)
|
|
161
161
|
throw new Error('Reset recipient unavailable')
|
|
162
162
|
const url = new URL('https://app.example.com/auth/reset-password')
|
|
163
163
|
url.searchParams.set('token', token)
|
|
164
|
-
await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt })
|
|
164
|
+
await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt: context.expiresAt, idempotencyKey: context.idempotencyKey })
|
|
165
165
|
},
|
|
166
166
|
})
|
|
167
167
|
if (!initialized.success)
|
|
@@ -234,14 +234,14 @@ iam.init()
|
|
|
234
234
|
|
|
235
235
|
#### データの保存先
|
|
236
236
|
|
|
237
|
-
| データ種別
|
|
238
|
-
|
|
|
239
|
-
| ユーザー、ロール、権限、関連付け、API Key
|
|
240
|
-
| セッション(accessToken → Session)
|
|
241
|
-
|
|
|
242
|
-
| OTP コード
|
|
243
|
-
| パスワードリセット Token
|
|
244
|
-
| API Key
|
|
237
|
+
| データ種別 | 保存先 | 説明 |
|
|
238
|
+
| ----------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| ユーザー、ロール、権限、関連付け、API Key | DB(基本 5、任意の API Key 1 テーブル) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
|
|
240
|
+
| セッション(accessToken → Session) | キャッシュ | TTL 付き、任意の自動延長 |
|
|
241
|
+
| Refresh 消費状態 | Redis ユーザー分区 | セッション・索引と単一キー CAS で更新 |
|
|
242
|
+
| OTP コード | キャッシュ | TTL + 試行回数制限 |
|
|
243
|
+
| パスワードリセット Token | ユーザー DB 行 | SM3 hash・DB 期限・試行回数・条件付き改密 |
|
|
244
|
+
| API Key | DB | `hai_iam_api_keys`。平文は保存せず hash のみ。接頭辞で候補検索 |
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
@@ -278,9 +278,10 @@ login(credentials)
|
|
|
278
278
|
```
|
|
279
279
|
verifyToken(accessToken)
|
|
280
280
|
→ session.get(accessToken) → キャッシュ検索
|
|
281
|
+
→ 永続ユーザーの有効状態とセッションに紐付く credentialVersion を検証
|
|
281
282
|
→ 自動延長モードなら TTL を延長
|
|
282
|
-
→
|
|
283
|
-
→
|
|
283
|
+
→ 現在のユーザーを再読込し、有効状態と credentialVersion を再確認して、ロール JOIN・権限一括検索(ロールなしなら権限検索を省略)
|
|
284
|
+
→ 永続データの読取失敗、ユーザー不在・無効、資格情報の世代不一致は拒否し、API Key scope を再適用
|
|
284
285
|
→ 現在の Session を返す(userId、roles、permissions)
|
|
285
286
|
```
|
|
286
287
|
|
|
@@ -323,6 +324,8 @@ async function registerWithSession(username: string, password: string) {
|
|
|
323
324
|
- **方式ごとの有効化**:`login.password` / `login.otp` / `login.ldap` / `login.apikey` で制御。
|
|
324
325
|
- **OTP の安全性**:rejection sampling で剰余偏りを除去、定時間比較でタイミング攻撃を防ぎ、`resendInterval` で頻度制限。
|
|
325
326
|
|
|
327
|
+
`iam.auth.logout(accessToken)` は完全な不透明 Token を使い、ユーザー文書の一回の CAS で access と refresh を同時に取り消します。access 期限後も実行します。不正・不存在 Token は冪等成功ですが、キャッシュの読取・確定障害は失敗で返し、復旧後に同じ Token で再試行できます。HTTP 入口は保持している Token だけを取り消し、userId を対象にせず、有効な access 認証を前提にしません。呼出側は `success` を確認し、Cookie・安全ストレージのローカル消去だけでサーバー失効を成功と扱わず、失敗を表示します。
|
|
328
|
+
|
|
326
329
|
---
|
|
327
330
|
|
|
328
331
|
#### セッション(session)— `iam.session`
|
|
@@ -339,21 +342,19 @@ async function registerWithSession(username: string, password: string) {
|
|
|
339
342
|
|
|
340
343
|
```
|
|
341
344
|
refresh(refreshToken)
|
|
342
|
-
→
|
|
343
|
-
→
|
|
344
|
-
→
|
|
345
|
-
→
|
|
346
|
-
→ 旧ユーザー context で新 session を作成
|
|
347
|
-
→ 新 TokenPair を返す
|
|
345
|
+
→ ユーザー分区と会話バージョンを読み、認可を検証
|
|
346
|
+
→ 未公開の新 Token 候補を生成
|
|
347
|
+
→ 単一キー CAS で version/refresh を再検証し、旧会話と原子的に置換
|
|
348
|
+
→ コミット後のみ唯一の後続 TokenPair を返す
|
|
348
349
|
```
|
|
349
350
|
|
|
350
351
|
**単一端末のログイン:**
|
|
351
352
|
|
|
352
353
|
```
|
|
353
354
|
create(options) [singleDevice=true]
|
|
354
|
-
→
|
|
355
|
-
→
|
|
356
|
-
→
|
|
355
|
+
→ 新 accessToken + refreshToken の候補を生成
|
|
356
|
+
→ 単一キー CAS で既存会話と索引を同時に置換
|
|
357
|
+
→ コミット失敗時は旧会話を維持
|
|
357
358
|
```
|
|
358
359
|
|
|
359
360
|
##### 使用方法
|
|
@@ -395,9 +396,17 @@ async function forceSignOut(userId: string) {
|
|
|
395
396
|
|
|
396
397
|
- **キャッシュのみの保存**:Memory は再起動時に消失します。共有 Redis のセッションは IAM サービス再起動後も TTL または失効まで保持します。
|
|
397
398
|
- **自動延長**:`sliding: true` なら各 `get()` で TTL を延長。
|
|
398
|
-
- **Refresh Rotation
|
|
399
|
+
- **Refresh Rotation**:refresh は一度だけ消費します。重複は正常な並行処理や応答喪失でも発生し、それだけで漏洩と判断しません。
|
|
399
400
|
- **Token 強度**:256-bit の暗号学的乱数(`crypto.getRandomValues(new Uint8Array(32))`)、base64url 形式。
|
|
400
401
|
|
|
402
|
+
`create({ maxAge })` は設定の既定値を正の安全な整数秒で上書きし、`Session.maxAge` に保存します。発行と更新の `expiresIn` はこの値を使います。自動延長は同じ期間を使い、確定済みの `lastActiveAt/expiresAt` を返し、遅いリクエストが新しい延長を短縮しません。明示的な `update` は有効な日時を反映して内容版を更新します。クライアントは返された `expiresIn` から更新時刻を決めます。
|
|
403
|
+
|
|
404
|
+
access と refresh は独立して期限切れになります。ユーザー文書は両期限の最大まで保持しますが、access 認証は自身の expiresAt のみを確認します。access 期限後も有効な refresh で更新でき、更新ごとに新しい refreshTokenMaxAge を開始して旧資格情報を失効させます。access 削除・全ログアウト・単一端末置換は更新待ち記録も取り消し、revokeRefresh は有効な access を残します。更新経路で旧 access の認証成功を前提にしません。
|
|
405
|
+
|
|
406
|
+
セッションの読取・確定障害は失敗 HaiResult として返し、不存在や期限切れと解釈せず補償削除もしません。索引と会話は同じ文書で保持し、個別 get/ttl や二重索引を廃止します。確定した期限だけで整理し、復旧後の再読取・全ログアウトは元の全記録を対象にします。破損状態は保持して認証を拒否し、空文書で上書きせず運用者が修復します。
|
|
407
|
+
|
|
408
|
+
Session.credentialVersion は作成時の DB 資格情報世代に結び付けます。CreateSessionOptions.expectedCredentialVersion は検証済みの信頼する戦略だけが指定し、旧世代は拒否します。省略した低レベル create でも実在する有効なローカルユーザーを DB から読み、現世代に結び付けるため、DB 記録のない認証済み ID は作れません。
|
|
409
|
+
|
|
401
410
|
---
|
|
402
411
|
|
|
403
412
|
#### 認可(authz)— `iam.authz`
|
|
@@ -487,7 +496,7 @@ async function currentAuthorization(userId: string) {
|
|
|
487
496
|
- **ワイルドカード**:`admin:*` は `admin:read`、`admin:write:detail` などに一致。1 つの `*` がコロン以降全体に一致します。
|
|
488
497
|
- **最高管理者**:`rbac.superAdminRole`(既定 `super_admin`)はすべての権限を持ちます。
|
|
489
498
|
- **トランザクション**:`deleteRole` / `deletePermission` は DB トランザクションで関連を削除してから本体を削除。
|
|
490
|
-
- **セッション同期**:キャッシュ更新は best-effort です。保護されたリクエストは `iam.auth.verifyToken`
|
|
499
|
+
- **セッション同期**:キャッシュ更新は best-effort です。保護されたリクエストは `iam.auth.verifyToken` でセッションに紐付く永続資格情報の世代を検証し、現在のユーザー状態・資格情報の世代を再確認して最新の認可を読みます。いずれかの読取に失敗すれば拒否します。`iam.session.get/verifyToken` も永続ユーザーの有効状態と `credentialVersion` を検証しますが、ロール・権限はセッションのスナップショットであり、現在の認可を代替しません。`iam.authz.getUserAuthorization(userId)` はロール JOIN と権限一括検索を共有します。
|
|
491
500
|
- **初期データ**:`seedDefaultData: true` で admin/user/guest と基本権限を冪等に作成。
|
|
492
501
|
|
|
493
502
|
---
|
|
@@ -749,6 +758,8 @@ await apiClient.iam.auth.changePassword({
|
|
|
749
758
|
})
|
|
750
759
|
```
|
|
751
760
|
|
|
761
|
+
browser 条件の入口は設定 Schema、型、HaiIamError のみを公開し、サーバーの `iam` インスタンスは含みません。認証には HTTP/typed client を使用します。
|
|
762
|
+
|
|
752
763
|
### パスワードリセット
|
|
753
764
|
|
|
754
765
|
リセットには `passwordReset.enabled` と送信コールバック/Reach テンプレートが必要です。成功はアカウントの存在や受信を証明しません。送信・内部処理の HaiResult エラーも処理します。期限/試行回数制限があり、一度だけ成功消費でき、成功時に旧セッションを取り消します。管理者リセットに旧パスワードは不要ですが、アプリで管理権限を確認します。
|
|
@@ -772,6 +783,8 @@ async function resetByAdministrator(userId: string, newPassword: string) {
|
|
|
772
783
|
}
|
|
773
784
|
```
|
|
774
785
|
|
|
786
|
+
キャッシュ削除が失敗を返しても、確定した新パスワードと資格情報世代は巻き戻しません。旧資格情報で自動再試行せず、再ログインしてください。
|
|
787
|
+
|
|
775
788
|
### 一度限りのチケット — `iam.ticket`
|
|
776
789
|
|
|
777
790
|
WebSocket URL など Bearer Token を付けられない経路向けの短期チケットです。認証済み HTTP リクエストで、主体・用途・操作に結び付けて発行します。サーバーは初回検証時に**アトミックに消費**し、一度のみ有効です。
|
|
@@ -807,6 +820,8 @@ async function consumeAudioTicket(ticket: string) {
|
|
|
807
820
|
|
|
808
821
|
### 設定による認証画面
|
|
809
822
|
|
|
823
|
+
パスワードログインの送信項目は `identifier` と `password` のみです。`AuthPanel` は `LoginForm` の `username` を `identifier` に変換します。有効期間はサーバーの `session.maxAge` / `session.refreshTokenMaxAge` で設定し、フォームにログインごとの永続セッション選択肢はありません。
|
|
824
|
+
|
|
810
825
|
`iam.publicConfig` とクライアントの HTTP `client.iam.auth.publicConfig()` は秘密情報を含まない設定だけを公開し、実際に利用可能な認証方式を表示します。`login.password/otp/ldap`、`otp.email/sms`、`register.enabled/defaultEnabled`、`passwordReset.enabled/mode/resetUrl` と規約表示を個別に設定できます。`ui` はカード/分割レイアウト、タイトル、説明、ブランド、Logo、画像を指定します。LDAP は任意で既定は無効です。既定クライアントを選択する時だけ ldapts を追加するか、独自ファクトリを注入します。
|
|
811
826
|
|
|
812
827
|
任意の Reach を選択した場合、reldb/cache/Reach の初期化後、`iam.init({ ...settings, reach })` を呼び出します。`otp_email`、`otp_sms`、`password_reset`、`password_reset_sms` テンプレートを対応する送信 Provider に接続します。明示的なコールバックは既定動作を上書きします。Console はメタデータだけを記録するため、実際の配送には SMTP、SMS または HTTP サービスが必要です。送信失敗はエラーになります。
|
|
@@ -815,7 +830,7 @@ SvelteKit は `kit.auth.createApi(iam)` と匿名用の `kit.auth.publicPaths`
|
|
|
815
830
|
|
|
816
831
|
規約は入力を保持したまま Modal で開きます。URL のページは iframe 埋め込みを許可する必要があり、スクリプトを無効にし別ウィンドウ表示も用意します。LoginForm/RegisterForm は `header/extraFields/footer`、AuthPanel は `loginHeader/loginFields/loginFooter` と `registerHeader/registerFields/registerFooter` を提供します。追加フィールドはアプリの状態と actions で処理します。[UI の使い方](../ui/README.ja.md) を参照してください。
|
|
817
832
|
|
|
818
|
-
|
|
833
|
+
全ノードで同じ DB と Redis を共有します。Memory は単一プロセスのテスト用です。OTP のコード・試行回数・再送間隔は単一キーの CAS で公開・消費し、旧世代の競合を拒否します。消費後も再送制限を保持します。リセットの hash・期限・試行回数・resetVersion はユーザーの DB 行に保存し、ポリシー違反も計数します。最終 SQL が条件を検証してパスワード変更・Token 消費・credentialVersion 更新を同時に行い、新 Token・後続試行・無効化・期限切れ後の遅延書込を拒否します。リセット要求は resetVersion のみ進め、改密成功時に credentialVersion を進めます。認証で取得した資格情報世代を保持し、セッション作成・認証・更新で DB の現世代を照合するため、キャッシュ削除失敗でも旧会話を許可しません。通知は公開成功後に実行し、第3引数 { expiresAt, idempotencyKey } の安定キーをアプリと Reach Provider が永続的に重複排除します。例外・タイムアウトは送達済みの可能性があるため結果不明とし、自動再送や公開済み資格情報の巻き戻しはしません。外部副作用の厳密な一回実行には受信側の冪等性が必要です。
|
|
819
834
|
|
|
820
835
|
標準 HTTP 入口は `GET /auth/config`、`POST /auth/login/ldap`、`POST /auth/password-reset/request`、`POST /auth/password-reset/confirm` です。セキュリティエラーは `SECURITY_BUSY (hai:iam:023)` と `PASSWORD_RESET_DISABLED (hai:iam:024)` です。`ldap` などの任意項目は YAML に宣言してから環境変数で上書きするか、`HAI_IAM` の JSON 値でモジュール設定全体を置き換えます。既存項目には `HAI_IAM_LOGIN_OTP` などを使用できます。
|
|
821
836
|
|
|
@@ -859,10 +874,20 @@ if (!result.success) {
|
|
|
859
874
|
}
|
|
860
875
|
```
|
|
861
876
|
|
|
877
|
+
会話と refresh 消費状態を `hai:iam:sessions:{SHA256(userId)}` のバージョン付き文書にまとめ、独立した対応キーと索引を廃止します。Bearer Token は認証には使えない分区ヒントと256ビットの乱数を含み、クライアントは不透明な文字列として扱います。更新時は Session.version を再検証します。Memory は単一プロセスのテスト用で、本番は全ノードが Redis(Cluster 対応)を共有します。競合は最新状態から再試行し、上限で CONFLICT を返します。応答喪失で結果が不明なら認証を拒否し再ログインします。すべての会話は同じユーザー索引で管理されます。
|
|
878
|
+
|
|
879
|
+
`iam.session.update(token, patch, expectedVersion)` は読み取った Session.version が必須です。旧版は CONFLICT、失効 Token は SESSION_NOT_FOUND を返し、新しい状態を上書きしません。自動延長の時刻だけの更新は内容版を変更せず、通常の並行リクエストを許容します。権限一括更新は最初の Token/版集合に限定し、後続ログインに古い値を適用しません。
|
|
880
|
+
|
|
862
881
|
## 開発とテスト
|
|
863
882
|
|
|
883
|
+
公開例は `@h-ai/iam` を使用し、既定値を省略できる `IamConfigInput` と解析済み `IamConfig` を区別します。依存を先に初期化し、`HaiResult.success` を確認し、会話更新には読取時の `version`、通知には永続的な冪等キーを使います。「模块内部上下文」の戦略・倉庫例はアプリ向け深い import の契約ではありません。チェッカーは型だけを確認し、Redis/DB・LDAP・配送の動作には対応する統合テストが必要です。[JSDoc 規約](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证)も参照してください。
|
|
884
|
+
|
|
864
885
|
```bash
|
|
865
|
-
pnpm
|
|
886
|
+
pnpm --filter @h-ai/iam typecheck
|
|
887
|
+
pnpm --filter @h-ai/iam lint
|
|
888
|
+
pnpm --filter @h-ai/iam build
|
|
889
|
+
pnpm --filter @h-ai/iam test
|
|
890
|
+
node scripts/check-module-jsdoc.mjs --package iam
|
|
866
891
|
```
|
|
867
892
|
|
|
868
893
|
## ライセンス
|