@chipmobilesdk/rn-auth 0.3.0 → 0.3.1
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/CHANGELOG.md +126 -113
- package/README.md +190 -190
- package/package.json +51 -54
- package/skills/sdk-rn-auth/SKILL.md +62 -63
- package/src/access/accessTier.ts +88 -88
- package/src/config/types.ts +154 -154
- package/src/config/validateConfig.ts +114 -108
- package/src/core/authMachine.ts +364 -364
- package/src/core/profile.ts +57 -60
- package/src/core/session.ts +105 -112
- package/src/diagnostics/errors.ts +87 -85
- package/src/diagnostics/messageKeys.ts +59 -61
- package/src/index.ts +71 -83
- package/src/oidc/authClient.ts +195 -199
- package/src/oidc/classifyError.ts +76 -76
- package/src/oidc/socialRouting.ts +66 -73
- package/src/react/AuthProvider.tsx +41 -47
- package/src/react/hooks.ts +86 -91
- package/src/tokens/accessToken.ts +159 -164
- package/src/tokens/secureStore.ts +51 -52
package/src/config/types.ts
CHANGED
|
@@ -1,154 +1,154 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public type contracts for @chipmobilesdk/rn-auth. Shapes follow
|
|
3
|
-
* specs/003-mobile-entra-auth/data-model.md.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { SecureTokenStore } from '../tokens/secureStore';
|
|
7
|
-
import type { OidcAuthClient } from '../oidc/authClient';
|
|
8
|
-
import type { AuthError, AuthErrorCode } from '../diagnostics/errors';
|
|
9
|
-
import type { AuthMessageKey, AuthMessageMap } from '../diagnostics/messageKeys';
|
|
10
|
-
|
|
11
|
-
export type ProviderId = 'google' | 'apple' | 'facebook';
|
|
12
|
-
export type DevicePlatform = 'ios' | 'android';
|
|
13
|
-
|
|
14
|
-
/** Provider Setup Configuration — non-secret public values only (FR-016/FR-017). */
|
|
15
|
-
export interface ProviderConfig {
|
|
16
|
-
id: ProviderId;
|
|
17
|
-
platforms: DevicePlatform[];
|
|
18
|
-
/** External ID `domain_hint` routing the user flow to this IdP (R3). */
|
|
19
|
-
domainHint: string;
|
|
20
|
-
/** Message key for the button label (Continue with Google / Apple). */
|
|
21
|
-
labelKey: AuthMessageKey;
|
|
22
|
-
available?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** One configured protected resource for delegated token requests (FR-007). */
|
|
26
|
-
export interface ResourceConfig {
|
|
27
|
-
key: string;
|
|
28
|
-
scopes: string[];
|
|
29
|
-
displayName?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface ClaimMatcher {
|
|
33
|
-
claim: string;
|
|
34
|
-
equals: string | number | boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** App-specific mapping from tenant indicators to access tiers (FR-019/FR-020). */
|
|
38
|
-
export interface AccessTierMapping {
|
|
39
|
-
/** Stable group object ids granting premium — authoritative (FR-020). */
|
|
40
|
-
premiumGroupObjectIds: string[];
|
|
41
|
-
premiumRoleNames?: string[];
|
|
42
|
-
premiumClaimMatchers?: ClaimMatcher[];
|
|
43
|
-
/** Diagnostics-only object-id → name map; never used for decisions (FR-020). */
|
|
44
|
-
groupDisplayNames?: Record<string, string>;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** App-supplied, public-only setup input (FR-015/FR-016/FR-017). */
|
|
48
|
-
export interface AuthConfig {
|
|
49
|
-
authority: string;
|
|
50
|
-
clientId: string;
|
|
51
|
-
redirectUrl: string;
|
|
52
|
-
scopes: string[];
|
|
53
|
-
providers: ProviderConfig[];
|
|
54
|
-
resources?: ResourceConfig[];
|
|
55
|
-
accessTierMapping: AccessTierMapping;
|
|
56
|
-
secureStore: SecureTokenStore;
|
|
57
|
-
authClient?: OidcAuthClient;
|
|
58
|
-
messages?: Partial<AuthMessageMap>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Advanced/testing options. `platform` overrides the detected device platform. */
|
|
62
|
-
export interface CreateAuthOptions {
|
|
63
|
-
platform?: DevicePlatform;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export type AuthStatus =
|
|
67
|
-
| 'loading'
|
|
68
|
-
| 'signed-out'
|
|
69
|
-
| 'authenticating'
|
|
70
|
-
| 'authenticated'
|
|
71
|
-
| 'token-refreshing'
|
|
72
|
-
| 'access-denied'
|
|
73
|
-
| 'cancelled'
|
|
74
|
-
| 'error';
|
|
75
|
-
|
|
76
|
-
export type RestoreStatus = 'pending' | 'restored' | 'unavailable' | 'offline';
|
|
77
|
-
|
|
78
|
-
/** Customer User / app-visible profile (FR-006). */
|
|
79
|
-
export interface Profile {
|
|
80
|
-
tenantUserId: string;
|
|
81
|
-
displayName?: string;
|
|
82
|
-
email?: string;
|
|
83
|
-
provider: string;
|
|
84
|
-
claims: Record<string, unknown>;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface ProviderIdentity {
|
|
88
|
-
provider: string;
|
|
89
|
-
subject: string;
|
|
90
|
-
emailBehavior: 'direct' | 'private-relay' | 'none';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Access Tier — local interpretation of tenant indicators (FR-010). */
|
|
94
|
-
export interface AccessTier {
|
|
95
|
-
tier: 'free' | 'premium';
|
|
96
|
-
source: 'group' | 'role' | 'claim' | 'default';
|
|
97
|
-
verification: 'verified' | 'unverified';
|
|
98
|
-
lastRefreshAt?: number;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export type AccessTokenStatus = 'success' | 'denied' | 'unavailable' | 'error';
|
|
102
|
-
|
|
103
|
-
/** Access Token Request result (FR-007). */
|
|
104
|
-
export interface AccessTokenResult {
|
|
105
|
-
resourceKey: string;
|
|
106
|
-
status: AccessTokenStatus;
|
|
107
|
-
token?: string;
|
|
108
|
-
expiresAt?: number;
|
|
109
|
-
errorCode?: AuthErrorCode;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Single active app-visible authenticated state (FR-005/FR-022). */
|
|
113
|
-
export interface AuthState {
|
|
114
|
-
status: AuthStatus;
|
|
115
|
-
profile?: Profile;
|
|
116
|
-
accessTier?: AccessTier;
|
|
117
|
-
restore: RestoreStatus;
|
|
118
|
-
error?: AuthError;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Result of a premium-only gate check (US3). */
|
|
122
|
-
export interface PremiumGateResult {
|
|
123
|
-
allowed: boolean;
|
|
124
|
-
tier: AccessTier;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* What `signOutGlobal` did beyond the device (added in 0.2.2). Local state is
|
|
129
|
-
* cleared either way; `failed` means the tenant session may still be alive, so
|
|
130
|
-
* the next interactive sign-in can complete without a password prompt.
|
|
131
|
-
*/
|
|
132
|
-
export interface GlobalSignOutResult {
|
|
133
|
-
global: 'ended' | 'failed';
|
|
134
|
-
/** Present when `global` is `failed`. Redacted like every AuthError. */
|
|
135
|
-
error?: AuthError;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The auth controller returned by `createAuth` (single active account). */
|
|
139
|
-
export interface Auth {
|
|
140
|
-
getState(): AuthState;
|
|
141
|
-
subscribe(listener: (state: AuthState) => void): () => void;
|
|
142
|
-
restore(): Promise<AuthState>;
|
|
143
|
-
signIn(providerId: ProviderId): Promise<AuthState>;
|
|
144
|
-
signOut(): Promise<void>;
|
|
145
|
-
signOutGlobal(): Promise<GlobalSignOutResult>;
|
|
146
|
-
requestAccessToken(resourceKey: string): Promise<AccessTokenResult>;
|
|
147
|
-
getAccessTier(): AccessTier;
|
|
148
|
-
/** Gate a premium-only path; enters `access-denied` when unverified (FR-011). */
|
|
149
|
-
requirePremium(): PremiumGateResult;
|
|
150
|
-
/** The first available provider to present on the current platform (UX-004), or undefined. */
|
|
151
|
-
getPlatformProvider(): ProviderConfig | undefined;
|
|
152
|
-
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
153
|
-
getPlatformProviders(): ProviderConfig[];
|
|
154
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Public type contracts for @chipmobilesdk/rn-auth. Shapes follow
|
|
3
|
+
* specs/003-mobile-entra-auth/data-model.md.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SecureTokenStore } from '../tokens/secureStore';
|
|
7
|
+
import type { OidcAuthClient } from '../oidc/authClient';
|
|
8
|
+
import type { AuthError, AuthErrorCode } from '../diagnostics/errors';
|
|
9
|
+
import type { AuthMessageKey, AuthMessageMap } from '../diagnostics/messageKeys';
|
|
10
|
+
|
|
11
|
+
export type ProviderId = 'google' | 'apple' | 'facebook';
|
|
12
|
+
export type DevicePlatform = 'ios' | 'android';
|
|
13
|
+
|
|
14
|
+
/** Provider Setup Configuration — non-secret public values only (FR-016/FR-017). */
|
|
15
|
+
export interface ProviderConfig {
|
|
16
|
+
id: ProviderId;
|
|
17
|
+
platforms: DevicePlatform[];
|
|
18
|
+
/** External ID `domain_hint` routing the user flow to this IdP (R3). */
|
|
19
|
+
domainHint: string;
|
|
20
|
+
/** Message key for the button label (Continue with Google / Apple). */
|
|
21
|
+
labelKey: AuthMessageKey;
|
|
22
|
+
available?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One configured protected resource for delegated token requests (FR-007). */
|
|
26
|
+
export interface ResourceConfig {
|
|
27
|
+
key: string;
|
|
28
|
+
scopes: string[];
|
|
29
|
+
displayName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClaimMatcher {
|
|
33
|
+
claim: string;
|
|
34
|
+
equals: string | number | boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** App-specific mapping from tenant indicators to access tiers (FR-019/FR-020). */
|
|
38
|
+
export interface AccessTierMapping {
|
|
39
|
+
/** Stable group object ids granting premium — authoritative (FR-020). */
|
|
40
|
+
premiumGroupObjectIds: string[];
|
|
41
|
+
premiumRoleNames?: string[];
|
|
42
|
+
premiumClaimMatchers?: ClaimMatcher[];
|
|
43
|
+
/** Diagnostics-only object-id → name map; never used for decisions (FR-020). */
|
|
44
|
+
groupDisplayNames?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** App-supplied, public-only setup input (FR-015/FR-016/FR-017). */
|
|
48
|
+
export interface AuthConfig {
|
|
49
|
+
authority: string;
|
|
50
|
+
clientId: string;
|
|
51
|
+
redirectUrl: string;
|
|
52
|
+
scopes: string[];
|
|
53
|
+
providers: ProviderConfig[];
|
|
54
|
+
resources?: ResourceConfig[];
|
|
55
|
+
accessTierMapping: AccessTierMapping;
|
|
56
|
+
secureStore: SecureTokenStore;
|
|
57
|
+
authClient?: OidcAuthClient;
|
|
58
|
+
messages?: Partial<AuthMessageMap>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Advanced/testing options. `platform` overrides the detected device platform. */
|
|
62
|
+
export interface CreateAuthOptions {
|
|
63
|
+
platform?: DevicePlatform;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type AuthStatus =
|
|
67
|
+
| 'loading'
|
|
68
|
+
| 'signed-out'
|
|
69
|
+
| 'authenticating'
|
|
70
|
+
| 'authenticated'
|
|
71
|
+
| 'token-refreshing'
|
|
72
|
+
| 'access-denied'
|
|
73
|
+
| 'cancelled'
|
|
74
|
+
| 'error';
|
|
75
|
+
|
|
76
|
+
export type RestoreStatus = 'pending' | 'restored' | 'unavailable' | 'offline';
|
|
77
|
+
|
|
78
|
+
/** Customer User / app-visible profile (FR-006). */
|
|
79
|
+
export interface Profile {
|
|
80
|
+
tenantUserId: string;
|
|
81
|
+
displayName?: string;
|
|
82
|
+
email?: string;
|
|
83
|
+
provider: string;
|
|
84
|
+
claims: Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ProviderIdentity {
|
|
88
|
+
provider: string;
|
|
89
|
+
subject: string;
|
|
90
|
+
emailBehavior: 'direct' | 'private-relay' | 'none';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Access Tier — local interpretation of tenant indicators (FR-010). */
|
|
94
|
+
export interface AccessTier {
|
|
95
|
+
tier: 'free' | 'premium';
|
|
96
|
+
source: 'group' | 'role' | 'claim' | 'default';
|
|
97
|
+
verification: 'verified' | 'unverified';
|
|
98
|
+
lastRefreshAt?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type AccessTokenStatus = 'success' | 'denied' | 'unavailable' | 'error';
|
|
102
|
+
|
|
103
|
+
/** Access Token Request result (FR-007). */
|
|
104
|
+
export interface AccessTokenResult {
|
|
105
|
+
resourceKey: string;
|
|
106
|
+
status: AccessTokenStatus;
|
|
107
|
+
token?: string;
|
|
108
|
+
expiresAt?: number;
|
|
109
|
+
errorCode?: AuthErrorCode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Single active app-visible authenticated state (FR-005/FR-022). */
|
|
113
|
+
export interface AuthState {
|
|
114
|
+
status: AuthStatus;
|
|
115
|
+
profile?: Profile;
|
|
116
|
+
accessTier?: AccessTier;
|
|
117
|
+
restore: RestoreStatus;
|
|
118
|
+
error?: AuthError;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Result of a premium-only gate check (US3). */
|
|
122
|
+
export interface PremiumGateResult {
|
|
123
|
+
allowed: boolean;
|
|
124
|
+
tier: AccessTier;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What `signOutGlobal` did beyond the device (added in 0.2.2). Local state is
|
|
129
|
+
* cleared either way; `failed` means the tenant session may still be alive, so
|
|
130
|
+
* the next interactive sign-in can complete without a password prompt.
|
|
131
|
+
*/
|
|
132
|
+
export interface GlobalSignOutResult {
|
|
133
|
+
global: 'ended' | 'failed';
|
|
134
|
+
/** Present when `global` is `failed`. Redacted like every AuthError. */
|
|
135
|
+
error?: AuthError;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The auth controller returned by `createAuth` (single active account). */
|
|
139
|
+
export interface Auth {
|
|
140
|
+
getState(): AuthState;
|
|
141
|
+
subscribe(listener: (state: AuthState) => void): () => void;
|
|
142
|
+
restore(): Promise<AuthState>;
|
|
143
|
+
signIn(providerId: ProviderId): Promise<AuthState>;
|
|
144
|
+
signOut(): Promise<void>;
|
|
145
|
+
signOutGlobal(): Promise<GlobalSignOutResult>;
|
|
146
|
+
requestAccessToken(resourceKey: string): Promise<AccessTokenResult>;
|
|
147
|
+
getAccessTier(): AccessTier;
|
|
148
|
+
/** Gate a premium-only path; enters `access-denied` when unverified (FR-011). */
|
|
149
|
+
requirePremium(): PremiumGateResult;
|
|
150
|
+
/** The first available provider to present on the current platform (UX-004), or undefined. */
|
|
151
|
+
getPlatformProvider(): ProviderConfig | undefined;
|
|
152
|
+
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
153
|
+
getPlatformProviders(): ProviderConfig[];
|
|
154
|
+
}
|
|
@@ -1,108 +1,114 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic configuration validation (FR-005 → configuration-error).
|
|
3
|
-
* Throws AuthConfigError before any provider flow can start (US4 scenario 2).
|
|
4
|
-
* Covers the auth-config.md Section A–F rules.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { AuthConfig } from './types';
|
|
8
|
-
import { AuthConfigError } from '../diagnostics/errors';
|
|
9
|
-
|
|
10
|
-
function hasScheme(url: string): boolean {
|
|
11
|
-
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function validateConfig(config: AuthConfig): void {
|
|
15
|
-
// Section A — tenant/client settings
|
|
16
|
-
if (!config.authority || !config.authority.trim()) {
|
|
17
|
-
throw new AuthConfigError('auth.error.configuration', 'Missing "authority"');
|
|
18
|
-
}
|
|
19
|
-
if (!hasScheme(config.authority)) {
|
|
20
|
-
throw new AuthConfigError('auth.error.configuration', '"authority" must be an absolute URL');
|
|
21
|
-
}
|
|
22
|
-
if (!config.clientId || !config.clientId.trim()) {
|
|
23
|
-
throw new AuthConfigError('auth.error.configuration', 'Missing "clientId"');
|
|
24
|
-
}
|
|
25
|
-
if (!config.redirectUrl || !config.redirectUrl.trim()) {
|
|
26
|
-
throw new AuthConfigError('auth.error.configuration', 'Missing "redirectUrl"');
|
|
27
|
-
}
|
|
28
|
-
if (!hasScheme(config.redirectUrl)) {
|
|
29
|
-
throw new AuthConfigError(
|
|
30
|
-
'auth.error.redirectMismatch',
|
|
31
|
-
'"redirectUrl" must include a scheme matching the native redirect configuration',
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
if (!Array.isArray(config.scopes) || config.scopes.length === 0) {
|
|
35
|
-
throw new AuthConfigError('auth.error.configuration', 'At least one OIDC scope is required');
|
|
36
|
-
}
|
|
37
|
-
if (!config.scopes.includes('offline_access')) {
|
|
38
|
-
throw new AuthConfigError(
|
|
39
|
-
'auth.error.configuration',
|
|
40
|
-
'"offline_access" scope is required for session restore and silent refresh',
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Sections B/C — providers
|
|
45
|
-
if (!Array.isArray(config.providers) || config.providers.length === 0) {
|
|
46
|
-
throw new AuthConfigError(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
providerIds.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
throw new AuthConfigError('auth.error.configuration',
|
|
77
|
-
}
|
|
78
|
-
resourceKeys.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic configuration validation (FR-005 → configuration-error).
|
|
3
|
+
* Throws AuthConfigError before any provider flow can start (US4 scenario 2).
|
|
4
|
+
* Covers the auth-config.md Section A–F rules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AuthConfig } from './types';
|
|
8
|
+
import { AuthConfigError } from '../diagnostics/errors';
|
|
9
|
+
|
|
10
|
+
function hasScheme(url: string): boolean {
|
|
11
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateConfig(config: AuthConfig): void {
|
|
15
|
+
// Section A — tenant/client settings
|
|
16
|
+
if (!config.authority || !config.authority.trim()) {
|
|
17
|
+
throw new AuthConfigError('auth.error.configuration', 'Missing "authority"');
|
|
18
|
+
}
|
|
19
|
+
if (!hasScheme(config.authority)) {
|
|
20
|
+
throw new AuthConfigError('auth.error.configuration', '"authority" must be an absolute URL');
|
|
21
|
+
}
|
|
22
|
+
if (!config.clientId || !config.clientId.trim()) {
|
|
23
|
+
throw new AuthConfigError('auth.error.configuration', 'Missing "clientId"');
|
|
24
|
+
}
|
|
25
|
+
if (!config.redirectUrl || !config.redirectUrl.trim()) {
|
|
26
|
+
throw new AuthConfigError('auth.error.configuration', 'Missing "redirectUrl"');
|
|
27
|
+
}
|
|
28
|
+
if (!hasScheme(config.redirectUrl)) {
|
|
29
|
+
throw new AuthConfigError(
|
|
30
|
+
'auth.error.redirectMismatch',
|
|
31
|
+
'"redirectUrl" must include a scheme matching the native redirect configuration',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(config.scopes) || config.scopes.length === 0) {
|
|
35
|
+
throw new AuthConfigError('auth.error.configuration', 'At least one OIDC scope is required');
|
|
36
|
+
}
|
|
37
|
+
if (!config.scopes.includes('offline_access')) {
|
|
38
|
+
throw new AuthConfigError(
|
|
39
|
+
'auth.error.configuration',
|
|
40
|
+
'"offline_access" scope is required for session restore and silent refresh',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Sections B/C — providers
|
|
45
|
+
if (!Array.isArray(config.providers) || config.providers.length === 0) {
|
|
46
|
+
throw new AuthConfigError(
|
|
47
|
+
'auth.error.configuration',
|
|
48
|
+
'At least one provider must be configured',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const providerIds = new Set<string>();
|
|
52
|
+
for (const provider of config.providers) {
|
|
53
|
+
if (providerIds.has(provider.id)) {
|
|
54
|
+
throw new AuthConfigError('auth.error.configuration', `Duplicate provider "${provider.id}"`);
|
|
55
|
+
}
|
|
56
|
+
providerIds.add(provider.id);
|
|
57
|
+
if (!provider.domainHint || !provider.domainHint.trim()) {
|
|
58
|
+
throw new AuthConfigError(
|
|
59
|
+
'auth.error.configuration',
|
|
60
|
+
`Provider "${provider.id}" is missing a domainHint`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(provider.platforms) || provider.platforms.length === 0) {
|
|
64
|
+
throw new AuthConfigError(
|
|
65
|
+
'auth.error.configuration',
|
|
66
|
+
`Provider "${provider.id}" must declare at least one platform`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Section D — resources
|
|
72
|
+
if (config.resources) {
|
|
73
|
+
const resourceKeys = new Set<string>();
|
|
74
|
+
for (const resource of config.resources) {
|
|
75
|
+
if (!resource.key || !resource.key.trim()) {
|
|
76
|
+
throw new AuthConfigError('auth.error.configuration', 'A resource is missing "key"');
|
|
77
|
+
}
|
|
78
|
+
if (resourceKeys.has(resource.key)) {
|
|
79
|
+
throw new AuthConfigError(
|
|
80
|
+
'auth.error.configuration',
|
|
81
|
+
`Duplicate resource "${resource.key}"`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
resourceKeys.add(resource.key);
|
|
85
|
+
if (!Array.isArray(resource.scopes) || resource.scopes.length === 0) {
|
|
86
|
+
throw new AuthConfigError(
|
|
87
|
+
'auth.error.configuration',
|
|
88
|
+
`Resource "${resource.key}" must declare at least one scope`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Section E — access-tier mapping
|
|
95
|
+
const mapping = config.accessTierMapping;
|
|
96
|
+
if (!mapping) {
|
|
97
|
+
throw new AuthConfigError('auth.error.configuration', 'Missing "accessTierMapping"');
|
|
98
|
+
}
|
|
99
|
+
const hasPremiumIndicator =
|
|
100
|
+
(mapping.premiumGroupObjectIds?.length ?? 0) > 0 ||
|
|
101
|
+
(mapping.premiumRoleNames?.length ?? 0) > 0 ||
|
|
102
|
+
(mapping.premiumClaimMatchers?.length ?? 0) > 0;
|
|
103
|
+
if (!hasPremiumIndicator) {
|
|
104
|
+
throw new AuthConfigError(
|
|
105
|
+
'auth.error.configuration',
|
|
106
|
+
'accessTierMapping must define at least one premium group object id, role name, or claim matcher',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Section F — injected ports
|
|
111
|
+
if (!config.secureStore) {
|
|
112
|
+
throw new AuthConfigError('auth.error.configuration', 'A secureStore is required (FR-021)');
|
|
113
|
+
}
|
|
114
|
+
}
|