@sd-angular/core 19.0.35 → 19.0.38

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.
Files changed (82) hide show
  1. package/README.md +2 -4
  2. package/components/index.d.ts +1 -0
  3. package/components/query-builder/src/query-builder.model.d.ts +1 -1
  4. package/components/table/src/components/desktop-cell/desktop-cell.component.d.ts +1 -1
  5. package/components/table/src/table.component.d.ts +1 -1
  6. package/components/timeline/index.d.ts +2 -0
  7. package/components/{history/models/history.model.d.ts → timeline/models/timeline.model.d.ts} +1 -1
  8. package/components/timeline/src/timeline.component.d.ts +7 -0
  9. package/configurations/src/sd-core.configuration.d.ts +0 -1
  10. package/fesm2022/sd-angular-core-components-form-generic.mjs.map +1 -1
  11. package/fesm2022/sd-angular-core-components-query-bar.mjs +8 -8
  12. package/fesm2022/sd-angular-core-components-query-bar.mjs.map +1 -1
  13. package/fesm2022/sd-angular-core-components-query-builder.mjs +2 -2
  14. package/fesm2022/sd-angular-core-components-query-builder.mjs.map +1 -1
  15. package/fesm2022/sd-angular-core-components-tab-router.mjs +11 -5
  16. package/fesm2022/sd-angular-core-components-tab-router.mjs.map +1 -1
  17. package/fesm2022/sd-angular-core-components-table.mjs +110 -39
  18. package/fesm2022/sd-angular-core-components-table.mjs.map +1 -1
  19. package/fesm2022/sd-angular-core-components-timeline.mjs +49 -0
  20. package/fesm2022/sd-angular-core-components-timeline.mjs.map +1 -0
  21. package/fesm2022/sd-angular-core-components-upload-file.mjs.map +1 -1
  22. package/fesm2022/sd-angular-core-components.mjs +1 -0
  23. package/fesm2022/sd-angular-core-components.mjs.map +1 -1
  24. package/fesm2022/sd-angular-core-configurations.mjs.map +1 -1
  25. package/fesm2022/sd-angular-core-forms-autocomplete.mjs +1 -1
  26. package/fesm2022/sd-angular-core-forms-autocomplete.mjs.map +1 -1
  27. package/fesm2022/sd-angular-core-forms-date-range.mjs +1 -1
  28. package/fesm2022/sd-angular-core-forms-date-range.mjs.map +1 -1
  29. package/fesm2022/sd-angular-core-forms-date.mjs +1 -1
  30. package/fesm2022/sd-angular-core-forms-date.mjs.map +1 -1
  31. package/fesm2022/sd-angular-core-forms-datetime.mjs +1 -1
  32. package/fesm2022/sd-angular-core-forms-datetime.mjs.map +1 -1
  33. package/fesm2022/sd-angular-core-forms-inline-text.mjs +1 -1
  34. package/fesm2022/sd-angular-core-forms-inline-text.mjs.map +1 -1
  35. package/fesm2022/sd-angular-core-forms-input-color.mjs +1 -1
  36. package/fesm2022/sd-angular-core-forms-input-color.mjs.map +1 -1
  37. package/fesm2022/sd-angular-core-forms-input-number.mjs +1 -1
  38. package/fesm2022/sd-angular-core-forms-input-number.mjs.map +1 -1
  39. package/fesm2022/sd-angular-core-forms-input.mjs +1 -1
  40. package/fesm2022/sd-angular-core-forms-input.mjs.map +1 -1
  41. package/fesm2022/sd-angular-core-forms-select.mjs +1 -1
  42. package/fesm2022/sd-angular-core-forms-select.mjs.map +1 -1
  43. package/fesm2022/sd-angular-core-i18n.mjs +10 -10
  44. package/fesm2022/sd-angular-core-i18n.mjs.map +1 -1
  45. package/fesm2022/sd-angular-core-modules-auth.mjs +35 -3
  46. package/fesm2022/sd-angular-core-modules-auth.mjs.map +1 -1
  47. package/fesm2022/sd-angular-core-modules-authom.mjs +847 -241
  48. package/fesm2022/sd-angular-core-modules-authom.mjs.map +1 -1
  49. package/fesm2022/sd-angular-core-modules-layout.mjs +4 -4
  50. package/fesm2022/sd-angular-core-modules-layout.mjs.map +1 -1
  51. package/fesm2022/sd-angular-core-services-cache.mjs +1 -1
  52. package/fesm2022/sd-angular-core-services-cache.mjs.map +1 -1
  53. package/fesm2022/sd-angular-core-services-confirm.mjs +2 -2
  54. package/fesm2022/sd-angular-core-services-confirm.mjs.map +1 -1
  55. package/fesm2022/sd-angular-core-services-storage.mjs.map +1 -1
  56. package/i18n/src/en.d.ts +1 -1
  57. package/modules/authom/authom.auth-configuration.d.ts +18 -0
  58. package/modules/authom/authom.configuration.d.ts +158 -2
  59. package/modules/authom/authom.event.d.ts +44 -0
  60. package/modules/authom/authom.guard.d.ts +13 -0
  61. package/modules/authom/authom.interceptor.d.ts +7 -1
  62. package/modules/authom/authom.module.d.ts +35 -5
  63. package/modules/authom/authom.return-url.d.ts +37 -0
  64. package/modules/authom/authom.service.d.ts +109 -15
  65. package/modules/authom/authom.session-channel.d.ts +26 -0
  66. package/modules/authom/index.d.ts +19 -9
  67. package/package.json +17 -19
  68. package/utilities/extensions/src/string.extension.d.ts +6 -2
  69. package/utilities/models/src/maybe-async.model.d.ts +1 -1
  70. package/components/base/index.d.ts +0 -1
  71. package/components/base/src/base-secure.component.d.ts +0 -8
  72. package/components/history/index.d.ts +0 -1
  73. package/components/history/src/history.component.d.ts +0 -7
  74. package/fesm2022/sd-angular-core-components-base.mjs +0 -23
  75. package/fesm2022/sd-angular-core-components-base.mjs.map +0 -1
  76. package/fesm2022/sd-angular-core-components-history.mjs +0 -51
  77. package/fesm2022/sd-angular-core-components-history.mjs.map +0 -1
  78. package/fesm2022/sd-angular-core-services-license.mjs +0 -97
  79. package/fesm2022/sd-angular-core-services-license.mjs.map +0 -1
  80. package/services/license/index.d.ts +0 -1
  81. package/services/license/src/license.service.d.ts +0 -9
  82. /package/components/{history → timeline}/pipes/view-date.pipe.d.ts +0 -0
@@ -1,251 +1,695 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, PLATFORM_ID, signal, computed, Injectable, provideAppInitializer, makeEnvironmentProviders, APP_INITIALIZER, NgModule } from '@angular/core';
2
+ import { InjectionToken, inject, PLATFORM_ID, DestroyRef, signal, computed, Injectable, provideAppInitializer, makeEnvironmentProviders, APP_INITIALIZER, NgModule } from '@angular/core';
3
3
  import { isPlatformBrowser } from '@angular/common';
4
- import { StringUtilities } from '@sdcorejs/utils/fns';
4
+ import { OAuthService, OAuthErrorEvent, provideOAuthClient } from 'angular-oauth2-oidc';
5
+ import { Subject, from, switchMap, catchError, throwError, map, of } from 'rxjs';
6
+ import { HttpErrorResponse } from '@angular/common/http';
7
+ import { toObservable } from '@angular/core/rxjs-interop';
8
+ import { SD_AUTH_CONFIGURATION } from '@sd-angular/core/modules/auth';
5
9
 
6
10
  const SD_AUTHOM_CONFIGURATION = new InjectionToken('sd-authom.configuration');
11
+ /** Scope mặc định. `offline_access` giữ cho token endpoint phát hành refresh_token. */
12
+ const SD_AUTHOM_DEFAULT_SCOPE = 'openid profile email offline_access';
13
+ /** Tỷ lệ vòng đời token mà tại đó refresh chủ động được kích hoạt. */
14
+ const SD_AUTHOM_DEFAULT_TIMEOUT_FACTOR = 0.75;
15
+ /** Timeout mặc định của silent refresh bằng iframe, tính bằng giây. */
16
+ const SD_AUTHOM_DEFAULT_SILENT_REFRESH_TIMEOUT_SECONDS = 20;
7
17
 
8
- const STORAGE_KEY_STATE = 'authom_state';
9
- const STORAGE_KEY_CODE_VERIFIER = 'authom_code_verifier';
10
- const STORAGE_KEY_RETURN_TO = 'authom_return_to';
11
- const DEFAULT_SCOPE = 'openid profile email';
12
- const DEFAULT_REFRESH_THRESHOLD_SECONDS = 30;
13
- const DEFAULT_AUTHORIZE_TIMEOUT_SECONDS = 5;
14
- class SdAuthOmService {
15
- platformId = inject(PLATFORM_ID);
16
- isBrowser = isPlatformBrowser(this.platformId);
17
- accessToken = signal(null);
18
- idTokenClaims = signal(null);
19
- isAuthenticated = computed(() => this.accessToken() !== null);
20
- config;
21
- refreshTimer = null;
22
- getAccessToken() {
23
- return this.accessToken();
18
+ /**
19
+ * Giữ deep link sống sót qua vòng redirect đăng nhập.
20
+ *
21
+ * AuthOM chỉ chấp nhận một `redirect_uri` đã đăng ký (các portal hiện dùng `/callback`), nên một
22
+ * user mở thẳng link sâu mà chưa có token sẽ mất đích đến: guard gọi code flow, cookie SSO của
23
+ * AuthOM trả lời ngay không cần hỏi, và trình duyệt quay về `/callback` rồi bị route mặc định đẩy
24
+ * sang trang chủ.
25
+ *
26
+ * Đích đến đi kèm tham số OIDC `state` (IdP round-trip nguyên vẹn) chứ không nằm trong storage:
27
+ * như vậy nó gắn với đúng một lần đăng nhập, không thể cũ đi và không rò sang điều hướng khác.
28
+ */
29
+ /** Đường dẫn callback mặc định — khôi phục nó chỉ khiến user bị đẩy về trang chủ lần nữa. */
30
+ const DEFAULT_CALLBACK_PATH = '/callback';
31
+ /**
32
+ * Mã hoá trang mà user thực sự muốn vào, để gửi kèm `initCodeFlow`.
33
+ *
34
+ * Trả về `undefined` khi không có gì đáng khôi phục — lúc đó caller khởi động đăng nhập bình thường.
35
+ *
36
+ * Giá trị trả về là chuỗi THÔ, cố ý không percent-encode: client OIDC đã tự
37
+ * `encodeURIComponent(additionalState)` trước khi ghép vào `state`, và chuỗi giải mã của nó chỉ bóc
38
+ * đúng một lớp. Encode thêm ở đây làm giá trị quay về dạng double-encoded (`%252F...`) và
39
+ * `resolveReturnUrl` sẽ loại bỏ nó.
40
+ */
41
+ function captureReturnUrl(requestedUrl, callbackPath = DEFAULT_CALLBACK_PATH) {
42
+ const url = requestedUrl?.trim();
43
+ if (!url || !isRestorableAppUrl(url, callbackPath)) {
44
+ return undefined;
24
45
  }
25
- base64UrlEncode(bytes) {
26
- let binary = '';
27
- for (let i = 0; i < bytes.length; i++)
28
- binary += String.fromCharCode(bytes[i]);
29
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
30
- }
31
- generateCodeVerifier() {
32
- const bytes = new Uint8Array(32);
33
- crypto.getRandomValues(bytes);
34
- return this.base64UrlEncode(bytes);
35
- }
36
- async generateCodeChallenge(verifier) {
37
- return StringUtilities.sha256(verifier);
38
- }
39
- buildAuthorizeUrl(params) {
40
- const search = new URLSearchParams({
41
- response_type: 'code',
42
- client_id: this.config.clientId,
43
- redirect_uri: params.redirectUri,
44
- state: params.state,
45
- code_challenge: params.codeChallenge,
46
- code_challenge_method: 'S256',
47
- scope: this.config.scope || DEFAULT_SCOPE,
48
- });
49
- if (this.config.audience)
50
- search.set('audience', this.config.audience);
51
- if (this.config.organization)
52
- search.set('organization', this.config.organization);
53
- if (params.prompt)
54
- search.set('prompt', params.prompt);
55
- return `https://${this.config.domain}/authorize?${search.toString()}`;
46
+ return url;
47
+ }
48
+ /**
49
+ * Đọc lại giá trị mà `captureReturnUrl` đã gửi đi; trả `null` khi `state` rỗng hoặc không khôi phục được.
50
+ *
51
+ * Một lần `decodeURIComponent` để bóc lớp mã hoá mà client OIDC áp lên phần state của app; lớp ngoài
52
+ * bọc cả tham số đã được chính nó gỡ trước khi giá trị tới đây. Chuỗi không mã hoá sẽ giải mã ra
53
+ * chính nó, nên hàm vẫn đúng trong cả hai trường hợp.
54
+ */
55
+ function resolveReturnUrl(state, callbackPath = DEFAULT_CALLBACK_PATH) {
56
+ const raw = state?.trim();
57
+ if (!raw) {
58
+ return null;
59
+ }
60
+ let url;
61
+ try {
62
+ url = decodeURIComponent(raw);
63
+ }
64
+ catch {
65
+ // why: percent-encoding hỏng — bị can thiệp trên đường truyền hoặc IdP làm méo.
66
+ // Bỏ hẳn thay vì đoán đích đến.
67
+ return null;
68
+ }
69
+ return isRestorableAppUrl(url, callbackPath) ? url : null;
70
+ }
71
+ /**
72
+ * `state` đi vòng qua IdP nên kẻ tấn công có thể tác động; chỉ chấp nhận path nằm trong chính app này.
73
+ *
74
+ * URL tuyệt đối hoặc protocol-relative (`//evil.example`, hoặc `/\evil.example` — trình duyệt tự
75
+ * chuẩn hoá dấu gạch ngược) sẽ biến vòng đăng nhập thành open redirect.
76
+ */
77
+ function isRestorableAppUrl(url, callbackPath = DEFAULT_CALLBACK_PATH) {
78
+ if (!url.startsWith('/') || url.startsWith('//') || url.startsWith('/\\')) {
79
+ return false;
80
+ }
81
+ // why: ký tự điều khiển (CR/LF/NUL/DEL) trong path mở đường cho header hoặc log injection.
82
+ if ([...url].some(character => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) {
83
+ return false;
84
+ }
85
+ return url.split(/[?#]/)[0] !== callbackPath;
86
+ }
87
+
88
+ /**
89
+ * Đồng bộ đăng xuất giữa các tab cùng origin.
90
+ *
91
+ * Phiên AuthOM sống trong bộ nhớ của client OIDC, có `sessionStorage` làm nền — mà `sessionStorage`
92
+ * bị trình duyệt cô lập theo tab. Không có server-side session để thu hồi (AuthOM không công bố
93
+ * `end_session_endpoint`) và cũng không có cơ chế kiểm tra liveness xuyên tab. Hệ quả: đăng xuất ở
94
+ * một tab để các tab còn lại dùng được bình thường cho tới khi access token của chính chúng hết hạn.
95
+ *
96
+ * `BroadcastChannel` không bao giờ gửi message ngược về đối tượng đã post, nên tab kích hoạt đăng
97
+ * xuất không tự xử lý lại sự kiện của chính nó.
98
+ */
99
+ const LOGOUT_MESSAGE_TYPE = 'sd-authom:logout';
100
+ /**
101
+ * Bọc `BroadcastChannel` cho tín hiệu đăng xuất, và tự vô hiệu hoá khi môi trường không hỗ trợ
102
+ * (SSR, trình duyệt cũ, `BroadcastChannel` bị chặn).
103
+ */
104
+ class SdAuthOmSessionChannel {
105
+ #channel;
106
+ constructor(name) {
107
+ this.#channel = SdAuthOmSessionChannel.#create(name);
108
+ }
109
+ /** `true` khi môi trường thực sự hỗ trợ kênh; `false` thì mọi thao tác là no-op. */
110
+ get enabled() {
111
+ return this.#channel !== null;
56
112
  }
57
- getDefaultRedirectUri() {
58
- return this.config.redirectUri || window.location.origin;
113
+ /** Báo cho mọi tab khác rằng phiên vừa kết thúc. */
114
+ notifyLogout() {
115
+ const message = { type: LOGOUT_MESSAGE_TYPE };
116
+ this.#channel?.postMessage(message);
117
+ }
118
+ /** Đăng ký callback chạy khi một tab khác báo đã đăng xuất. */
119
+ onLogout(handler) {
120
+ if (!this.#channel) {
121
+ return;
122
+ }
123
+ this.#channel.onmessage = (event) => {
124
+ if (event.data?.type === LOGOUT_MESSAGE_TYPE) {
125
+ handler();
126
+ }
127
+ };
59
128
  }
60
- getSilentRedirectUri() {
61
- return this.config.silentRefreshRedirectUri || `${window.location.origin}/silent-authom.html`;
129
+ close() {
130
+ this.#channel?.close();
62
131
  }
63
- decodeJwtPayload(token) {
132
+ static #create(name) {
133
+ if (typeof BroadcastChannel === 'undefined') {
134
+ return null;
135
+ }
64
136
  try {
65
- const parts = token.split('.');
66
- if (parts.length !== 3)
67
- return null;
68
- const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
69
- const json = atob(padded + '==='.slice((padded.length + 3) % 4));
70
- return JSON.parse(json);
137
+ return new BroadcastChannel(name);
71
138
  }
72
139
  catch {
140
+ // why: một số trình duyệt ném khi site data bị chặn. Đăng xuất xuyên tab là tính năng
141
+ // phụ trợ, không được phép làm hỏng bootstrap.
73
142
  return null;
74
143
  }
75
144
  }
145
+ }
146
+
147
+ /** Sự kiện của client OIDC được coi là lỗi xác thực ngoài luồng refresh. */
148
+ const AUTH_ERROR_EVENT_TYPES = new Set([
149
+ 'discovery_document_load_error',
150
+ 'discovery_document_validation_error',
151
+ 'jwks_load_error',
152
+ 'invalid_nonce_in_state',
153
+ 'code_error',
154
+ 'token_error',
155
+ 'token_validation_error',
156
+ ]);
157
+ /** Sự kiện của client OIDC báo một lần gia hạn token thất bại. */
158
+ const REFRESH_ERROR_EVENT_TYPES = new Set(['token_refresh_error', 'silent_refresh_error', 'silent_refresh_timeout']);
159
+ /**
160
+ * Client xác thực AuthOM: Authorization Code + PKCE trên discovery document của IdP.
161
+ *
162
+ * Service sở hữu toàn bộ vòng đời phiên — bootstrap, khôi phục deep link, gia hạn token chủ động,
163
+ * phát hiện hết phiên, đăng xuất xuyên tab — nên app chỉ cần khai báo cấu hình tenant.
164
+ */
165
+ class SdAuthOmService {
166
+ #platformId = inject(PLATFORM_ID);
167
+ #isBrowser = isPlatformBrowser(this.#platformId);
168
+ #destroyRef = inject(DestroyRef);
169
+ /**
170
+ * Optional để một app chỉ đăng ký `SdAuthOmInterceptor` mà quên `provideSdAuthOm()` thì bị hỏng
171
+ * đúng luồng đăng nhập, chứ không ném NullInjectorError trên mọi HTTP request.
172
+ */
173
+ #oauth = inject(OAuthService, { optional: true });
174
+ /** Cấu hình tenant đang dùng. `undefined` cho tới khi `init()` chạy xong. */
175
+ config;
176
+ accessToken = signal(null);
177
+ idTokenClaims = signal(null);
178
+ isAuthenticated = computed(() => this.accessToken() !== null);
179
+ /** `true` khi gia hạn đã thất bại VÀ access token cuối cùng cũng đã hết hạn. */
180
+ sessionExpired = signal(false);
181
+ /** Sự kiện `auth_error` gần nhất, hoặc `null` khi lần đăng nhập gần nhất sạch. */
182
+ lastAuthError = signal(null);
183
+ #events = new Subject();
184
+ /** Luồng sự kiện xác thực. Xem `SdAuthOmEventType`. */
185
+ events$ = this.#events.asObservable();
186
+ #channel = null;
187
+ #eventsSubscription = null;
188
+ #expiryCheckTimer = null;
189
+ #inFlightRefresh = null;
190
+ #initialized = false;
191
+ constructor() {
192
+ this.#destroyRef.onDestroy(() => this.#teardown());
193
+ }
194
+ // ---------------------------------------------------------------------------------------------
195
+ // Bootstrap
196
+ // ---------------------------------------------------------------------------------------------
197
+ /**
198
+ * Cấu hình client, tải discovery document, xử lý callback nếu có, rồi bật gia hạn chủ động.
199
+ *
200
+ * Chạy trong APP_INITIALIZER (xem `provideSdAuthOm`), tức trước điều hướng đầu tiên của Router —
201
+ * nhờ vậy guard của route đầu tiên đã thấy trạng thái phiên đúng.
202
+ *
203
+ * @returns `true` khi kết thúc với một access token hợp lệ.
204
+ */
205
+ async init(config) {
206
+ this.config = config;
207
+ if (!this.#isBrowser || !this.#oauth) {
208
+ return false;
209
+ }
210
+ const authErrorHref = config.authErrorUrl ? this.#toPublicHtmlUrl(config.authErrorUrl) : null;
211
+ // why: đang đứng sẵn ở trang lỗi thì KHÔNG init lại, tránh vòng lặp redirect khi IdP sập lâu.
212
+ if (authErrorHref && window.location.pathname === new URL(authErrorHref).pathname) {
213
+ return false;
214
+ }
215
+ this.#oauth.configure(this.buildAuthConfig(config));
216
+ if (!this.#initialized) {
217
+ this.#initialized = true;
218
+ this.#subscribeOAuthEvents();
219
+ }
220
+ try {
221
+ await this.#oauth.loadDiscoveryDocumentAndTryLogin();
222
+ }
223
+ catch (error) {
224
+ this.#emitAuthError('discovery_document_load_error', error);
225
+ if (authErrorHref) {
226
+ this.navigate(authErrorHref);
227
+ }
228
+ return false;
229
+ }
230
+ this.#restoreReturnUrl();
231
+ this.#syncFromOAuth();
232
+ this.#emit('ready');
233
+ this.#oauth.setupAutomaticSilentRefresh({}, 'access_token');
234
+ if (config.crossTabLogout !== false) {
235
+ this.#listenCrossTabLogout(config);
236
+ }
237
+ return this.isAuthenticated();
238
+ }
239
+ /**
240
+ * Quy đổi cấu hình tenant sang `AuthConfig` của client OIDC.
241
+ *
242
+ * Public để app kiểm tra được cấu hình thực tế trong test mà không phải chạy cả `init()`.
243
+ */
244
+ buildAuthConfig(config) {
245
+ const audience = config.audience?.trim();
246
+ const silentRefreshSeconds = config.silentRefreshTimeoutInSeconds ?? config.authorizeTimeoutInSeconds ?? SD_AUTHOM_DEFAULT_SILENT_REFRESH_TIMEOUT_SECONDS;
247
+ const authConfig = {
248
+ issuer: this.#resolveIssuer(config),
249
+ clientId: config.clientId,
250
+ redirectUri: config.redirectUri || window.location.origin,
251
+ postLogoutRedirectUri: config.postLogoutRedirectUri || window.location.origin,
252
+ responseType: 'code',
253
+ scope: config.scope || SD_AUTHOM_DEFAULT_SCOPE,
254
+ // why: giữ bật (mặc định của client OIDC). Discovery document thật của AuthOM QC đặt mọi
255
+ // endpoint trong cây issuer nên phép kiểm tra này đi qua; chỉ tenant nào công bố endpoint
256
+ // ngoài cây issuer mới cần tắt.
257
+ strictDiscoveryDocumentValidation: config.strictDiscoveryDocumentValidation ?? true,
258
+ // why: 'remoteOnly' vẫn ép HTTPS với IdP thật nhưng cho phép chạy dev trên localhost.
259
+ requireHttps: config.requireHttps ?? 'remoteOnly',
260
+ showDebugInformation: config.showDebugInformation ?? false,
261
+ timeoutFactor: config.timeoutFactor ?? SD_AUTHOM_DEFAULT_TIMEOUT_FACTOR,
262
+ silentRefreshTimeout: silentRefreshSeconds * 1000,
263
+ useSilentRefresh: config.useSilentRefresh ?? false,
264
+ clearHashAfterLogin: true,
265
+ };
266
+ if (config.refreshThresholdSeconds !== undefined) {
267
+ // why: client OIDC đo ngưỡng refresh bằng "coi token hết hạn sớm N giây", không có
268
+ // tham số "refresh trước exp N giây" riêng. Hai cách diễn đạt cho cùng một hành vi.
269
+ authConfig.decreaseExpirationBySec = config.refreshThresholdSeconds;
270
+ }
271
+ if (config.clockSkewInSec !== undefined) {
272
+ authConfig.clockSkewInSec = config.clockSkewInSec;
273
+ }
274
+ if (config.useSilentRefresh) {
275
+ authConfig.silentRefreshRedirectUri = config.silentRefreshRedirectUri || `${window.location.origin}/silent-authom.html`;
276
+ }
277
+ // why: customQueryParams được gắn vào CẢ /authorize, CẢ lần đổi code lấy token, CẢ refresh —
278
+ // đó là đường duy nhất để `audience` bắt buộc của AuthOM tới được token endpoint.
279
+ const customQueryParams = { ...(config.customQueryParams ?? {}) };
280
+ if (audience) {
281
+ customQueryParams['audience'] = audience;
282
+ }
283
+ if (Object.keys(customQueryParams).length > 0) {
284
+ authConfig.customQueryParams = customQueryParams;
285
+ }
286
+ return authConfig;
287
+ }
288
+ // ---------------------------------------------------------------------------------------------
289
+ // Đăng nhập / đăng xuất
290
+ // ---------------------------------------------------------------------------------------------
291
+ /**
292
+ * Khởi động Authorization Code flow.
293
+ *
294
+ * `returnTo` mặc định là URL hiện tại, đi kèm tham số OIDC `state` để deep link sống sót qua
295
+ * vòng redirect (xem `authom.return-url.ts`).
296
+ */
76
297
  async login(options) {
77
- if (!this.isBrowser)
298
+ if (!this.#isBrowser || !this.#oauth) {
78
299
  return;
79
- const state = crypto.randomUUID();
80
- const codeVerifier = this.generateCodeVerifier();
81
- const codeChallenge = await this.generateCodeChallenge(codeVerifier);
82
- const returnTo = options?.returnTo || (window.location.pathname + window.location.search);
83
- sessionStorage.setItem(STORAGE_KEY_STATE, state);
84
- sessionStorage.setItem(STORAGE_KEY_CODE_VERIFIER, codeVerifier);
85
- sessionStorage.setItem(STORAGE_KEY_RETURN_TO, returnTo);
86
- window.location.href = this.buildAuthorizeUrl({
87
- state,
88
- codeChallenge,
89
- redirectUri: this.getDefaultRedirectUri(),
90
- });
300
+ }
301
+ const requested = options?.returnTo ?? `${window.location.pathname}${window.location.search}${window.location.hash}`;
302
+ this.#oauth.initCodeFlow(captureReturnUrl(requested), this.#authorizeParams(options?.prompt));
91
303
  }
304
+ /**
305
+ * Kết thúc phiên cục bộ rồi (mặc định) bắt đăng nhập lại với `prompt=login`.
306
+ *
307
+ * AuthOM không công bố `end_session_endpoint` và không hỗ trợ thu hồi token, nên đăng xuất
308
+ * KHÔNG gọi endpoint nào của IdP trừ khi `endSessionOnLogout` được bật rõ ràng.
309
+ */
92
310
  logout(options) {
93
- if (!this.isBrowser)
311
+ if (!this.#isBrowser || !this.#oauth) {
94
312
  return;
95
- this.accessToken.set(null);
96
- this.idTokenClaims.set(null);
97
- if (this.refreshTimer) {
98
- clearTimeout(this.refreshTimer);
99
- this.refreshTimer = null;
100
- }
101
- const returnTo = options?.returnTo || window.location.origin;
102
- const search = new URLSearchParams({
103
- client_id: this.config.clientId,
104
- returnTo,
313
+ }
314
+ const config = this.config;
315
+ const prompt = options?.prompt ?? config?.logoutPrompt ?? 'login';
316
+ this.#clearExpiryCheck();
317
+ this.#inFlightRefresh = null;
318
+ if (config?.endSessionOnLogout) {
319
+ this.#resetSessionState();
320
+ this.#emit('logout');
321
+ this.#notifyOtherTabs(config);
322
+ // logOut() không tham số sẽ redirect tới end_session_endpoint khi IdP có công bố.
323
+ this.#oauth.logOut();
324
+ return;
325
+ }
326
+ // why: logOut(true) chỉ dọn phiên cục bộ, không thử redirect tới logout URL không tồn tại.
327
+ this.#oauth.logOut(true);
328
+ this.#resetSessionState();
329
+ this.#emit('logout');
330
+ this.#notifyOtherTabs(config);
331
+ if (prompt === false) {
332
+ const returnTo = options?.returnTo ?? config?.postLogoutRedirectUri ?? window.location.origin;
333
+ this.navigate(returnTo);
334
+ return;
335
+ }
336
+ // why: cookie SSO của AuthOM không bị xoá cùng phiên cục bộ. Không ép prompt thì lần
337
+ // /authorize kế tiếp được cookie đó trả lời ngay và user bị đăng nhập lại im lặng.
338
+ this.#oauth.initCodeFlow(undefined, this.#authorizeParams(prompt));
339
+ }
340
+ // ---------------------------------------------------------------------------------------------
341
+ // Gia hạn token
342
+ // ---------------------------------------------------------------------------------------------
343
+ /**
344
+ * Gia hạn access token ngay lập tức.
345
+ *
346
+ * Dùng refresh_token grant khi IdP đã phát hành refresh token, ngược lại rơi về silent refresh
347
+ * bằng iframe ẩn. Các lời gọi chồng nhau dùng chung một request đang bay.
348
+ *
349
+ * @returns `true` khi kết thúc với access token hợp lệ.
350
+ */
351
+ refresh() {
352
+ if (!this.#isBrowser || !this.#oauth) {
353
+ return Promise.resolve(false);
354
+ }
355
+ if (this.#inFlightRefresh) {
356
+ return this.#inFlightRefresh;
357
+ }
358
+ const pending = this.#runRefresh().finally(() => {
359
+ this.#inFlightRefresh = null;
105
360
  });
106
- window.location.href = `https://${this.config.domain}/v2/logout?${search.toString()}`;
361
+ this.#inFlightRefresh = pending;
362
+ return pending;
363
+ }
364
+ /**
365
+ * Tên cũ của `refresh()`, giữ để tương thích ngược.
366
+ *
367
+ * @deprecated Dùng `refresh()`.
368
+ */
369
+ silentRefresh() {
370
+ return this.refresh();
371
+ }
372
+ /**
373
+ * Ghi nhận một `401` từ route đã được bảo vệ.
374
+ *
375
+ * Đây là tín hiệu hết phiên đáng tin nhất: một lần refresh hỏng vẫn có thể xảy ra khi token cũ
376
+ * còn hạn, nhưng `401` nghĩa là resource server đã từ chối đúng token đang dùng.
377
+ *
378
+ * - Token đã hết hạn ⇒ phiên chết, phát `session_expired` (và đăng nhập lại nếu cấu hình cho phép).
379
+ * - Token vẫn còn hạn ⇒ server từ chối vì lý do khác (sai `audience`, client bị thu hồi...);
380
+ * phát `auth_error` với `reason = 'unauthorized'` để app tự hiển thị thông báo.
381
+ *
382
+ * `SdAuthOmInterceptor` gọi sẵn; chỉ cần gọi tay khi app dùng HTTP client riêng.
383
+ */
384
+ handleUnauthorized() {
385
+ if (!this.#oauth) {
386
+ return;
387
+ }
388
+ if (this.#oauth.hasValidAccessToken()) {
389
+ this.#emitAuthError('unauthorized');
390
+ return;
391
+ }
392
+ this.#markSessionExpired();
393
+ }
394
+ /** `true` khi có thể thử gia hạn mà không cần user tương tác. */
395
+ canRefresh() {
396
+ if (!this.#oauth) {
397
+ return false;
398
+ }
399
+ return Boolean(this.#oauth.getRefreshToken()) || this.config?.useSilentRefresh === true;
107
400
  }
108
- async exchangeCode(code, codeVerifier, redirectUri) {
401
+ // ---------------------------------------------------------------------------------------------
402
+ // Callback
403
+ // ---------------------------------------------------------------------------------------------
404
+ /**
405
+ * Xử lý callback `?code=` thủ công.
406
+ *
407
+ * `init()` đã tự làm việc này; chỉ cần gọi tay khi app tách riêng một route `/callback` chạy
408
+ * ngoài APP_INITIALIZER.
409
+ */
410
+ async handleCallback() {
411
+ if (!this.#isBrowser || !this.#oauth) {
412
+ return false;
413
+ }
109
414
  try {
110
- const body = new URLSearchParams({
111
- grant_type: 'authorization_code',
112
- code,
113
- code_verifier: codeVerifier,
114
- redirect_uri: redirectUri,
115
- client_id: this.config.clientId,
116
- });
117
- const res = await fetch(`https://${this.config.domain}/oauth/token`, {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
120
- body: body.toString(),
121
- });
122
- if (!res.ok)
123
- return false;
124
- const data = await res.json();
125
- if (!data.access_token)
126
- return false;
127
- this.accessToken.set(data.access_token);
128
- if (data.id_token) {
129
- this.idTokenClaims.set(this.decodeJwtPayload(data.id_token));
130
- }
131
- this.scheduleRefresh(data.access_token);
132
- return true;
415
+ await this.#oauth.tryLogin();
416
+ }
417
+ catch (error) {
418
+ this.#emitAuthError('code_error', error);
419
+ return false;
420
+ }
421
+ this.#restoreReturnUrl();
422
+ this.#syncFromOAuth();
423
+ return this.isAuthenticated();
424
+ }
425
+ // ---------------------------------------------------------------------------------------------
426
+ // Đọc trạng thái
427
+ // ---------------------------------------------------------------------------------------------
428
+ getAccessToken() {
429
+ return this.accessToken();
430
+ }
431
+ /** `true` khi không còn access token dùng được (chưa đăng nhập, hoặc đã quá hạn). */
432
+ isAccessTokenExpired() {
433
+ return !this.#oauth?.hasValidAccessToken();
434
+ }
435
+ /** URL có nằm trong `secureRoutes` không — tức có được đính Bearer token không. */
436
+ isSecureUrl(url) {
437
+ const patterns = this.config?.secureRoutes;
438
+ if (!patterns?.length) {
439
+ return false;
440
+ }
441
+ return patterns.some(pattern => matchSecureRoute(pattern, url));
442
+ }
443
+ /** Origin của IdP, để interceptor không bao giờ tự đính token vào chính request lấy token. */
444
+ getIssuerOrigin() {
445
+ const issuer = this.config ? this.#resolveIssuer(this.config) : null;
446
+ if (!issuer) {
447
+ return null;
448
+ }
449
+ try {
450
+ return new URL(issuer).origin;
133
451
  }
134
452
  catch {
453
+ return null;
454
+ }
455
+ }
456
+ /** Claims của user hiện tại ở dạng `SdAuthInfo` của module auth, hoặc `null` khi chưa đăng nhập. */
457
+ authInfo() {
458
+ const claims = this.idTokenClaims();
459
+ if (!claims) {
460
+ return null;
461
+ }
462
+ return {
463
+ id: claims['sub'],
464
+ username: resolveUsername(claims),
465
+ email: claims['email'],
466
+ firstName: claims['given_name'],
467
+ lastName: claims['family_name'],
468
+ data: claims,
469
+ };
470
+ }
471
+ /** Claims của user hiện tại ở dạng `SdLayoutUserInfo` của module layout. */
472
+ userInfo() {
473
+ const claims = this.idTokenClaims();
474
+ if (!claims) {
475
+ return {};
476
+ }
477
+ return {
478
+ username: resolveUsername(claims),
479
+ email: claims['email'],
480
+ fullName: resolveFullName(claims),
481
+ avatar: claims['picture'],
482
+ };
483
+ }
484
+ // ---------------------------------------------------------------------------------------------
485
+ // Mối nối với trình duyệt (tách riêng để test ghi đè được)
486
+ // ---------------------------------------------------------------------------------------------
487
+ /** Điều hướng full-page. */
488
+ navigate(url) {
489
+ window.location.href = url;
490
+ }
491
+ /** Viết lại URL trên thanh địa chỉ mà không tạo entry mới trong history. */
492
+ replaceHistoryState(url) {
493
+ window.history.replaceState(null, '', url);
494
+ }
495
+ // ---------------------------------------------------------------------------------------------
496
+ // Nội bộ
497
+ // ---------------------------------------------------------------------------------------------
498
+ async #runRefresh() {
499
+ const oauth = this.#oauth;
500
+ if (!oauth) {
501
+ return false;
502
+ }
503
+ try {
504
+ if (!this.config?.useSilentRefresh && oauth.getRefreshToken()) {
505
+ await oauth.refreshToken();
506
+ }
507
+ else {
508
+ await oauth.silentRefresh();
509
+ }
510
+ }
511
+ catch (error) {
512
+ this.#emit('token_refresh_error', undefined, error);
513
+ this.#evaluateSessionExpired();
135
514
  return false;
136
515
  }
516
+ this.#syncFromOAuth();
517
+ return this.isAuthenticated();
518
+ }
519
+ #subscribeOAuthEvents() {
520
+ const oauth = this.#oauth;
521
+ if (!oauth) {
522
+ return;
523
+ }
524
+ // why: KHÔNG đăng ký onDestroy ở đây — hàm này chạy bên trong init() bất đồng bộ, và
525
+ // DestroyRef.onDestroy ném NG0205 nếu injector đã bị huỷ trước khi init() kịp chạy xong.
526
+ // Dọn dẹp dồn hết về #teardown(), vốn đã đăng ký một lần trong constructor.
527
+ this.#eventsSubscription = oauth.events.subscribe(event => this.#onOAuthEvent(event));
528
+ }
529
+ #onOAuthEvent(event) {
530
+ if (event.type === 'token_received') {
531
+ this.#clearExpiryCheck();
532
+ this.sessionExpired.set(false);
533
+ this.lastAuthError.set(null);
534
+ this.#syncFromOAuth();
535
+ this.#emit('token_received');
536
+ return;
537
+ }
538
+ if (event.type === 'token_refreshed' || event.type === 'silently_refreshed') {
539
+ this.#clearExpiryCheck();
540
+ this.sessionExpired.set(false);
541
+ this.#syncFromOAuth();
542
+ this.#emit('token_refreshed');
543
+ return;
544
+ }
545
+ if (REFRESH_ERROR_EVENT_TYPES.has(event.type)) {
546
+ this.#emit('token_refresh_error', event.type, reasonOf(event));
547
+ this.#evaluateSessionExpired();
548
+ return;
549
+ }
550
+ if (AUTH_ERROR_EVENT_TYPES.has(event.type)) {
551
+ this.#emitAuthError(event.type, reasonOf(event));
552
+ }
137
553
  }
138
- scheduleRefresh(token) {
139
- if (this.refreshTimer) {
140
- clearTimeout(this.refreshTimer);
141
- this.refreshTimer = null;
554
+ /**
555
+ * Quyết định một lần gia hạn thất bại có phải là hết phiên hay không.
556
+ *
557
+ * Refresh hỏng CHƯA phải hết phiên: access token đang giữ thường vẫn còn hạn thêm một lúc, và
558
+ * báo "phiên hết hạn" ngay lúc đó là báo sớm — app vẫn chạy bình thường cho tới khi có một
559
+ * request thật trả 401. Chỉ khi token đó cũng chết thì phiên mới thực sự kết thúc.
560
+ */
561
+ #evaluateSessionExpired() {
562
+ if (!this.#oauth) {
563
+ return;
142
564
  }
143
- const claims = this.decodeJwtPayload(token);
144
- const exp = claims?.['exp'];
145
- if (typeof exp !== 'number') {
146
- // @i18n-ignore — dev console warning
147
- console.warn('[SdAuthOmService] Token không có exp — bỏ qua auto-refresh');
565
+ if (this.#oauth.hasValidAccessToken()) {
566
+ // why: lần gia hạn chủ động này đã hỏng và sẽ không tự lặp lại (timer chỉ lên lịch lại khi
567
+ // nhận token mới), nên phải tự hẹn kiểm tra đúng lúc token hiện tại hết hạn.
568
+ this.#scheduleExpiryCheck();
148
569
  return;
149
570
  }
150
- const threshold = this.config.refreshThresholdSeconds ?? DEFAULT_REFRESH_THRESHOLD_SECONDS;
151
- const nowSeconds = Math.floor(Date.now() / 1000);
152
- const delayMs = Math.max(0, (exp - nowSeconds - threshold) * 1000);
153
- this.refreshTimer = setTimeout(() => {
154
- this.silentRefresh();
155
- }, delayMs);
571
+ this.#markSessionExpired();
156
572
  }
157
- silentRefresh() {
158
- if (!this.isBrowser)
159
- return Promise.resolve(false);
160
- return new Promise(async (resolve) => {
161
- const state = crypto.randomUUID();
162
- const codeVerifier = this.generateCodeVerifier();
163
- const codeChallenge = await this.generateCodeChallenge(codeVerifier);
164
- const redirectUri = this.getSilentRedirectUri();
165
- const timeoutMs = (this.config.authorizeTimeoutInSeconds ?? DEFAULT_AUTHORIZE_TIMEOUT_SECONDS) * 1000;
166
- const iframe = document.createElement('iframe');
167
- iframe.style.display = 'none';
168
- let done = false;
169
- const finish = (success) => {
170
- if (done)
171
- return;
172
- done = true;
173
- clearTimeout(timer);
174
- iframe.remove();
175
- window.removeEventListener('message', onMessage);
176
- resolve(success);
177
- };
178
- const timer = setTimeout(() => finish(false), timeoutMs);
179
- const onMessage = async (event) => {
180
- if (event.origin !== window.location.origin)
181
- return;
182
- const data = event.data;
183
- if (!data || typeof data !== 'object')
184
- return;
185
- if (data.type === 'AUTHOM_SILENT_SUCCESS' && data.code && data.state === state) {
186
- const ok = await this.exchangeCode(data.code, codeVerifier, redirectUri);
187
- if (!ok) {
188
- this.accessToken.set(null);
189
- this.idTokenClaims.set(null);
190
- }
191
- finish(ok);
192
- }
193
- else if (data.type === 'AUTHOM_SILENT_ERROR') {
194
- this.accessToken.set(null);
195
- this.idTokenClaims.set(null);
196
- finish(false);
197
- }
198
- };
199
- window.addEventListener('message', onMessage);
200
- iframe.src = this.buildAuthorizeUrl({
201
- state,
202
- codeChallenge,
203
- redirectUri,
204
- prompt: 'none',
205
- });
206
- document.body.appendChild(iframe);
573
+ #scheduleExpiryCheck() {
574
+ this.#clearExpiryCheck();
575
+ const expiresAt = this.#oauth?.getAccessTokenExpiration();
576
+ if (!expiresAt) {
577
+ return;
578
+ }
579
+ const delay = Math.max(0, expiresAt - Date.now()) + 1000;
580
+ this.#expiryCheckTimer = setTimeout(() => {
581
+ this.#expiryCheckTimer = null;
582
+ if (!this.#oauth?.hasValidAccessToken()) {
583
+ this.#markSessionExpired();
584
+ }
585
+ }, delay);
586
+ }
587
+ #clearExpiryCheck() {
588
+ if (this.#expiryCheckTimer !== null) {
589
+ clearTimeout(this.#expiryCheckTimer);
590
+ this.#expiryCheckTimer = null;
591
+ }
592
+ }
593
+ #markSessionExpired() {
594
+ if (this.sessionExpired()) {
595
+ return;
596
+ }
597
+ this.#clearExpiryCheck();
598
+ this.#resetSessionState();
599
+ this.sessionExpired.set(true);
600
+ this.#emit('session_expired');
601
+ if ((this.config?.onSessionExpired ?? 'login') === 'login') {
602
+ void this.login();
603
+ }
604
+ }
605
+ #listenCrossTabLogout(config) {
606
+ if (this.#channel) {
607
+ return;
608
+ }
609
+ this.#channel = new SdAuthOmSessionChannel(config.sessionChannelName || `sd-authom-session:${config.clientId}`);
610
+ this.#channel.onLogout(() => {
611
+ this.#clearExpiryCheck();
612
+ this.#oauth?.logOut(true);
613
+ this.#resetSessionState();
614
+ this.#emit('logout_other_tab');
615
+ this.#oauth?.initCodeFlow(undefined, this.#authorizeParams('login'));
207
616
  });
208
617
  }
209
- async handleCallback() {
210
- if (!this.isBrowser)
211
- return false;
212
- const params = new URLSearchParams(window.location.search);
213
- const code = params.get('code');
214
- const state = params.get('state');
215
- if (!code)
216
- return false;
217
- const savedState = sessionStorage.getItem(STORAGE_KEY_STATE);
218
- const codeVerifier = sessionStorage.getItem(STORAGE_KEY_CODE_VERIFIER);
219
- const returnTo = sessionStorage.getItem(STORAGE_KEY_RETURN_TO);
220
- sessionStorage.removeItem(STORAGE_KEY_STATE);
221
- sessionStorage.removeItem(STORAGE_KEY_CODE_VERIFIER);
222
- if (state !== savedState || !codeVerifier) {
223
- window.history.replaceState({}, '', window.location.pathname);
224
- sessionStorage.removeItem(STORAGE_KEY_RETURN_TO);
225
- return false;
618
+ #notifyOtherTabs(config) {
619
+ if (config?.crossTabLogout === false) {
620
+ return;
226
621
  }
227
- const redirectUri = this.getDefaultRedirectUri();
228
- const ok = await this.exchangeCode(code, codeVerifier, redirectUri);
622
+ this.#channel?.notifyLogout();
623
+ }
624
+ #restoreReturnUrl() {
625
+ const returnTo = resolveReturnUrl(this.#oauth?.state);
229
626
  if (returnTo) {
230
- sessionStorage.removeItem(STORAGE_KEY_RETURN_TO);
231
- window.history.replaceState({}, '', returnTo);
627
+ // why: vẫn đang trong APP_INITIALIZER, tức trước điều hướng đầu tiên của Router — viết lại
628
+ // URL ở đây khiến lần điều hướng đó nhắm thẳng trang user yêu cầu và chạy guard bình thường.
629
+ this.replaceHistoryState(window.location.origin + returnTo);
232
630
  }
233
- else {
234
- window.history.replaceState({}, '', window.location.pathname);
631
+ }
632
+ #syncFromOAuth() {
633
+ const oauth = this.#oauth;
634
+ if (!oauth?.hasValidAccessToken()) {
635
+ this.accessToken.set(null);
636
+ this.idTokenClaims.set(null);
637
+ return;
235
638
  }
236
- return ok;
639
+ this.accessToken.set(oauth.getAccessToken() || null);
640
+ this.idTokenClaims.set(oauth.getIdentityClaims() ?? null);
237
641
  }
238
- async init(config) {
239
- this.config = config;
240
- if (!this.isBrowser)
241
- return false;
242
- const hasCode = new URLSearchParams(window.location.search).has('code');
243
- if (hasCode) {
244
- const ok = await this.handleCallback();
245
- if (ok)
246
- return true;
642
+ #resetSessionState() {
643
+ this.accessToken.set(null);
644
+ this.idTokenClaims.set(null);
645
+ }
646
+ #authorizeParams(prompt) {
647
+ const params = {};
648
+ if (this.config?.organization) {
649
+ params['organization'] = this.config.organization;
650
+ }
651
+ if (prompt) {
652
+ params['prompt'] = prompt;
653
+ }
654
+ return params;
655
+ }
656
+ #resolveIssuer(config) {
657
+ const issuer = config.issuer?.trim();
658
+ if (issuer) {
659
+ return issuer;
660
+ }
661
+ const domain = config.domain?.trim();
662
+ if (domain) {
663
+ return /^https?:\/\//i.test(domain) ? domain : `https://${domain}/`;
247
664
  }
248
- return this.silentRefresh();
665
+ throw new Error('[SdAuthOmService] Missing tenant "issuer" (or legacy "domain") in SdAuthOmTenantConfig.');
666
+ }
667
+ /**
668
+ * Chuẩn hoá basename file tĩnh trong `public/` thành URL tuyệt đối.
669
+ * `'auth-error'` | `'/auth-error'` | `'auth-error.html'` → `${origin}/auth-error.html`.
670
+ */
671
+ #toPublicHtmlUrl(name) {
672
+ const base = name
673
+ .trim()
674
+ .replace(/^\/+/, '')
675
+ .replace(/\.html$/i, '');
676
+ return `${window.location.origin}/${base}.html`;
677
+ }
678
+ #emit(type, reason, error) {
679
+ this.#events.next({ type, reason, error });
680
+ }
681
+ #emitAuthError(reason, error) {
682
+ const event = { type: 'auth_error', reason, error };
683
+ this.lastAuthError.set(event);
684
+ this.#events.next(event);
685
+ }
686
+ #teardown() {
687
+ this.#clearExpiryCheck();
688
+ this.#eventsSubscription?.unsubscribe();
689
+ this.#eventsSubscription = null;
690
+ this.#channel?.close();
691
+ this.#channel = null;
692
+ this.#events.complete();
249
693
  }
250
694
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdAuthOmService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
251
695
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdAuthOmService, providedIn: 'root' });
@@ -253,59 +697,216 @@ class SdAuthOmService {
253
697
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdAuthOmService, decorators: [{
254
698
  type: Injectable,
255
699
  args: [{ providedIn: 'root' }]
256
- }] });
257
-
258
- const escapeRegExp = (str) => str.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
700
+ }], ctorParameters: () => [] });
701
+ /**
702
+ * Khớp một URL với một phần tử `secureRoutes`.
703
+ *
704
+ * Có `*` → glob trên toàn bộ URL. Không có `*` → khớp tiền tố, không phân biệt hoa thường
705
+ * (đây là dạng "danh sách host" mà các portal đang dùng).
706
+ */
707
+ function matchSecureRoute(pattern, url) {
708
+ if (!pattern) {
709
+ return false;
710
+ }
711
+ if (pattern.includes('*')) {
712
+ return matchGlob(pattern, url);
713
+ }
714
+ return url.toLowerCase().startsWith(pattern.toLowerCase());
715
+ }
716
+ const escapeRegExp = (value) => value.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
717
+ /** Khớp glob đơn giản, `*` thay cho một đoạn bất kỳ. Giữ nguyên hành vi từ bản trước của module. */
259
718
  const matchGlob = (pattern, url) => {
260
719
  const regexBody = pattern.split('*').map(escapeRegExp).join('.*');
261
720
  return new RegExp(`^${regexBody}$`).test(url);
262
721
  };
722
+ /**
723
+ * AuthOM không phát claim `preferred_username` (xác nhận từ discovery document thật), nên phải có
724
+ * chuỗi fallback thay vì đọc thẳng một claim.
725
+ */
726
+ function resolveUsername(claims) {
727
+ return (claims['preferred_username'] ?? claims['email'] ?? claims['nickname'] ?? claims['sub']);
728
+ }
729
+ function resolveFullName(claims) {
730
+ const lastName = claims['family_name'];
731
+ const firstName = claims['given_name'];
732
+ const composed = [lastName, firstName].filter(Boolean).join(' ');
733
+ return composed || (claims['name'] ?? claims['nickname']);
734
+ }
735
+ function reasonOf(event) {
736
+ return event instanceof OAuthErrorEvent ? event.reason : event.info;
737
+ }
738
+
739
+ /**
740
+ * Đính `Authorization: Bearer <token>` vào các request khớp `secureRoutes`.
741
+ *
742
+ * Interceptor này cũng là chốt gia hạn cuối cùng: nếu access token đã hết hạn ngay trước khi gửi
743
+ * (máy vừa ngủ dậy, tab bị treo, timer refresh chủ động lỡ nhịp) thì nó gia hạn trước rồi mới gửi,
744
+ * thay vì để request chết 401 rồi mới xử lý.
745
+ */
263
746
  const SdAuthOmInterceptor = (req, next) => {
264
747
  const authom = inject(SdAuthOmService);
265
- const token = authom.getAccessToken();
266
- const config = authom.config;
267
- if (!token || !config)
748
+ if (!authom.config) {
749
+ return next(req);
750
+ }
751
+ // why: request lấy/gia hạn token cũng đi qua HttpClient. Không loại trừ origin của IdP thì một
752
+ // `secureRoutes` rộng tay sẽ khiến refresh tự gọi lại chính nó.
753
+ const issuerOrigin = authom.getIssuerOrigin();
754
+ if (issuerOrigin && req.url.toLowerCase().startsWith(issuerOrigin.toLowerCase())) {
268
755
  return next(req);
269
- const isSecure = config.secureRoutes?.some(pattern => matchGlob(pattern, req.url));
270
- if (!isSecure)
756
+ }
757
+ if (!authom.isSecureUrl(req.url)) {
271
758
  return next(req);
272
- const authReq = req.clone({
273
- headers: req.headers.set('Authorization', `Bearer ${token}`),
274
- });
275
- return next(authReq);
759
+ }
760
+ const send = (token) => forward(req, next, authom, token);
761
+ if (!authom.isAccessTokenExpired()) {
762
+ return send(authom.getAccessToken());
763
+ }
764
+ if (!authom.canRefresh()) {
765
+ return send(null);
766
+ }
767
+ return from(authom.refresh()).pipe(switchMap(refreshed => send(refreshed ? authom.getAccessToken() : null)));
276
768
  };
769
+ function forward(req, next, authom, token) {
770
+ const outgoing = token ? req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`) }) : req;
771
+ return next(outgoing).pipe(catchError((error) => {
772
+ // why: 401 từ một route đã được bảo vệ là tín hiệu đáng tin cậy nhất cho biết phiên đã chết
773
+ // thật — đáng tin hơn một lần refresh hỏng, vì lúc đó token cũ thường vẫn còn hạn.
774
+ if (error instanceof HttpErrorResponse && error.status === 401) {
775
+ authom.handleUnauthorized();
776
+ }
777
+ return throwError(() => error);
778
+ }));
779
+ }
277
780
 
278
- function provideSdAuthOm(options) {
279
- const providers = [SdAuthOmService];
781
+ /**
782
+ * Chặn route khi chưa đăng nhập và khởi động code flow, mang theo URL được yêu cầu.
783
+ *
784
+ * Dùng trực tiếp trên route:
785
+ * ```ts
786
+ * { path: 'orders', canActivate: [sdAuthOmGuard], loadChildren: ... }
787
+ * ```
788
+ *
789
+ * App đã dùng `SdAuthGuard` của module auth thì không cần guard này — hãy để
790
+ * `SdAuthOmAuthConfiguration` cấp `SD_AUTH_CONFIGURATION` (mặc định `provideSdAuthOm` làm sẵn).
791
+ */
792
+ const sdAuthOmGuard = (_route, state) => {
793
+ const authom = inject(SdAuthOmService);
794
+ if (authom.isAuthenticated()) {
795
+ return true;
796
+ }
797
+ void authom.login({ returnTo: state.url });
798
+ return false;
799
+ };
800
+
801
+ /**
802
+ * Nối AuthOM vào module `auth` dùng chung.
803
+ *
804
+ * Cấp token này (mặc định `provideSdAuthOm` đã làm) là đủ để `SdAuthGuard`, `SdPortalGuard` và
805
+ * `SdAuthService.signout()` chạy đúng trên AuthOM mà app không phải viết thêm gì.
806
+ *
807
+ * `changePassword` cố ý KHÔNG được cài đặt: AuthOM chưa công bố cơ chế đổi mật khẩu, và
808
+ * `SdAuthService.changePassword()` tự bỏ qua khi thiếu action — đúng hơn là ném lỗi lúc chạy.
809
+ */
810
+ class SdAuthOmAuthConfiguration {
811
+ #authom = inject(SdAuthOmService);
812
+ /**
813
+ * Phát lại mỗi khi claims đổi, thay vì trả một giá trị chụp một lần.
814
+ *
815
+ * `SdAuthService` bọc giá trị này bằng `toSignal` trong constructor của chính nó, nên một
816
+ * Observable sống giữ cho `getAuthInfo()` đúng cả khi service được tạo trước lúc đăng nhập xong.
817
+ */
818
+ #authInfo$ = toObservable(this.#authom.idTokenClaims).pipe(map(() => this.#authom.authInfo() ?? {}));
819
+ guard = {
820
+ auth: (_route, state) => {
821
+ if (this.#authom.isAuthenticated()) {
822
+ return of(true);
823
+ }
824
+ void this.#authom.login({ returnTo: state.url });
825
+ return of(false);
826
+ },
827
+ /** AuthOM không có khái niệm portal riêng — mặc định cho qua, app tự override nếu cần. */
828
+ portal: () => of(true),
829
+ authInfo: () => this.#authInfo$,
830
+ };
831
+ action = {
832
+ signout: () => this.#authom.logout(),
833
+ };
834
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdAuthOmAuthConfiguration, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
835
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdAuthOmAuthConfiguration, providedIn: 'root' });
836
+ }
837
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImport: i0, type: SdAuthOmAuthConfiguration, decorators: [{
838
+ type: Injectable,
839
+ args: [{ providedIn: 'root' }]
840
+ }] });
841
+
842
+ function buildConfigurationProvider(options) {
280
843
  if (options.useFactory) {
281
- providers.push({ provide: SD_AUTHOM_CONFIGURATION, useFactory: options.useFactory, deps: options.deps || [] });
844
+ return { provide: SD_AUTHOM_CONFIGURATION, useFactory: options.useFactory, deps: options.deps || [] };
845
+ }
846
+ if (options.useClass) {
847
+ return { provide: SD_AUTHOM_CONFIGURATION, useClass: options.useClass };
282
848
  }
283
- else if (options.useClass) {
284
- providers.push({ provide: SD_AUTHOM_CONFIGURATION, useClass: options.useClass });
849
+ throw new Error('[provideSdAuthOm] Either "useClass" or "useFactory" must be supplied.');
850
+ }
851
+ function buildCommonProviders(options) {
852
+ const providers = [
853
+ // why: config = null nên interceptor DI mặc định của client OIDC không đính token cho ai cả.
854
+ // Việc đính token thuộc về SdAuthOmInterceptor, nhờ đó app chỉ cần withInterceptors([...])
855
+ // và không phải nhớ thêm withInterceptorsFromDi().
856
+ provideOAuthClient(),
857
+ SdAuthOmService,
858
+ buildConfigurationProvider(options),
859
+ ];
860
+ if (options.withAuthConfiguration !== false) {
861
+ providers.push({ provide: SD_AUTH_CONFIGURATION, useExisting: SdAuthOmAuthConfiguration });
285
862
  }
863
+ return providers;
864
+ }
865
+ /**
866
+ * Đăng ký AuthOM cho app standalone (gọi trong `app.config.ts`).
867
+ *
868
+ * ```ts
869
+ * export const appConfig: ApplicationConfig = {
870
+ * providers: [
871
+ * provideHttpClient(withInterceptors([SdAuthOmInterceptor])),
872
+ * provideSdAuthOm({
873
+ * useFactory: () => ({
874
+ * loadTenantConfig: () => Promise.resolve({
875
+ * issuer: 'https://api-qc.onemount.dev/auth-om/iam/v1/mag-qc/',
876
+ * clientId: 'YOUR_CLIENT_ID',
877
+ * redirectUri: `${location.origin}/callback`,
878
+ * audience: 'http://api-qc.int.onemount.dev/auth-om/api/v1/mag-qc',
879
+ * secureRoutes: ['https://api-qc.onemount.dev'],
880
+ * }),
881
+ * }),
882
+ * }),
883
+ * ],
884
+ * };
885
+ * ```
886
+ */
887
+ function provideSdAuthOm(options) {
888
+ const providers = buildCommonProviders(options);
286
889
  providers.push(provideAppInitializer(() => {
287
890
  const configLoader = inject(SD_AUTHOM_CONFIGURATION);
288
891
  const authom = inject(SdAuthOmService);
289
- return configLoader.loadTenantConfig().then((config) => authom.init(config));
892
+ return configLoader.loadTenantConfig().then(config => authom.init(config));
290
893
  }));
291
894
  return makeEnvironmentProviders(providers);
292
895
  }
896
+ /** Đăng ký AuthOM cho kiến trúc `NgModule` cũ. */
293
897
  class SdAuthOmModule {
294
898
  static forRoot(options) {
295
899
  return {
296
900
  ngModule: SdAuthOmModule,
297
901
  providers: [
298
- SdAuthOmService,
299
- ...(options.useFactory
300
- ? [{ provide: SD_AUTHOM_CONFIGURATION, useFactory: options.useFactory, deps: options.deps || [] }]
301
- : [{ provide: SD_AUTHOM_CONFIGURATION, useClass: options.useClass }]),
902
+ ...buildCommonProviders(options),
302
903
  {
303
904
  provide: APP_INITIALIZER,
304
905
  multi: true,
305
906
  useFactory: () => {
306
907
  const configLoader = inject(SD_AUTHOM_CONFIGURATION);
307
908
  const authom = inject(SdAuthOmService);
308
- return () => configLoader.loadTenantConfig().then((config) => authom.init(config));
909
+ return () => configLoader.loadTenantConfig().then(config => authom.init(config));
309
910
  },
310
911
  },
311
912
  ],
@@ -321,7 +922,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImpo
321
922
  }] });
322
923
 
323
924
  /**
324
- * Module `authom` — OAuth 2.0 + PKCE authentication client cho AuthOM (Auth0-based).
925
+ * Module `authom` — client xác thực OAuth 2.0 Authorization Code + PKCE cho AuthOM.
926
+ *
927
+ * Module sở hữu trọn vòng đời phiên: bootstrap qua discovery document, đổi authorization code,
928
+ * khôi phục deep link, gia hạn token chủ động, phát hiện hết phiên, xử lý lỗi xác thực và đăng
929
+ * xuất đồng bộ giữa các tab.
325
930
  *
326
931
  * Cách dùng (standalone):
327
932
  * ```ts
@@ -334,12 +939,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImpo
334
939
  * provideSdAuthOm({
335
940
  * useFactory: () => ({
336
941
  * loadTenantConfig: () => Promise.resolve({
337
- * domain: 'login.example.com',
942
+ * issuer: 'https://api-qc.onemount.dev/auth-om/iam/v1/mag-qc/',
338
943
  * clientId: 'YOUR_CLIENT_ID',
339
- * audience: 'https://api.example.com',
340
- * organization: 'org_xxx',
341
- * scope: 'openid profile email offline_access',
342
- * secureRoutes: ['https://api.example.com/*'],
944
+ * redirectUri: `${location.origin}/callback`,
945
+ * audience: 'http://api-qc.int.onemount.dev/auth-om/api/v1/mag-qc',
946
+ * secureRoutes: ['https://api-qc.onemount.dev'],
343
947
  * }),
344
948
  * }),
345
949
  * }),
@@ -347,14 +951,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.21", ngImpo
347
951
  * };
348
952
  * ```
349
953
  *
350
- * Yêu cầu setup thêm:
351
- * 1. Copy file `silent-authom.html` từ source module này vào thư mục `public/` của app.
352
- * 2. App phải chạy trên HTTPS (hoặc localhost) — Web Crypto API yêu cầu secure context.
954
+ * Yêu cầu phía app:
955
+ * 1. Cài `angular-oauth2-oidc` (peer dependency, chọn major khớp Angular của app).
956
+ * 2. App chạy trên HTTPS hoặc localhost — Web Crypto API cần secure context để tạo PKCE.
957
+ * 3. `redirectUri` phải trùng giá trị đã đăng ký trên client AuthOM.
958
+ * 4. Chỉ khi bật `useSilentRefresh`: copy `silent-authom.html` cạnh source module này vào `public/`.
353
959
  */
354
960
 
355
961
  /**
356
962
  * Generated bundle index. Do not edit.
357
963
  */
358
964
 
359
- export { SD_AUTHOM_CONFIGURATION, SdAuthOmInterceptor, SdAuthOmModule, SdAuthOmService, matchGlob, provideSdAuthOm };
965
+ export { SD_AUTHOM_CONFIGURATION, SD_AUTHOM_DEFAULT_SCOPE, SD_AUTHOM_DEFAULT_SILENT_REFRESH_TIMEOUT_SECONDS, SD_AUTHOM_DEFAULT_TIMEOUT_FACTOR, SdAuthOmAuthConfiguration, SdAuthOmInterceptor, SdAuthOmModule, SdAuthOmService, SdAuthOmSessionChannel, captureReturnUrl, isRestorableAppUrl, matchGlob, matchSecureRoute, provideSdAuthOm, resolveReturnUrl, sdAuthOmGuard };
360
966
  //# sourceMappingURL=sd-angular-core-modules-authom.mjs.map