@chipmobilesdk/rn-auth 0.2.0
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 +71 -0
- package/README.md +177 -0
- package/package.json +53 -0
- package/src/access/accessTier.ts +88 -0
- package/src/config/types.ts +143 -0
- package/src/config/validateConfig.ts +108 -0
- package/src/core/authMachine.ts +249 -0
- package/src/core/profile.ts +60 -0
- package/src/core/session.ts +96 -0
- package/src/diagnostics/errors.ts +85 -0
- package/src/diagnostics/messageKeys.ts +61 -0
- package/src/index.ts +82 -0
- package/src/oidc/authClient.ts +224 -0
- package/src/oidc/socialRouting.ts +73 -0
- package/src/react/AuthProvider.tsx +47 -0
- package/src/react/hooks.ts +90 -0
- package/src/tokens/accessToken.ts +105 -0
- package/src/tokens/secureStore.ts +52 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@chipmobilesdk/rn-auth` are documented here. This project
|
|
4
|
+
adheres to [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.2.0] - 2026-06-30
|
|
7
|
+
|
|
8
|
+
Add Facebook as a social sign-in provider (additive, backward compatible).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `'facebook'` is now a valid `ProviderId`. Facebook is federated inside the
|
|
13
|
+
Entra External ID tenant like Google/Apple and routed with `domain_hint`
|
|
14
|
+
(`facebook.com`) — no native Facebook SDK and no new dependency.
|
|
15
|
+
- `auth.action.continueFacebook` message key with English default
|
|
16
|
+
`'Continue with Facebook'`.
|
|
17
|
+
- `Auth.getPlatformProviders()` and the `useSignInProviders()` hook return all
|
|
18
|
+
available providers for the current platform in configuration order, so a
|
|
19
|
+
platform can present multiple sign-in buttons (e.g. Google + Facebook).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `defaultProviderForPlatform` / `getPlatformProvider()` / `useSignInProvider()`
|
|
24
|
+
now return the first available provider in configuration order. Behavior is
|
|
25
|
+
unchanged for existing single-provider (Google/Apple-only) configurations.
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
|
|
29
|
+
- Backward compatible: apps that do not configure Facebook observe no change.
|
|
30
|
+
- Adoption: add `{ id: 'facebook', platforms: ['ios','android'], domainHint:
|
|
31
|
+
'facebook.com', labelKey: 'auth.action.continueFacebook' }` to `providers`,
|
|
32
|
+
federate Facebook in the tenant, and render `useSignInProviders()` for the
|
|
33
|
+
multi-button experience. See the README and
|
|
34
|
+
`specs/004-facebook-auth-provider/`.
|
|
35
|
+
- iOS: offering Facebook (a third-party login) requires Sign in with Apple to
|
|
36
|
+
remain available (Apple guideline 4.8) — it does. Update Apple App Privacy and
|
|
37
|
+
Google Play Data Safety to add Facebook before release.
|
|
38
|
+
|
|
39
|
+
## [0.1.0] - 2026-06-24
|
|
40
|
+
|
|
41
|
+
Initial release of the reusable Microsoft Entra External ID auth library.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- `createAuth(config, options?)` controller with a single active account: sign-in,
|
|
46
|
+
session restore, local `signOut()` and global `signOutGlobal()`, per-resource
|
|
47
|
+
`requestAccessToken()`, `getAccessTier()`, and a `requirePremium()` gate.
|
|
48
|
+
- `AuthProvider` plus `useAuth`, `useProfile`, `useAccessTier`, `useAccessToken`,
|
|
49
|
+
and `useSignInProvider` hooks.
|
|
50
|
+
- Injectable ports `OidcAuthClient` and `SecureTokenStore`, with default adapters
|
|
51
|
+
`createAppAuthClient()` (react-native-app-auth, PKCE public client) and
|
|
52
|
+
`createKeychainSecureStore()` (react-native-keychain).
|
|
53
|
+
- Social routing via per-provider `domain_hint` (Google on Android, Apple on iOS),
|
|
54
|
+
with Google/Apple federated inside the tenant — no native social SDK required.
|
|
55
|
+
- Access-tier evaluation (`evaluateAccessTier`) that prefers stable group object
|
|
56
|
+
ids, ignores display names, and fails closed on missing/stale/overage claims.
|
|
57
|
+
- Token + refresh persistence restricted to platform secure storage; access
|
|
58
|
+
tokens cached in memory with silent refresh.
|
|
59
|
+
- Stable user-facing message keys with English defaults (`DEFAULT_MESSAGES`),
|
|
60
|
+
no i18n runtime dependency.
|
|
61
|
+
- Diagnostics (`AuthError`/`AuthConfigError`) with secret/token redaction.
|
|
62
|
+
|
|
63
|
+
### Notes
|
|
64
|
+
|
|
65
|
+
- New package; no migration required. Adoption notes and the tenant/provider
|
|
66
|
+
prerequisite checklist are in the README.
|
|
67
|
+
- Native redirect configuration and on-device provider flows must be validated
|
|
68
|
+
per `specs/003-mobile-entra-auth/contracts/native-setup.md` (the R8 spike).
|
|
69
|
+
|
|
70
|
+
[0.2.0]: #
|
|
71
|
+
[0.1.0]: #
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @chipmobilesdk/rn-auth
|
|
2
|
+
|
|
3
|
+
Reusable Microsoft Entra External ID authentication for ChipMobileSdk-family
|
|
4
|
+
React Native apps. The library is a **public OAuth2/OIDC client** (Authorization
|
|
5
|
+
Code + PKCE, **no backend, no client secret**). Android presents **Continue with
|
|
6
|
+
Google**, iOS presents **Continue with Apple**, and either platform can also
|
|
7
|
+
present **Continue with Facebook**; all start the same External ID
|
|
8
|
+
sign-up/sign-in user flow, routed to the right social provider with a
|
|
9
|
+
`domain_hint`. Google, Apple, and Facebook are federated **inside the External ID
|
|
10
|
+
tenant**, so the app embeds no native social SDK and no Sign in with Apple
|
|
11
|
+
entitlement. Each platform renders all of its configured providers as separate
|
|
12
|
+
buttons in configuration order.
|
|
13
|
+
|
|
14
|
+
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
15
|
+
> applications; no open-source license is granted.
|
|
16
|
+
|
|
17
|
+
- **Version**: `0.2.0`
|
|
18
|
+
- **Entrypoint**: `@chipmobilesdk/rn-auth`
|
|
19
|
+
|
|
20
|
+
- Single active account, session restore, local + global sign-out.
|
|
21
|
+
- Per-resource delegated access tokens for Azure resources.
|
|
22
|
+
- Group/role/claim access-tier evaluation that **fails closed**.
|
|
23
|
+
- Tokens persist only in platform secure storage (Keychain / Keystore).
|
|
24
|
+
- No i18n runtime dependency: user-facing copy is stable keys + English defaults.
|
|
25
|
+
|
|
26
|
+
## Install (consuming app)
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @chipmobilesdk/rn-auth react-native-app-auth react-native-keychain
|
|
30
|
+
cd ios && pod install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`react`, `react-native`, `react-native-app-auth`, and `react-native-keychain`
|
|
34
|
+
are **peer dependencies**. The two native libraries are only required by the
|
|
35
|
+
default adapters — an app injecting custom ports may omit them.
|
|
36
|
+
|
|
37
|
+
See [native setup](../../specs/003-mobile-entra-auth/contracts/native-setup.md)
|
|
38
|
+
for the iOS `Info.plist` URL scheme and the Android `appAuthRedirectScheme`
|
|
39
|
+
Gradle placeholder.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import {
|
|
45
|
+
AuthProvider,
|
|
46
|
+
createAuth,
|
|
47
|
+
createAppAuthClient,
|
|
48
|
+
createKeychainSecureStore,
|
|
49
|
+
useAuth,
|
|
50
|
+
useSignInProvider,
|
|
51
|
+
} from '@chipmobilesdk/rn-auth';
|
|
52
|
+
|
|
53
|
+
const auth = createAuth({
|
|
54
|
+
authority: 'https://<tenant>.ciamlogin.com/<tenant>.onmicrosoft.com',
|
|
55
|
+
clientId: '<public-client-id>',
|
|
56
|
+
redirectUrl: 'com.yourapp://oauthredirect',
|
|
57
|
+
scopes: ['openid', 'profile', 'offline_access'],
|
|
58
|
+
providers: [
|
|
59
|
+
{ id: 'google', platforms: ['android'], domainHint: 'google.com', labelKey: 'auth.action.continueGoogle' },
|
|
60
|
+
{ id: 'apple', platforms: ['ios'], domainHint: 'apple.com', labelKey: 'auth.action.continueApple' },
|
|
61
|
+
// Optional third provider, shown on both platforms after the platform-native one:
|
|
62
|
+
{ id: 'facebook', platforms: ['ios', 'android'], domainHint: 'facebook.com', labelKey: 'auth.action.continueFacebook' },
|
|
63
|
+
],
|
|
64
|
+
resources: [{ key: 'storage', scopes: ['https://storage.azure.com/user_impersonation'] }],
|
|
65
|
+
accessTierMapping: { premiumGroupObjectIds: ['<premium-group-object-id>'] },
|
|
66
|
+
secureStore: createKeychainSecureStore(),
|
|
67
|
+
authClient: createAppAuthClient(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const App = () => (
|
|
71
|
+
<AuthProvider auth={auth}>
|
|
72
|
+
<Screen />
|
|
73
|
+
</AuthProvider>
|
|
74
|
+
);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Hooks: `useAuth()` (state + `signIn`/`signOut`/`signOutGlobal`/`restore`/`requirePremium`),
|
|
78
|
+
`useProfile()`, `useAccessTier()`, `useAccessToken(resourceKey)`,
|
|
79
|
+
`useSignInProviders()` (all platform providers, in config order — map to one
|
|
80
|
+
button each) and `useSignInProvider()` (first available, for a single-button UI).
|
|
81
|
+
|
|
82
|
+
## Configuration contract (LIB-010)
|
|
83
|
+
|
|
84
|
+
The library accepts **public values only**. Provider secrets, Apple keys, and
|
|
85
|
+
client secrets are configured in the External ID tenant, never in the app
|
|
86
|
+
([FR-017](../../specs/003-mobile-entra-auth/spec.md)).
|
|
87
|
+
|
|
88
|
+
| Section | Keys |
|
|
89
|
+
|---------|------|
|
|
90
|
+
| A — Tenant / client | `authority`, `clientId`, `redirectUrl`, `scopes` (include `offline_access`) |
|
|
91
|
+
| B — Android Google | `providers[google]`: `domainHint`, `platforms: ['android']`, `labelKey` |
|
|
92
|
+
| C — iOS Apple | `providers[apple]`: `domainHint`, `platforms: ['ios']`, `labelKey` |
|
|
93
|
+
| C2 — Facebook (optional) | `providers[facebook]`: `domainHint: 'facebook.com'`, `platforms: ['ios','android']`, `labelKey: 'auth.action.continueFacebook'`, optional `available` |
|
|
94
|
+
| D — Resources | `resources[].key`, `resources[].scopes`, `resources[].displayName?` |
|
|
95
|
+
| E — Access tier | `accessTierMapping.premiumGroupObjectIds` (+ optional role names / claim matchers; display names are diagnostics-only) |
|
|
96
|
+
| F — Ports | `secureStore` (required), `authClient` (optional), `messages` (optional copy override) |
|
|
97
|
+
|
|
98
|
+
Full contract: [auth-config.md](../../specs/003-mobile-entra-auth/contracts/auth-config.md)
|
|
99
|
+
and [public-api.md](../../specs/003-mobile-entra-auth/contracts/public-api.md).
|
|
100
|
+
|
|
101
|
+
## Tenant / provider prerequisite checklist (LIB-018)
|
|
102
|
+
|
|
103
|
+
These are **external** prerequisites (FR-013/FR-018) completed before the library
|
|
104
|
+
can sign users in:
|
|
105
|
+
|
|
106
|
+
**External ID tenant**
|
|
107
|
+
- [ ] Customer (CIAM) tenant created; sign-up/sign-in user flow configured.
|
|
108
|
+
- [ ] App registration created as a **public client**; redirect URI added (must match the native scheme, e.g. `com.yourapp://oauthredirect`).
|
|
109
|
+
- [ ] API permissions / scopes for each target Azure resource consented.
|
|
110
|
+
- [ ] Premium group(s) created; record the **group object id** (not the name).
|
|
111
|
+
- [ ] Groups (or roles) emitted in the token; plan for groups overage.
|
|
112
|
+
|
|
113
|
+
**Google (federated in the tenant)**
|
|
114
|
+
- [ ] Google Cloud OAuth client configured; client id/secret stored in the **tenant** IdP config (never in the app).
|
|
115
|
+
- [ ] Google added as an identity provider in the External ID user flow.
|
|
116
|
+
|
|
117
|
+
**Apple (federated in the tenant)**
|
|
118
|
+
- [ ] Apple Services ID + Sign in with Apple key configured; stored in the **tenant** IdP config.
|
|
119
|
+
- [ ] Apple added as an identity provider in the External ID user flow.
|
|
120
|
+
- [ ] Note: Apple sign-in operational credentials expire on a schedule and must be rotated tenant-side.
|
|
121
|
+
|
|
122
|
+
**Facebook (federated in the tenant — optional)**
|
|
123
|
+
- [ ] Facebook app created in Meta for Developers; **App ID + App Secret** stored in the **tenant** IdP config (never in the app).
|
|
124
|
+
- [ ] Facebook added as an identity provider in the External ID user flow; `domain_hint` confirmed (expected `facebook.com`).
|
|
125
|
+
- [ ] Facebook app OAuth redirect / valid OAuth domains match the tenant federation redirect.
|
|
126
|
+
- [ ] iOS: because Facebook is a third-party login, keep Sign in with Apple offered on iOS (Apple guideline 4.8).
|
|
127
|
+
- [ ] Update Apple App Privacy and Google Play Data Safety to include Facebook as an identity provider before release.
|
|
128
|
+
|
|
129
|
+
## Security & privacy
|
|
130
|
+
|
|
131
|
+
- Tokens and refresh artifacts persist only in `SecureTokenStore` (Keychain /
|
|
132
|
+
Keystore). Access tokens are kept in memory only.
|
|
133
|
+
- Access-tier decisions prefer stable group **object ids**; display names never
|
|
134
|
+
grant access; missing/overage claims fail closed.
|
|
135
|
+
- Diagnostics (`AuthError`) never contain secrets or token values.
|
|
136
|
+
- Authentication and profile data are collected and transmitted to the
|
|
137
|
+
identity providers and configured Azure resources — update Apple App Privacy
|
|
138
|
+
and Google Play Data Safety before release.
|
|
139
|
+
|
|
140
|
+
## Public API
|
|
141
|
+
|
|
142
|
+
Everything below is exported from `@chipmobilesdk/rn-auth`; deep imports into
|
|
143
|
+
`config/`, `core/`, `tokens/`, `oidc/`, `access/`, `diagnostics/`, or `react/`
|
|
144
|
+
are unsupported.
|
|
145
|
+
|
|
146
|
+
- Controller and configuration: `createAuth`, `Auth`, `AuthConfig`,
|
|
147
|
+
`CreateAuthOptions`, `AuthState`, `AuthStatus`, `RestoreStatus`
|
|
148
|
+
- Identity and access: `Profile`, `ProviderConfig`, `ProviderIdentity`,
|
|
149
|
+
`ResourceConfig`, `AccessTier`, `AccessTierMapping`, `AccessTokenResult`,
|
|
150
|
+
`PremiumGateResult`, `evaluateAccessTier`, `failClosedTier`
|
|
151
|
+
- Ports and default adapters: `SecureTokenStore`, `createKeychainSecureStore`,
|
|
152
|
+
`AUTH_STORAGE_KEY`, `OidcAuthClient`, `createAppAuthClient` and the public OIDC
|
|
153
|
+
request/token types
|
|
154
|
+
- React: `AuthProvider`, `useAuth`, `useProfile`, `useAccessTier`,
|
|
155
|
+
`useAccessToken`, `useSignInProvider`, `useSignInProviders`
|
|
156
|
+
- Diagnostics and copy: `AuthError`, `AuthConfigError`, `isAuthError`, `redact`,
|
|
157
|
+
`DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types
|
|
158
|
+
|
|
159
|
+
## Compatibility and validation
|
|
160
|
+
|
|
161
|
+
React ≥19 · React Native ≥0.85 · `react-native-app-auth` ≥8 ·
|
|
162
|
+
`react-native-keychain` ≥10 · iOS + Android · Node ≥22.11 for tooling.
|
|
163
|
+
|
|
164
|
+
The demo integration lives in `src/screens/AuthDemoScreen.tsx`. Run from the
|
|
165
|
+
repository root:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npm run typecheck:auth
|
|
169
|
+
npm run pack:auth
|
|
170
|
+
npm run validate:auth
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Distribution and license
|
|
174
|
+
|
|
175
|
+
Published as a public npm package (`publishConfig.access: public`) for use by
|
|
176
|
+
the owner's applications. License: `UNLICENSED`; no open-source license is
|
|
177
|
+
granted.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-auth",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/index.ts",
|
|
17
|
+
"src/config",
|
|
18
|
+
"src/core",
|
|
19
|
+
"src/tokens",
|
|
20
|
+
"src/oidc",
|
|
21
|
+
"src/access",
|
|
22
|
+
"src/diagnostics",
|
|
23
|
+
"src/react",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=19",
|
|
34
|
+
"react-native": ">=0.85",
|
|
35
|
+
"react-native-app-auth": ">=8",
|
|
36
|
+
"react-native-keychain": ">=10"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"react-native-app-auth": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"react-native-keychain": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22.11.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org/"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access-tier evaluation (FR-010/FR-011/FR-019/FR-020, R6).
|
|
3
|
+
*
|
|
4
|
+
* Pure function over the authenticated token's claims and the app-specific
|
|
5
|
+
* mapping. Object ids are authoritative; display names never grant access.
|
|
6
|
+
* Missing, stale, ambiguous, or group-overage claims fail closed (premium
|
|
7
|
+
* denied, verification = 'unverified').
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AccessTier, AccessTierMapping, ClaimMatcher } from '../config/types';
|
|
11
|
+
|
|
12
|
+
/** The fail-closed default: free + unverified. */
|
|
13
|
+
export function failClosedTier(): AccessTier {
|
|
14
|
+
return { tier: 'free', source: 'default', verification: 'unverified' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toStringArray(value: unknown): string[] {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.filter((entry): entry is string => typeof entry === 'string');
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'string') {
|
|
22
|
+
return [value];
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entra emits a groups overage indicator (`_claim_names.groups` or `hasgroups`)
|
|
29
|
+
* instead of inline groups when the list is too large. Reading the full list
|
|
30
|
+
* would require Graph (out of scope, FR-012), so overage fails closed.
|
|
31
|
+
*/
|
|
32
|
+
function hasGroupOverage(claims: Record<string, unknown>): boolean {
|
|
33
|
+
if (claims.hasgroups === true) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const claimNames = claims._claim_names as Record<string, unknown> | undefined;
|
|
37
|
+
if (claimNames && typeof claimNames === 'object' && 'groups' in claimNames) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesClaim(claims: Record<string, unknown>, matcher: ClaimMatcher): boolean {
|
|
44
|
+
const value = claims[matcher.claim];
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
return value.includes(matcher.equals);
|
|
47
|
+
}
|
|
48
|
+
return value === matcher.equals;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Evaluate the access tier, failing closed when entitlement is unverifiable. */
|
|
52
|
+
export function evaluateAccessTier(
|
|
53
|
+
claims: Record<string, unknown>,
|
|
54
|
+
mapping: AccessTierMapping,
|
|
55
|
+
now: number = Date.now(),
|
|
56
|
+
): AccessTier {
|
|
57
|
+
// Object ids first (authoritative). Display names are never consulted.
|
|
58
|
+
const groups = toStringArray(claims.groups);
|
|
59
|
+
if (
|
|
60
|
+
mapping.premiumGroupObjectIds.length > 0 &&
|
|
61
|
+
groups.some(group => mapping.premiumGroupObjectIds.includes(group))
|
|
62
|
+
) {
|
|
63
|
+
return { tier: 'premium', source: 'group', verification: 'verified', lastRefreshAt: now };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Roles next.
|
|
67
|
+
const roles = toStringArray(claims.roles);
|
|
68
|
+
if (
|
|
69
|
+
mapping.premiumRoleNames?.length &&
|
|
70
|
+
roles.some(role => mapping.premiumRoleNames!.includes(role))
|
|
71
|
+
) {
|
|
72
|
+
return { tier: 'premium', source: 'role', verification: 'verified', lastRefreshAt: now };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Custom claim matchers last.
|
|
76
|
+
if (mapping.premiumClaimMatchers?.some(matcher => matchesClaim(claims, matcher))) {
|
|
77
|
+
return { tier: 'premium', source: 'claim', verification: 'verified', lastRefreshAt: now };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// No premium indicator matched. If group claims are unavailable due to
|
|
81
|
+
// overage and premium depends on groups, we cannot verify → fail closed.
|
|
82
|
+
if (mapping.premiumGroupObjectIds.length > 0 && hasGroupOverage(claims)) {
|
|
83
|
+
return { tier: 'free', source: 'default', verification: 'unverified', lastRefreshAt: now };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Verified free: claims were present and simply did not grant premium.
|
|
87
|
+
return { tier: 'free', source: 'default', verification: 'verified', lastRefreshAt: now };
|
|
88
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
/** The auth controller returned by `createAuth` (single active account). */
|
|
128
|
+
export interface Auth {
|
|
129
|
+
getState(): AuthState;
|
|
130
|
+
subscribe(listener: (state: AuthState) => void): () => void;
|
|
131
|
+
restore(): Promise<AuthState>;
|
|
132
|
+
signIn(providerId: ProviderId): Promise<AuthState>;
|
|
133
|
+
signOut(): Promise<void>;
|
|
134
|
+
signOutGlobal(): Promise<void>;
|
|
135
|
+
requestAccessToken(resourceKey: string): Promise<AccessTokenResult>;
|
|
136
|
+
getAccessTier(): AccessTier;
|
|
137
|
+
/** Gate a premium-only path; enters `access-denied` when unverified (FR-011). */
|
|
138
|
+
requirePremium(): PremiumGateResult;
|
|
139
|
+
/** The first available provider to present on the current platform (UX-004), or undefined. */
|
|
140
|
+
getPlatformProvider(): ProviderConfig | undefined;
|
|
141
|
+
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
142
|
+
getPlatformProviders(): ProviderConfig[];
|
|
143
|
+
}
|
|
@@ -0,0 +1,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('auth.error.configuration', 'At least one provider must be configured');
|
|
47
|
+
}
|
|
48
|
+
const providerIds = new Set<string>();
|
|
49
|
+
for (const provider of config.providers) {
|
|
50
|
+
if (providerIds.has(provider.id)) {
|
|
51
|
+
throw new AuthConfigError('auth.error.configuration', `Duplicate provider "${provider.id}"`);
|
|
52
|
+
}
|
|
53
|
+
providerIds.add(provider.id);
|
|
54
|
+
if (!provider.domainHint || !provider.domainHint.trim()) {
|
|
55
|
+
throw new AuthConfigError(
|
|
56
|
+
'auth.error.configuration',
|
|
57
|
+
`Provider "${provider.id}" is missing a domainHint`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(provider.platforms) || provider.platforms.length === 0) {
|
|
61
|
+
throw new AuthConfigError(
|
|
62
|
+
'auth.error.configuration',
|
|
63
|
+
`Provider "${provider.id}" must declare at least one platform`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Section D — resources
|
|
69
|
+
if (config.resources) {
|
|
70
|
+
const resourceKeys = new Set<string>();
|
|
71
|
+
for (const resource of config.resources) {
|
|
72
|
+
if (!resource.key || !resource.key.trim()) {
|
|
73
|
+
throw new AuthConfigError('auth.error.configuration', 'A resource is missing "key"');
|
|
74
|
+
}
|
|
75
|
+
if (resourceKeys.has(resource.key)) {
|
|
76
|
+
throw new AuthConfigError('auth.error.configuration', `Duplicate resource "${resource.key}"`);
|
|
77
|
+
}
|
|
78
|
+
resourceKeys.add(resource.key);
|
|
79
|
+
if (!Array.isArray(resource.scopes) || resource.scopes.length === 0) {
|
|
80
|
+
throw new AuthConfigError(
|
|
81
|
+
'auth.error.configuration',
|
|
82
|
+
`Resource "${resource.key}" must declare at least one scope`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Section E — access-tier mapping
|
|
89
|
+
const mapping = config.accessTierMapping;
|
|
90
|
+
if (!mapping) {
|
|
91
|
+
throw new AuthConfigError('auth.error.configuration', 'Missing "accessTierMapping"');
|
|
92
|
+
}
|
|
93
|
+
const hasPremiumIndicator =
|
|
94
|
+
(mapping.premiumGroupObjectIds?.length ?? 0) > 0 ||
|
|
95
|
+
(mapping.premiumRoleNames?.length ?? 0) > 0 ||
|
|
96
|
+
(mapping.premiumClaimMatchers?.length ?? 0) > 0;
|
|
97
|
+
if (!hasPremiumIndicator) {
|
|
98
|
+
throw new AuthConfigError(
|
|
99
|
+
'auth.error.configuration',
|
|
100
|
+
'accessTierMapping must define at least one premium group object id, role name, or claim matcher',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Section F — injected ports
|
|
105
|
+
if (!config.secureStore) {
|
|
106
|
+
throw new AuthConfigError('auth.error.configuration', 'A secureStore is required (FR-021)');
|
|
107
|
+
}
|
|
108
|
+
}
|