@plannorium/id
v0.3.0
Published
Browser SDK for Plannorium Identity — popup sign-in with PKCE, origin-bound sessions, and short-lived credentials.
Maintainers
Readme
@plannorium/id
Add "Continue with Plannorium" to a web or mobile app. Plannorium Identity owns login, MFA, consent, account selection and the whole window lifecycle — your app gets a signed result and creates its own session.
Status: pre-1.0. The protocol is still being hardened and may change. Pin an exact version and read the release notes before upgrading.
npm install @plannorium/idOne package, two entry points. Pick the one that matches where your app runs.
| | Web app | React Native / Expo |
| --- | --- | --- |
| Import | @plannorium/id | @plannorium/id/expo (or /native) |
| Class | PlannoriumID | PlannoriumIDNative |
| Opens | a popup window | the system browser |
| Comes back via | postMessage | a deep link |
| You receive | a short-lived credential | an access token (+ refresh) |
| Client secret | never | never |
| Extra packages | none | expo-web-browser + expo-crypto |
Both use Authorization Code + PKCE (S256). Neither ever puts a client secret in client code, because anything shipped to a browser or an app binary can be read out of it.
"Can I just use a WebView?"
No — and this is the one place worth being blunt, because a WebView looks like the easy answer.
An embedded WebView is controlled by the app hosting it. The app can read everything typed into it, including a password, so the user has no way to tell a genuine login from a convincing imitation — and neither do we. Two more consequences follow from the same fact:
- It cannot see the browser's existing session, so people sign in again every single time, even when already signed in on that device.
- Passkeys do not work in it. They are bound to the browser's origin handling, which a WebView does not participate in.
RFC 8252 §8.12 says
native apps MUST NOT use embedded user agents for authorization.
ASWebAuthenticationSession (iOS) and Custom Tabs (Android) exist precisely so
you do not have to: they are the real browser, with the real address bar and the
real session, presented as a sheet over your app. @plannorium/id/native uses
those, which is also what Google's own mobile sign-in does.
Web: popup sign-in
import { PlannoriumID } from "@plannorium/id";
const identity = new PlannoriumID({
clientId: "plannorium_...",
issuer: "https://id.plannorium.com",
});
// Call this DIRECTLY from a click. Browsers only allow a popup that opens in
// the same task as the user's gesture — wrap it in an await first and it is
// silently blocked, even though a real click triggered it.
button.addEventListener("click", async () => {
try {
const result = await identity.signIn({
scopes: ["openid", "profile", "email"],
});
await fetch("/api/auth/plannorium", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential: result.credential }),
});
} catch (err) {
if (err.code === "popup_blocked") {
// Ask the user to allow popups, or fall back to a full-page redirect.
}
}
});Script tag / CDN
<script src="https://id.plannorium.com/sdk/v1/plannorium-id.js"></script>
<script>
const identity = new PlannoriumID({ clientId: "plannorium_..." });
</script>Exposes window.PlannoriumID (and window.Plannorium.PlannoriumID). Built from
the same source as the npm package, so the two never drift apart.
Native: React Native / Expo
npm install @plannorium/id expo-web-browser expo-cryptoimport { createPlannoriumID } from "@plannorium/id/expo";
const identity = createPlannoriumID({
clientId: "plannorium_...",
redirectUri: "com.yourcompany.app://auth", // must be registered on the client
issuer: "https://id.plannorium.com",
});
const result = await identity.signIn({ scopes: ["openid", "profile", "email"] });
// result.credential — an ID token. Send it to YOUR backend to verify (below).That is the whole integration. @plannorium/id/expo wires the three platform
adapters to Expo's modules for you.
Wiring it yourself
Use @plannorium/id/native if you are not on Expo. It imports nothing from
react-native or expo, so the package stays installable in a plain web
project — which means you supply the platform pieces:
import { PlannoriumIDNative } from "@plannorium/id/native";
import * as WebBrowser from "expo-web-browser";
import * as Crypto from "expo-crypto";
const identity = new PlannoriumIDNative({
clientId: "plannorium_...",
redirectUri: "com.yourcompany.app://auth",
openAuthSession: (url, redirectUri) =>
WebBrowser.openAuthSessionAsync(url, redirectUri),
// React Native has no Web Crypto. BOTH of these are required there:
// `sha256` for the PKCE challenge, `getRandomBytes` for the PKCE verifier,
// `state` and `nonce`. Without the second, signIn() throws before the
// browser ever opens.
getRandomBytes: Crypto.getRandomBytes,
sha256: async (value) => {
const b64 = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
value,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
},
});Any browser library works — react-native-app-auth, expo-auth-session, or a
bare Linking listener — as long as openAuthSession resolves with
{ type: "success", url } on the redirect.
Deep link setup
app.json (Expo):
{ "expo": { "scheme": "com.yourcompany.app" } }Then register com.yourcompany.app://auth as a redirect URI on your client. A
claimed HTTPS link (https://app.example.com/auth) is stronger than a custom
scheme, since another app can register the same scheme — prefer it in
production, and keep the custom scheme for development.
Account switching
Turn on the Google-style account picker per call. Nothing is forced on apps that do not want it.
// Web and native both take `prompt`.
await identity.signIn({ prompt: "select_account" });
// Native has a shorthand.
await identity.switchAccount();| prompt | Behaviour |
| --- | --- |
| (omitted) | Uses the current session; consent screen only if needed |
| select_account | Shows the account picker, even when signed in |
| login | Forces re-authentication |
| consent | Always shows the consent screen |
| none | Never shows UI; returns login_required / interaction_required |
login_hint pre-fills the address when you already know it.
Account settings (native)
Send people to the web for account management — do not rebuild these screens in your app.
await identity.openAccountPage("security"); // opens the system browser
identity.accountUrls();
// { settings, profile, security, privacy, switchAccount }Same reasoning as sign-in: a settings screen rendered inside your app is indistinguishable from one that harvests credentials. The system browser already holds the user's session, so they arrive signed in.
Verify on your server — required
signIn() returns a signed credential, not a session. Never trust profile
data that only client code has seen: anything the client can read, the client
can fabricate.
credential is always an ID token — RS256, signed with the key published at
/.well-known/jwks.json, with aud set to your client ID. It is never an
access token, on either entry point. The native SDK also checks the token's
nonce against the one it generated before returning, so the token you receive
answers the request you made.
// On your server
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(
new URL("https://id.plannorium.com/.well-known/jwks.json")
);
const { payload } = await jwtVerify(credential, jwks, {
issuer: "https://id.plannorium.com",
audience: process.env.PLANNORIUM_CLIENT_ID,
});
// payload.sub is the stable user id. Map it to your own user, then issue
// your own session cookie.The web SDK never returns a refresh token to browser code and never writes the
credential to localStorage.
Registering your client
In the developer console, create a client and choose the application type:
| Type | For | Secret | | --- | --- | --- | | Browser SPA | web apps using the popup | none — PKCE only | | Native App | React Native / Expo | none — PKCE only | | Web Server | server-side OAuth | yes, server-side only |
Then register, exactly:
- Allowed origins (browser) — scheme + host + port, e.g.
https://app.example.com - Redirect URIs (native) — your deep link, e.g.
com.yourcompany.app://auth
Matching is exact. http:// is accepted only for localhost and 127.0.0.1.
API
PlannoriumID (web)
| Method | Description |
| --- | --- |
| signIn(options?) | Opens the popup, resolves with a short-lived credential |
| cancel() | Cancels an in-flight attempt |
| renderButton(el, options?) | Renders a branded sign-in button |
| logout(options?) | Navigates to the hosted end-session endpoint |
createPlannoriumID (Expo)
Returns a PlannoriumIDNative with openAuthSession, sha256 and
getRandomBytes already wired to Expo. Any of them can still be overridden.
PlannoriumIDNative
| Method | Description |
| --- | --- |
| signIn(options?) | Opens the system browser, resolves with tokens |
| switchAccount(options?) | signIn with prompt: "select_account" |
| openAccountPage(page?) | Opens an account page in the system browser |
| accountUrls() | The account page URLs |
| logoutUrl(returnTo?) | End-session URL to open |
Options
{
scopes?: string[]; // default ["openid", "profile", "email"]
prompt?: "none" | "login" | "consent" | "select_account";
loginHint?: string;
}Results
// Web
{ credential: string; expiresAt: number; scopes: string[]; sessionState?: string }
// Native
{ accessToken: string; refreshToken?: string; idToken?: string;
tokenType: string; credential: string; expiresAt: number; scopes: string[] }Errors
Both classes reject with a PlannoriumSDKError carrying a stable code:
| Code | Meaning |
| --- | --- |
| popup_blocked | Browser blocked the popup — invoke signIn() directly from a user gesture |
| popup_closed | Window closed before authentication completed |
| canceled | Authentication was canceled or dismissed |
| invalid_origin | The calling origin is not registered on the client |
| invalid_client | Invalid or unregistered client ID |
| network_error | Unable to connect to Plannorium Identity |
| attempt_in_progress | An authentication request is already in progress |
| missing_crypto | No secure randomness or SHA-256 — pass getRandomBytes / sha256, or use @plannorium/id/expo |
| invalid_config | A required constructor option is missing or the wrong type |
| state_mismatch | The response did not match the request that was sent |
| nonce_mismatch | The ID token's nonce is not the one this request generated |
| missing_id_token | No id_token in the token response — request the openid scope |
| invalid_response | The response was malformed, or carried no authorization code |
| exchange_failed | The token endpoint refused the authorization code |
Requirements
- HTTPS in production;
http://only forlocalhost/127.0.0.1 - Registered origins (web) or redirect URIs (native), matched exactly
- S256 PKCE — mandatory, handled for you
- Node 18+ for the server-side verification example
License
MIT
