@yielded/auth-react-native 0.1.0-beta.13 → 0.1.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,18 @@
1
1
  # @yielded/auth-react-native
2
2
 
3
- Client-local iOS passkey registration, authentication, and capability checks for
4
- `@yielded/auth`. Import `* as ReactNativePasskey` from this package and construct
5
- the adapter with `ReactNativePasskey.make()`, or provide `ReactNativePasskey.layer`.
3
+ Client-local iOS passkeys and browser login for `@yielded/auth`. Applications own
4
+ authentication workflows, HTTP, sessions, and native application configuration.
5
+ Core has no React Native dependency. Passkeys require iOS 16+; browser login's
6
+ minimum depends on the chosen Expo SDK. Android is unsupported.
7
+
8
+ Import the package root for passkeys, or `@yielded/auth-react-native/BrowserLogin`
9
+ for browser login. Install only the optional native peers for the entrypoint you
10
+ use, autolink with CocoaPods, and rebuild the application.
11
+
12
+ ## Passkeys
13
+
14
+ Import `* as ReactNativePasskey` from this package and construct the adapter with
15
+ `ReactNativePasskey.make()`, or provide `ReactNativePasskey.layer`.
6
16
 
7
17
  Install this package and its `react-native` and `react-native-passkey` peers only
8
18
  in the native application. `@yielded/auth` does not depend on React Native or
@@ -17,3 +27,74 @@ prompt, and the shared busy guard stays held until the native promise settles.
17
27
 
18
28
  See the [iOS setup guide](../../docs/src/content/docs/guide/passkeys.md#prompt-in-an-ios-react-native-app)
19
29
  and [API reference](../../docs/src/content/docs/reference/passkey-react-native.md).
30
+
31
+ ## Browser login
32
+
33
+ Open your hosted sign-in page with Expo WebBrowser and keep the native session in
34
+ Expo SecureStore. See the [browser-login guide](../../docs/src/content/docs/guide/browser-login.mdx)
35
+ for the shared client, server policy, and recovery flow.
36
+
37
+ Install the peers matching your Expo SDK:
38
+
39
+ | Expo SDK | `expo-web-browser` | `expo-secure-store` | Minimum iOS |
40
+ | -------- | ------------------ | ------------------- | ----------- |
41
+ | 54 | `~15.0.11` | `~15.0.8` | 16 |
42
+ | 57 | `~57.0.3` | `~57.0.4` | 16.4 |
43
+
44
+ For SDK 54:
45
+
46
+ ```sh
47
+ bun add @yielded/auth-react-native@beta effect expo-web-browser@~15.0.11 expo-secure-store@~15.0.8 react-native-url-polyfill
48
+ ```
49
+
50
+ Plain React Native apps must first [install Expo Modules](https://docs.expo.dev/bare/installing-expo-modules/).
51
+ Rebuild the native app after installing the peers.
52
+
53
+ Load the required [WHATWG URL polyfill](https://github.com/charpeni/react-native-url-polyfill)
54
+ in your application entrypoint before any auth imports:
55
+
56
+ ```ts
57
+ import "react-native-url-polyfill/auto";
58
+ import "./app";
59
+ ```
60
+
61
+ Supply core's `BrowserLogin.Browser` and `BrowserLogin.Vault` with separate Layers:
62
+
63
+ ```ts
64
+ import { Layer } from "effect";
65
+ import * as NativeBrowserLogin from "@yielded/auth-react-native/BrowserLogin";
66
+
67
+ export const NativeLoginLive = Layer.merge(
68
+ NativeBrowserLogin.layerBrowser,
69
+ NativeBrowserLogin.layerVault({ service: "com.example.app.auth.production" }),
70
+ );
71
+ ```
72
+
73
+ Create one vault Layer per authentication lifetime and share it with
74
+ `BrowserLogin.makeClient` and your native HTTP client's credentials service.
75
+ Choose a dedicated `service` per app, server environment, and client ID; keep it
76
+ stable across restarts and allow no other writers. Storage is device-only,
77
+ available while unlocked, and does not use biometric gating. Keychain data can
78
+ survive app deletion.
79
+
80
+ Your app also supplies `Crypto.Crypto` with cryptographically secure random bytes
81
+ and SHA-256. React Native does not supply browser `SubtleCrypto`.
82
+ With [`expo-crypto`](https://docs.expo.dev/versions/v54.0.0/sdk/crypto/), use
83
+ `getRandomValues` and `digest`; `getRandomBytes` can fall back to `Math.random`
84
+ in development.
85
+
86
+ The hosted page must use HTTPS. For a custom callback, register a reverse-domain
87
+ scheme in `CFBundleURLTypes` and the exact return URL, such as
88
+ `com.example.app://auth/callback`, with your server.
89
+
90
+ HTTPS callbacks require iOS 17.4+ and a signed `webcredentials:<callback-host>`
91
+ [Associated Domains entitlement](https://developer.apple.com/documentation/xcode/supporting-associated-domains).
92
+ The host's `apple-app-site-association` file must list the app's `PREFIX.bundleID`
93
+ under `webcredentials.apps`. Register the exact callback URL on the server; app IDs
94
+ belong to your association setup. The optional
95
+ [association helper](../../docs/src/content/docs/reference/browser-login.md#apple-association)
96
+ contributes paths to your app-owned file. Older iOS versions reject HTTPS callbacks.
97
+
98
+ Let this adapter own the authentication session: do not open a competing
99
+ WebBrowser session or handle its callback through `Linking`. On SDK 54, a failed
100
+ presentation can stay pending; interrupt login before trying again.
@@ -0,0 +1,16 @@
1
+ import { Layer } from "effect";
2
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
3
+ //#region src/BrowserLogin.d.ts
4
+ /** iOS ASWebAuthenticationSession; HTTPS callbacks require iOS 17.4+ and
5
+ * associated web credentials domains. Each open owns its Scope and dismissal;
6
+ * interruption retains the shared busy guard until the native promise settles. */
7
+ export declare const layerBrowser: Layer.Layer<BrowserLogin.Browser, never, never>;
8
+ /** Use a fresh, dedicated SecureStore service per app, server environment and client ID.
9
+ * Build this Layer once per authentication lifetime; no other process or Layer
10
+ * may write this service. Keep its name stable across restarts; SecureStore has
11
+ * no cross-process compare-and-set contract. */
12
+ export declare const layerVault: (options: {
13
+ readonly service: string;
14
+ }) => Layer.Layer<BrowserLogin.Vault, BrowserLogin.PlatformError, never>;
15
+ //#endregion
16
+ //# sourceMappingURL=BrowserLogin.d.mts.map
@@ -0,0 +1,174 @@
1
+ import { DateTime, Effect, Layer, Schema, Semaphore } from "effect";
2
+ import { Platform } from "react-native";
3
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
4
+ import { OperationHttpError } from "@yielded/auth/OperationHttp";
5
+ import * as SecureStore from "expo-secure-store";
6
+ import * as WebBrowser from "expo-web-browser";
7
+ //#region src/BrowserLogin.ts
8
+ const failure = (reason) => BrowserLogin.PlatformError.make({ reason });
9
+ const environment = Effect.try({
10
+ try: () => Platform.OS === "ios" && Number.parseInt(String(Platform.Version), 10) >= 16,
11
+ catch: () => failure("unavailable")
12
+ }).pipe(Effect.flatMap((supported) => supported ? Effect.void : Effect.fail(failure("unavailable"))));
13
+ const openInput = Schema.Struct({
14
+ url: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096), Schema.makeFilter((text) => {
15
+ try {
16
+ const url = new URL(text);
17
+ return url.href === text && url.protocol === "https:" && !url.username && !url.password && !url.hash;
18
+ } catch {
19
+ return false;
20
+ }
21
+ })),
22
+ returnUrl: Schema.Union([BrowserLogin.HttpsReturnUrl, BrowserLogin.ReturnUrl.check(Schema.makeFilter((text) => !text.startsWith("https:")))]),
23
+ ephemeral: Schema.Boolean
24
+ });
25
+ const browserResult = Schema.Union([Schema.Struct({
26
+ type: Schema.Literal("success"),
27
+ url: Schema.String.check(Schema.isMaxLength(8192))
28
+ }), Schema.Struct({ type: Schema.Literals(["cancel", "dismiss"]) })]);
29
+ let activeBrowser;
30
+ const settle = (lease) => {
31
+ lease.settled = true;
32
+ if (activeBrowser === lease) activeBrowser = void 0;
33
+ };
34
+ const acquireBrowser = Effect.sync(() => {
35
+ if (activeBrowser !== void 0) return void 0;
36
+ const lease = {
37
+ started: false,
38
+ settled: false,
39
+ listening: true
40
+ };
41
+ activeBrowser = lease;
42
+ return lease;
43
+ }).pipe(Effect.flatMap((lease) => lease === void 0 ? Effect.fail(failure("busy")) : Effect.succeed(lease)));
44
+ const releaseBrowser = (lease) => Effect.sync(() => {
45
+ lease.listening = false;
46
+ if (!lease.started) settle(lease);
47
+ if (lease.settled) return;
48
+ try {
49
+ WebBrowser.dismissAuthSession();
50
+ } catch {}
51
+ });
52
+ const prompt = (lease, input) => Effect.callback((resume, signal) => {
53
+ lease.started = true;
54
+ try {
55
+ const options = {
56
+ preferEphemeralSession: input.ephemeral,
57
+ preferUniversalLinks: true
58
+ };
59
+ WebBrowser.openAuthSessionAsync(input.url, input.returnUrl, options).then((value) => {
60
+ settle(lease);
61
+ if (lease.listening && !signal.aborted) resume(Effect.succeed(value));
62
+ }, () => {
63
+ settle(lease);
64
+ if (lease.listening && !signal.aborted) resume(Effect.fail(failure("unavailable")));
65
+ });
66
+ } catch {
67
+ settle(lease);
68
+ resume(Effect.fail(failure("unavailable")));
69
+ }
70
+ });
71
+ /** iOS ASWebAuthenticationSession; HTTPS callbacks require iOS 17.4+ and
72
+ * associated web credentials domains. Each open owns its Scope and dismissal;
73
+ * interruption retains the shared busy guard until the native promise settles. */
74
+ const layerBrowser = Layer.succeed(BrowserLogin.Browser, BrowserLogin.Browser.of({ open: Effect.fnUntraced(function* (input) {
75
+ yield* environment;
76
+ const request = yield* Schema.decodeEffect(openInput)(input).pipe(Effect.mapError(() => failure("callback")));
77
+ if (request.returnUrl.startsWith("https:")) {
78
+ if (!(yield* Effect.try({
79
+ try: () => {
80
+ const [major = 0, minor = 0] = String(Platform.Version).split(".").map(Number);
81
+ return major > 17 || major === 17 && minor >= 4;
82
+ },
83
+ catch: () => failure("unavailable")
84
+ }))) return yield* failure("unavailable");
85
+ }
86
+ return yield* Effect.scoped(Effect.gen(function* () {
87
+ const lease = yield* Effect.acquireRelease(acquireBrowser, releaseBrowser);
88
+ const result = yield* Schema.decodeUnknownEffect(browserResult)(yield* prompt(lease, request)).pipe(Effect.mapError(() => failure("callback")));
89
+ if (result.type !== "success") return yield* failure("cancelled");
90
+ return yield* Schema.decodeEffect(Schema.String.check(Schema.makeFilter((text) => {
91
+ try {
92
+ const url = new URL(text);
93
+ if (url.href !== text || url.username || url.password || url.hash) return false;
94
+ url.search = "";
95
+ return url.href === request.returnUrl;
96
+ } catch {
97
+ return false;
98
+ }
99
+ })))(result.url).pipe(Effect.mapError(() => failure("callback")));
100
+ }));
101
+ }) }));
102
+ const vaultRecord = BrowserLogin.VaultRecord;
103
+ const slots = vaultRecord.fields.credentials.key.literals;
104
+ const vaultJson = Schema.fromJsonString(vaultRecord);
105
+ const vaultKey = "yielded-auth.browser-login";
106
+ const storedValue = Schema.NullOr(Schema.String.check(Schema.isMaxLength(1048576)));
107
+ /** Use a fresh, dedicated SecureStore service per app, server environment and client ID.
108
+ * Build this Layer once per authentication lifetime; no other process or Layer
109
+ * may write this service. Keep its name stable across restarts; SecureStore has
110
+ * no cross-process compare-and-set contract. */
111
+ const layerVault = (options) => Layer.effect(BrowserLogin.Vault, Effect.gen(function* () {
112
+ yield* environment;
113
+ const storageOptions = {
114
+ keychainService: yield* Schema.decodeEffect(Schema.NonEmptyString.check(Schema.isMaxLength(256)))(options.service).pipe(Effect.mapError(() => failure("storage"))),
115
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
116
+ requireAuthentication: false
117
+ };
118
+ const gate = yield* Semaphore.make(1);
119
+ const readRecord = Effect.gen(function* () {
120
+ const result = yield* Effect.tryPromise({
121
+ try: () => SecureStore.getItemAsync(vaultKey, storageOptions),
122
+ catch: () => failure("storage")
123
+ }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(storedValue)), Effect.mapError(() => failure("storage")));
124
+ if (result === null) return { credentials: {} };
125
+ return yield* Schema.decodeEffect(vaultJson)(result, { onExcessProperty: "error" }).pipe(Effect.mapError(() => failure("storage")));
126
+ });
127
+ const writeRecord = Effect.fnUntraced(function* (record) {
128
+ const encoded = yield* Schema.encodeEffect(vaultJson)(record).pipe(Effect.mapError(() => failure("storage")));
129
+ yield* Effect.tryPromise({
130
+ try: () => SecureStore.setItemAsync(vaultKey, encoded, storageOptions),
131
+ catch: () => failure("storage")
132
+ });
133
+ });
134
+ const locked = (work) => gate.withPermits(1)(Effect.uninterruptible(work));
135
+ const read = locked(Effect.gen(function* () {
136
+ const record = yield* readRecord;
137
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
138
+ const credentials = {};
139
+ for (const slot of slots) {
140
+ const value = record.credentials[slot];
141
+ if (value !== void 0 && value.expiresAtMillis > now) credentials[slot] = value.credential;
142
+ }
143
+ return credentials;
144
+ })).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
145
+ const accept = (commands) => locked(Effect.gen(function* () {
146
+ const record = yield* readRecord;
147
+ const credentials = { ...record.credentials };
148
+ for (const command of commands) if (command._tag === "Clear") delete credentials[command.slot];
149
+ else credentials[command.slot] = {
150
+ credential: command.credential,
151
+ expiresAtMillis: command.expiresAtMillis
152
+ };
153
+ yield* writeRecord({
154
+ ...record,
155
+ credentials
156
+ });
157
+ })).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
158
+ return BrowserLogin.Vault.of({
159
+ read,
160
+ accept,
161
+ loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),
162
+ saveAttempt: (attempt) => locked(Effect.gen(function* () {
163
+ const record = yield* readRecord;
164
+ yield* writeRecord(attempt === void 0 ? { credentials: record.credentials } : {
165
+ ...record,
166
+ attempt
167
+ });
168
+ }))
169
+ });
170
+ }));
171
+ //#endregion
172
+ export { layerBrowser, layerVault };
173
+
174
+ //# sourceMappingURL=BrowserLogin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrowserLogin.mjs","names":[],"sources":["../src/BrowserLogin.ts"],"sourcesContent":["import * as BrowserLogin from \"@yielded/auth/BrowserLogin\";\nimport { OperationHttpError } from \"@yielded/auth/OperationHttp\";\nimport type { NativeCredentials } from \"@yielded/auth/OperationHttpClient\";\nimport type { CredentialSlot } from \"@yielded/auth/Operations\";\nimport { DateTime, Effect, Layer, type Redacted, Schema, Semaphore } from \"effect\";\nimport * as SecureStore from \"expo-secure-store\";\nimport * as WebBrowser from \"expo-web-browser\";\nimport { Platform } from \"react-native\";\n\nconst failure = (reason: BrowserLogin.PlatformError[\"reason\"]) =>\n BrowserLogin.PlatformError.make({ reason });\n\nconst environment = Effect.try({\n try: () => Platform.OS === \"ios\" && Number.parseInt(String(Platform.Version), 10) >= 16,\n catch: () => failure(\"unavailable\"),\n}).pipe(\n Effect.flatMap((supported) => (supported ? Effect.void : Effect.fail(failure(\"unavailable\")))),\n);\n\nconst openInput = Schema.Struct({\n url: Schema.String.check(\n Schema.isMinLength(1),\n Schema.isMaxLength(4096),\n Schema.makeFilter((text) => {\n try {\n const url = new URL(text);\n\n return (\n url.href === text &&\n url.protocol === \"https:\" &&\n !url.username &&\n !url.password &&\n !url.hash\n );\n } catch {\n return false;\n }\n }),\n ),\n returnUrl: Schema.Union([\n BrowserLogin.HttpsReturnUrl,\n BrowserLogin.ReturnUrl.check(Schema.makeFilter((text) => !text.startsWith(\"https:\"))),\n ]),\n ephemeral: Schema.Boolean,\n});\n\nconst browserResult = Schema.Union([\n Schema.Struct({\n type: Schema.Literal(\"success\"),\n url: Schema.String.check(Schema.isMaxLength(8192)),\n }),\n Schema.Struct({ type: Schema.Literals([\"cancel\", \"dismiss\"]) }),\n]);\n\ninterface Lease {\n started: boolean;\n settled: boolean;\n listening: boolean;\n}\n\n// The bridge owns a single native resolver. All Layers in this installed module\n// share admission; applications must not call the peer independently.\nlet activeBrowser: Lease | undefined;\n\nconst settle = (lease: Lease) => {\n lease.settled = true;\n if (activeBrowser === lease) activeBrowser = undefined;\n};\n\nconst acquireBrowser = Effect.sync(() => {\n if (activeBrowser !== undefined) return undefined;\n const lease: Lease = { started: false, settled: false, listening: true };\n\n activeBrowser = lease;\n\n return lease;\n}).pipe(\n Effect.flatMap((lease) =>\n lease === undefined ? Effect.fail(failure(\"busy\")) : Effect.succeed(lease),\n ),\n);\n\nconst releaseBrowser = (lease: Lease) =>\n Effect.sync(() => {\n lease.listening = false;\n if (!lease.started) settle(lease);\n if (lease.settled) return;\n try {\n WebBrowser.dismissAuthSession();\n } catch {\n // Dismissal is best-effort. Keep admission held until native settlement even\n // if dismissal fails; never deliver a late callback to another attempt.\n }\n });\n\nconst prompt = (lease: Lease, input: typeof openInput.Type) =>\n Effect.callback<unknown, BrowserLogin.PlatformError>((resume, signal) => {\n lease.started = true;\n try {\n // SDK 54 binds HTTPS callbacks directly; SDK 57 requires this opt-in.\n const options = {\n preferEphemeralSession: input.ephemeral,\n preferUniversalLinks: true,\n };\n\n void WebBrowser.openAuthSessionAsync(input.url, input.returnUrl, options).then(\n (value: unknown) => {\n settle(lease);\n if (lease.listening && !signal.aborted) resume(Effect.succeed(value));\n },\n () => {\n settle(lease);\n if (lease.listening && !signal.aborted) resume(Effect.fail(failure(\"unavailable\")));\n },\n );\n } catch {\n settle(lease);\n resume(Effect.fail(failure(\"unavailable\")));\n }\n });\n\n/** iOS ASWebAuthenticationSession; HTTPS callbacks require iOS 17.4+ and\n * associated web credentials domains. Each open owns its Scope and dismissal;\n * interruption retains the shared busy guard until the native promise settles. */\nexport const layerBrowser = Layer.succeed(\n BrowserLogin.Browser,\n BrowserLogin.Browser.of({\n open: Effect.fnUntraced(function* (input) {\n yield* environment;\n\n const request = yield* Schema.decodeEffect(openInput)(input).pipe(\n Effect.mapError(() => failure(\"callback\")),\n );\n\n if (request.returnUrl.startsWith(\"https:\")) {\n // Reject before invoking Expo's fallback to callbackURLScheme on older iOS.\n const supported = yield* Effect.try({\n try: () => {\n const [major = 0, minor = 0] = String(Platform.Version).split(\".\").map(Number);\n\n return major > 17 || (major === 17 && minor >= 4);\n },\n catch: () => failure(\"unavailable\"),\n });\n\n if (!supported) return yield* failure(\"unavailable\");\n }\n\n return yield* Effect.scoped(\n Effect.gen(function* () {\n const lease = yield* Effect.acquireRelease(acquireBrowser, releaseBrowser);\n\n const result = yield* Schema.decodeUnknownEffect(browserResult)(\n yield* prompt(lease, request),\n ).pipe(Effect.mapError(() => failure(\"callback\")));\n\n if (result.type !== \"success\") return yield* failure(\"cancelled\");\n\n // Bind the full target even when the OS matches only the custom scheme;\n // core owns state/code validation and the exchange decision.\n return yield* Schema.decodeEffect(\n Schema.String.check(\n Schema.makeFilter((text) => {\n try {\n const url = new URL(text);\n\n if (url.href !== text || url.username || url.password || url.hash) return false;\n url.search = \"\";\n\n return url.href === request.returnUrl;\n } catch {\n return false;\n }\n }),\n ),\n )(result.url).pipe(Effect.mapError(() => failure(\"callback\")));\n }),\n );\n }),\n }),\n);\n\nconst vaultRecord = BrowserLogin.VaultRecord;\nconst slots = vaultRecord.fields.credentials.key.literals;\n\nconst vaultJson = Schema.fromJsonString(vaultRecord);\n\nconst vaultKey = \"yielded-auth.browser-login\";\nconst storedValue = Schema.NullOr(Schema.String.check(Schema.isMaxLength(1048576)));\n\n/** Use a fresh, dedicated SecureStore service per app, server environment and client ID.\n * Build this Layer once per authentication lifetime; no other process or Layer\n * may write this service. Keep its name stable across restarts; SecureStore has\n * no cross-process compare-and-set contract. */\nexport const layerVault = (options: { readonly service: string }) =>\n Layer.effect(\n BrowserLogin.Vault,\n Effect.gen(function* () {\n yield* environment;\n\n const service = yield* Schema.decodeEffect(\n Schema.NonEmptyString.check(Schema.isMaxLength(256)),\n )(options.service).pipe(Effect.mapError(() => failure(\"storage\")));\n\n const storageOptions = {\n keychainService: service,\n keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,\n // Biometric changes must not invalidate the exchange fence.\n requireAuthentication: false,\n } satisfies SecureStore.SecureStoreOptions;\n\n const gate = yield* Semaphore.make(1);\n\n const readRecord: Effect.Effect<typeof vaultRecord.Type, BrowserLogin.PlatformError> =\n Effect.gen(function* () {\n const result = yield* Effect.tryPromise({\n try: () => SecureStore.getItemAsync(vaultKey, storageOptions),\n catch: () => failure(\"storage\"),\n }).pipe(\n Effect.flatMap(Schema.decodeUnknownEffect(storedValue)),\n Effect.mapError(() => failure(\"storage\")),\n );\n\n if (result === null) return { credentials: {} } satisfies typeof vaultRecord.Type;\n\n return yield* Schema.decodeEffect(vaultJson)(result, {\n onExcessProperty: \"error\",\n }).pipe(Effect.mapError(() => failure(\"storage\")));\n });\n\n const writeRecord = Effect.fnUntraced(function* (record: typeof vaultRecord.Type) {\n const encoded = yield* Schema.encodeEffect(vaultJson)(record).pipe(\n Effect.mapError(() => failure(\"storage\")),\n );\n\n // SecureStore updates existing iOS items with SecItemUpdate. Always replace\n // the whole record, including clears; never delete the item before writing.\n yield* Effect.tryPromise({\n try: () => SecureStore.setItemAsync(vaultKey, encoded, storageOptions),\n catch: () => failure(\"storage\"),\n });\n });\n\n // SecureStore promises are not cancellable. Admitted operations must settle\n // before releasing this gate or acknowledging an Exchanging checkpoint.\n const locked = <A, E>(work: Effect.Effect<A, E>) =>\n gate.withPermits(1)(Effect.uninterruptible(work));\n\n const read: NativeCredentials[\"read\"] = locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n const now = DateTime.toEpochMillis(yield* DateTime.now);\n const credentials: Partial<Record<CredentialSlot, Redacted.Redacted<string>>> = {};\n\n for (const slot of slots) {\n const value = record.credentials[slot];\n\n if (value !== undefined && value.expiresAtMillis > now)\n credentials[slot] = value.credential;\n }\n\n return credentials;\n }),\n ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: \"credentials\" })));\n\n const accept: NativeCredentials[\"accept\"] = (commands) =>\n locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n const credentials = { ...record.credentials };\n\n for (const command of commands) {\n if (command._tag === \"Clear\") delete credentials[command.slot];\n else\n credentials[command.slot] = {\n credential: command.credential,\n expiresAtMillis: command.expiresAtMillis,\n };\n }\n yield* writeRecord({ ...record, credentials });\n }),\n ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: \"credentials\" })));\n\n return BrowserLogin.Vault.of({\n read,\n accept,\n loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),\n saveAttempt: (attempt) =>\n locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n\n yield* writeRecord(\n attempt === undefined\n ? { credentials: record.credentials }\n : { ...record, attempt },\n );\n }),\n ),\n });\n }),\n );\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@yielded/auth-react-native","version":"0.1.0-beta.13","dependencies":{"@yielded/auth":"0.1.0-beta.13"},"devDependencies":{"@effect/vitest":"4.0.0","effect":"4.0.0","react-native":"0.81.4","react-native-passkey":"3.6.2","typescript":"7.0.2","vite-plus":"0.3.3"},"peerDependencies":{"effect":"^4.0.0","react-native":">=0.81 <1","react-native-passkey":"~3.6.2"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"}},"description":"iOS passkey ceremonies for Yielded Auth and React Native.","homepage":"https://yielded.dev/auth/","bugs":{"url":"https://github.com/yielded-dev/auth/issues"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/yielded-dev/auth.git","directory":"packages/auth-react-native"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json","test":"vp test --passWithNoTests"}}
1
+ {"name":"@yielded/auth-react-native","version":"0.1.0-beta.15","dependencies":{"@yielded/auth":"0.1.0-beta.15"},"devDependencies":{"@effect/vitest":"4.0.0","effect":"4.0.0","expo":"54.0.37","expo-secure-store":"15.0.8","expo-web-browser":"15.0.11","react-native":"0.81.4","react-native-passkey":"3.6.2","typescript":"7.0.2","vite-plus":"0.3.3"},"peerDependencies":{"effect":"^4.0.0","expo-secure-store":"~15.0.8 || ~57.0.4","expo-web-browser":"~15.0.11 || ~57.0.3","react-native":">=0.81 <1","react-native-passkey":"~3.6.2"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./BrowserLogin":{"types":"./dist/BrowserLogin.d.mts","default":"./dist/BrowserLogin.mjs"}},"description":"iOS passkey and browser login adapters for Yielded Auth and React Native.","homepage":"https://yielded.dev/auth/","bugs":{"url":"https://github.com/yielded-dev/auth/issues"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/yielded-dev/auth.git","directory":"packages/auth-react-native"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json","test":"vp test --passWithNoTests"},"peerDependenciesMeta":{"expo-secure-store":{"optional":true},"expo-web-browser":{"optional":true},"react-native-passkey":{"optional":true}}}
@@ -0,0 +1,302 @@
1
+ import * as BrowserLogin from "@yielded/auth/BrowserLogin";
2
+ import { OperationHttpError } from "@yielded/auth/OperationHttp";
3
+ import type { NativeCredentials } from "@yielded/auth/OperationHttpClient";
4
+ import type { CredentialSlot } from "@yielded/auth/Operations";
5
+ import { DateTime, Effect, Layer, type Redacted, Schema, Semaphore } from "effect";
6
+ import * as SecureStore from "expo-secure-store";
7
+ import * as WebBrowser from "expo-web-browser";
8
+ import { Platform } from "react-native";
9
+
10
+ const failure = (reason: BrowserLogin.PlatformError["reason"]) =>
11
+ BrowserLogin.PlatformError.make({ reason });
12
+
13
+ const environment = Effect.try({
14
+ try: () => Platform.OS === "ios" && Number.parseInt(String(Platform.Version), 10) >= 16,
15
+ catch: () => failure("unavailable"),
16
+ }).pipe(
17
+ Effect.flatMap((supported) => (supported ? Effect.void : Effect.fail(failure("unavailable")))),
18
+ );
19
+
20
+ const openInput = Schema.Struct({
21
+ url: Schema.String.check(
22
+ Schema.isMinLength(1),
23
+ Schema.isMaxLength(4096),
24
+ Schema.makeFilter((text) => {
25
+ try {
26
+ const url = new URL(text);
27
+
28
+ return (
29
+ url.href === text &&
30
+ url.protocol === "https:" &&
31
+ !url.username &&
32
+ !url.password &&
33
+ !url.hash
34
+ );
35
+ } catch {
36
+ return false;
37
+ }
38
+ }),
39
+ ),
40
+ returnUrl: Schema.Union([
41
+ BrowserLogin.HttpsReturnUrl,
42
+ BrowserLogin.ReturnUrl.check(Schema.makeFilter((text) => !text.startsWith("https:"))),
43
+ ]),
44
+ ephemeral: Schema.Boolean,
45
+ });
46
+
47
+ const browserResult = Schema.Union([
48
+ Schema.Struct({
49
+ type: Schema.Literal("success"),
50
+ url: Schema.String.check(Schema.isMaxLength(8192)),
51
+ }),
52
+ Schema.Struct({ type: Schema.Literals(["cancel", "dismiss"]) }),
53
+ ]);
54
+
55
+ interface Lease {
56
+ started: boolean;
57
+ settled: boolean;
58
+ listening: boolean;
59
+ }
60
+
61
+ // The bridge owns a single native resolver. All Layers in this installed module
62
+ // share admission; applications must not call the peer independently.
63
+ let activeBrowser: Lease | undefined;
64
+
65
+ const settle = (lease: Lease) => {
66
+ lease.settled = true;
67
+ if (activeBrowser === lease) activeBrowser = undefined;
68
+ };
69
+
70
+ const acquireBrowser = Effect.sync(() => {
71
+ if (activeBrowser !== undefined) return undefined;
72
+ const lease: Lease = { started: false, settled: false, listening: true };
73
+
74
+ activeBrowser = lease;
75
+
76
+ return lease;
77
+ }).pipe(
78
+ Effect.flatMap((lease) =>
79
+ lease === undefined ? Effect.fail(failure("busy")) : Effect.succeed(lease),
80
+ ),
81
+ );
82
+
83
+ const releaseBrowser = (lease: Lease) =>
84
+ Effect.sync(() => {
85
+ lease.listening = false;
86
+ if (!lease.started) settle(lease);
87
+ if (lease.settled) return;
88
+ try {
89
+ WebBrowser.dismissAuthSession();
90
+ } catch {
91
+ // Dismissal is best-effort. Keep admission held until native settlement even
92
+ // if dismissal fails; never deliver a late callback to another attempt.
93
+ }
94
+ });
95
+
96
+ const prompt = (lease: Lease, input: typeof openInput.Type) =>
97
+ Effect.callback<unknown, BrowserLogin.PlatformError>((resume, signal) => {
98
+ lease.started = true;
99
+ try {
100
+ // SDK 54 binds HTTPS callbacks directly; SDK 57 requires this opt-in.
101
+ const options = {
102
+ preferEphemeralSession: input.ephemeral,
103
+ preferUniversalLinks: true,
104
+ };
105
+
106
+ void WebBrowser.openAuthSessionAsync(input.url, input.returnUrl, options).then(
107
+ (value: unknown) => {
108
+ settle(lease);
109
+ if (lease.listening && !signal.aborted) resume(Effect.succeed(value));
110
+ },
111
+ () => {
112
+ settle(lease);
113
+ if (lease.listening && !signal.aborted) resume(Effect.fail(failure("unavailable")));
114
+ },
115
+ );
116
+ } catch {
117
+ settle(lease);
118
+ resume(Effect.fail(failure("unavailable")));
119
+ }
120
+ });
121
+
122
+ /** iOS ASWebAuthenticationSession; HTTPS callbacks require iOS 17.4+ and
123
+ * associated web credentials domains. Each open owns its Scope and dismissal;
124
+ * interruption retains the shared busy guard until the native promise settles. */
125
+ export const layerBrowser = Layer.succeed(
126
+ BrowserLogin.Browser,
127
+ BrowserLogin.Browser.of({
128
+ open: Effect.fnUntraced(function* (input) {
129
+ yield* environment;
130
+
131
+ const request = yield* Schema.decodeEffect(openInput)(input).pipe(
132
+ Effect.mapError(() => failure("callback")),
133
+ );
134
+
135
+ if (request.returnUrl.startsWith("https:")) {
136
+ // Reject before invoking Expo's fallback to callbackURLScheme on older iOS.
137
+ const supported = yield* Effect.try({
138
+ try: () => {
139
+ const [major = 0, minor = 0] = String(Platform.Version).split(".").map(Number);
140
+
141
+ return major > 17 || (major === 17 && minor >= 4);
142
+ },
143
+ catch: () => failure("unavailable"),
144
+ });
145
+
146
+ if (!supported) return yield* failure("unavailable");
147
+ }
148
+
149
+ return yield* Effect.scoped(
150
+ Effect.gen(function* () {
151
+ const lease = yield* Effect.acquireRelease(acquireBrowser, releaseBrowser);
152
+
153
+ const result = yield* Schema.decodeUnknownEffect(browserResult)(
154
+ yield* prompt(lease, request),
155
+ ).pipe(Effect.mapError(() => failure("callback")));
156
+
157
+ if (result.type !== "success") return yield* failure("cancelled");
158
+
159
+ // Bind the full target even when the OS matches only the custom scheme;
160
+ // core owns state/code validation and the exchange decision.
161
+ return yield* Schema.decodeEffect(
162
+ Schema.String.check(
163
+ Schema.makeFilter((text) => {
164
+ try {
165
+ const url = new URL(text);
166
+
167
+ if (url.href !== text || url.username || url.password || url.hash) return false;
168
+ url.search = "";
169
+
170
+ return url.href === request.returnUrl;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }),
175
+ ),
176
+ )(result.url).pipe(Effect.mapError(() => failure("callback")));
177
+ }),
178
+ );
179
+ }),
180
+ }),
181
+ );
182
+
183
+ const vaultRecord = BrowserLogin.VaultRecord;
184
+ const slots = vaultRecord.fields.credentials.key.literals;
185
+
186
+ const vaultJson = Schema.fromJsonString(vaultRecord);
187
+
188
+ const vaultKey = "yielded-auth.browser-login";
189
+ const storedValue = Schema.NullOr(Schema.String.check(Schema.isMaxLength(1048576)));
190
+
191
+ /** Use a fresh, dedicated SecureStore service per app, server environment and client ID.
192
+ * Build this Layer once per authentication lifetime; no other process or Layer
193
+ * may write this service. Keep its name stable across restarts; SecureStore has
194
+ * no cross-process compare-and-set contract. */
195
+ export const layerVault = (options: { readonly service: string }) =>
196
+ Layer.effect(
197
+ BrowserLogin.Vault,
198
+ Effect.gen(function* () {
199
+ yield* environment;
200
+
201
+ const service = yield* Schema.decodeEffect(
202
+ Schema.NonEmptyString.check(Schema.isMaxLength(256)),
203
+ )(options.service).pipe(Effect.mapError(() => failure("storage")));
204
+
205
+ const storageOptions = {
206
+ keychainService: service,
207
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
208
+ // Biometric changes must not invalidate the exchange fence.
209
+ requireAuthentication: false,
210
+ } satisfies SecureStore.SecureStoreOptions;
211
+
212
+ const gate = yield* Semaphore.make(1);
213
+
214
+ const readRecord: Effect.Effect<typeof vaultRecord.Type, BrowserLogin.PlatformError> =
215
+ Effect.gen(function* () {
216
+ const result = yield* Effect.tryPromise({
217
+ try: () => SecureStore.getItemAsync(vaultKey, storageOptions),
218
+ catch: () => failure("storage"),
219
+ }).pipe(
220
+ Effect.flatMap(Schema.decodeUnknownEffect(storedValue)),
221
+ Effect.mapError(() => failure("storage")),
222
+ );
223
+
224
+ if (result === null) return { credentials: {} } satisfies typeof vaultRecord.Type;
225
+
226
+ return yield* Schema.decodeEffect(vaultJson)(result, {
227
+ onExcessProperty: "error",
228
+ }).pipe(Effect.mapError(() => failure("storage")));
229
+ });
230
+
231
+ const writeRecord = Effect.fnUntraced(function* (record: typeof vaultRecord.Type) {
232
+ const encoded = yield* Schema.encodeEffect(vaultJson)(record).pipe(
233
+ Effect.mapError(() => failure("storage")),
234
+ );
235
+
236
+ // SecureStore updates existing iOS items with SecItemUpdate. Always replace
237
+ // the whole record, including clears; never delete the item before writing.
238
+ yield* Effect.tryPromise({
239
+ try: () => SecureStore.setItemAsync(vaultKey, encoded, storageOptions),
240
+ catch: () => failure("storage"),
241
+ });
242
+ });
243
+
244
+ // SecureStore promises are not cancellable. Admitted operations must settle
245
+ // before releasing this gate or acknowledging an Exchanging checkpoint.
246
+ const locked = <A, E>(work: Effect.Effect<A, E>) =>
247
+ gate.withPermits(1)(Effect.uninterruptible(work));
248
+
249
+ const read: NativeCredentials["read"] = locked(
250
+ Effect.gen(function* () {
251
+ const record = yield* readRecord;
252
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
253
+ const credentials: Partial<Record<CredentialSlot, Redacted.Redacted<string>>> = {};
254
+
255
+ for (const slot of slots) {
256
+ const value = record.credentials[slot];
257
+
258
+ if (value !== undefined && value.expiresAtMillis > now)
259
+ credentials[slot] = value.credential;
260
+ }
261
+
262
+ return credentials;
263
+ }),
264
+ ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
265
+
266
+ const accept: NativeCredentials["accept"] = (commands) =>
267
+ locked(
268
+ Effect.gen(function* () {
269
+ const record = yield* readRecord;
270
+ const credentials = { ...record.credentials };
271
+
272
+ for (const command of commands) {
273
+ if (command._tag === "Clear") delete credentials[command.slot];
274
+ else
275
+ credentials[command.slot] = {
276
+ credential: command.credential,
277
+ expiresAtMillis: command.expiresAtMillis,
278
+ };
279
+ }
280
+ yield* writeRecord({ ...record, credentials });
281
+ }),
282
+ ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
283
+
284
+ return BrowserLogin.Vault.of({
285
+ read,
286
+ accept,
287
+ loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),
288
+ saveAttempt: (attempt) =>
289
+ locked(
290
+ Effect.gen(function* () {
291
+ const record = yield* readRecord;
292
+
293
+ yield* writeRecord(
294
+ attempt === undefined
295
+ ? { credentials: record.credentials }
296
+ : { ...record, attempt },
297
+ );
298
+ }),
299
+ ),
300
+ });
301
+ }),
302
+ );