@iaurelius/accounts-expo
v0.4.0
Published
Accounts by iAurelius for Expo: sign-in on the hosted page in an authentication session, tokens in SecureStore, useAuth() and getToken() that refreshes itself.
Maintainers
Readme
@iaurelius/accounts-expo
Accounts by iAurelius for Expo (iOS, Android and web).
People sign in on your application's hosted page, with passkeys, email codes and Google. The page opens in the system's authentication session: ASWebAuthenticationSession on iOS, Custom Tabs on Android. The SDK then:
- keeps the tokens in SecureStore;
- refreshes the access token when it is about to expire;
- signs out here and on the hosted pages.
Quick start
npx @iaurelius/accounts init --code aci_… # the command from the console: Apps and APIs → Integrate your appWhat it does:
- registers your mobile app with its bundle id as the scheme (
com.example.app:/oauth/callback, RFC 8252); - installs this package and its Expo modules with
npx expo install; - writes
.env.local; - adds the scheme to
app.json.
Rebuild the app afterwards, because the scheme is native. By hand, the steps are:
npx expo install @iaurelius/accounts-expo expo-crypto expo-secure-store expo-web-browser// app/_layout.tsx
import { AccountsProvider } from "@iaurelius/accounts-expo";
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<AccountsProvider
publishableKey={process.env.EXPO_PUBLIC_ACCOUNTS_PUBLISHABLE_KEY!}
clientId={process.env.EXPO_PUBLIC_ACCOUNTS_CLIENT_ID!}
scheme="com.example.app"
>
<Stack />
</AccountsProvider>
);
}import { SignedIn, SignedOut, useAuth, useUser } from "@iaurelius/accounts-expo";
import { Button, Text } from "react-native";
function Profile() {
const { signIn, signOut, getToken } = useAuth();
const { user } = useUser();
return (
<>
<SignedOut><Button title="Sign in" onPress={signIn} /></SignedOut>
<SignedIn>
<Text>{user?.email}</Text>
<Button title="Call my API" onPress={async () => fetch("https://api.example.com/me", { headers: { authorization: `Bearer ${await getToken()}` } })} />
<Button title="Sign out" onPress={() => signOut()} />
</SignedIn>
</>
);
}On the web, add the route app/oauth/callback.tsx, which calls completeSignIn() from this package.
Your own sign-in screen
Instead of the hosted page, the app can sign people in on its own screens, with no browser: a code to
their email, then the code. Someone new signs up the same way where the application is public; where
it has a waitlist, sendEmailCode answers { waitlist: true } and sends no code.
const { sendEmailCode, signInWithEmailCode } = useAuth();
await sendEmailCode(email); // step 1: the code goes to the email
await signInWithEmailCode(email, code); // step 2: signed in (throws OAuthError: INVALID_OTP, ACCOUNT_LOCKED…)Only the application's native apps may do this (registered as mobile in the console). The hosted page's bot check (Turnstile) cannot run on a native screen: the per-address and per-IP limits still apply.
- Both steps throw an
OAuthError. A refusal keeps the application's code (INVALID_OTP,ACCOUNT_LOCKED,ACCOUNT_OTP_LIMITED,organization_required…). A limit without a code of its own istoo_many_requests(status429), and a5xxor no answertemporarily_unavailable; both carryretryAfterin seconds when the application says how long to wait. None of them touches the session the device already had. - The code for the session waits up to a minute, like the hosted sign-in's code exchange, and is sent once: the application spends it (and issues a refresh token) as soon as it arrives, so giving up after 10 seconds would waste it and sending it again would only be refused.
- If the person signs out while the code is on its way, they stay signed out and the refresh token its answer brings is revoked. A refresh of the session before, still on its way, does not bring that one back.
Tokens and the network
getToken()gives a token for youraudience, refreshed a minute before it expires. While the token still lasts,getToken()gives it at once and the refresh goes on in the background, so a network that hangs holds no call; once it has expired,getToken()waits for the refresh. When your API refuses one (a 401), ask once withgetToken({ forceRefresh: true }):- Several refusals of the same token make a single refresh, and a token that a forced refresh gave less than 30 seconds ago is given again (an API that always answers 401 cannot use up the application's limit).
- It resolves to
nullonly when the session is over (then sign the person out). If no new token can be had now (no network, or the application asked to wait), it throws anOAuthError(exported by this package) witherror: "temporarily_unavailable": the person is still signed in; try again later.
- On start, the session kept on the device shows at once (
isLoadedandisSignedIn), and its refresh follows. Offline, the person stays signed in with the token they have. - Each request to your application gives up after 10 seconds (
requestTimeoutMson<AccountsProvider>), except those to the token endpoint:- A refresh that gets no answer in 8 seconds is sent once more, at once and identical, and that second try waits up to a minute. When the application processes a refresh it rotates the refresh token, and it answers the old one again only for a few seconds; after that, presenting the old one ends every session of the person in your app. Sending it again early is what keeps the second try within those seconds.
- The sign-in's code exchange waits up to a minute and is sent once more if no answer comes. A code sent twice is refused and the refresh token it issued is revoked, so none is left alive on no device.
Security
- Sign-in: authorization code with PKCE (S256),
stateandnonce, and RFC 9207'sisschecked. The app is public, with no secret on the device. - ID token: it comes straight from the token endpoint over TLS, so its issuer, audience, nonce and expiry are checked (OIDC Core 3.1.3.7).
- On the device: tokens stay in SecureStore, split in parts because Android is unreliable past 2 KB.
- Revocation: a person who is banned or signed out everywhere is out at the next refresh. Sign-out revokes the refresh token.
- Refresh: only a refusal for good signs the person out: a
4xxwith an OAuth error, such asinvalid_grant(revoked, banned),invalid_client,invalid_scopeorinvalid_target(youraudiencechanged). A429(many phones behind one carrier IP), a5xx, no answer or a4xxwithout an OAuth error (a firewall's page) keeps the session and its refresh token; the access token serves while it lasts, and no refresh is tried again before the server'sRetry-After. A refreshed ID token must be the same person (iss,sub,aud), and userinfo'ssubmust match. - Sign-out during a refresh or a sign-in: a refresh that was on its way when the person signed out (or signed in again) does not bring the old session back, and a sign-in (hosted page or your own screen) that was on its way when they signed out does not sign them in: its answer is dropped and its refresh token revoked. A sign-in answer whose ID token is not for that sign-in is refused, and its refresh token revoked too.
- Bundle size: the package uses no Node APIs and no WebCrypto (
expo-cryptoinstead), and does not pull jose into your bundle.
License
MIT
