@providame/core
v0.0.15
Published
Providame authentication SDK — headless primitives, prebuilt-component foundation, and backend token verification.
Downloads
672
Maintainers
Readme
@providame/core
The framework-agnostic TypeScript SDK for Providame — headless auth primitives and
the foundation @providame/vue and @providame/react are built on.
Use this directly if you're not on Vue or React (Svelte, Angular, Solid, plain TypeScript), or if you want the flow objects rather than a framework binding.
For a browser bundle with zero dependencies, see
@providame/sdk-js. For server-side code, see@providame/backend— it takes your secret key and must never run in a browser.
Install
npm install @providame/coreQuickstart
import { createProvidame } from '@providame/core'
const providame = createProvidame({
publishableKey: 'pk_test_...',
apiBase: 'https://<your-providame-host>',
})
// Sign up — the backend always requires email verification next
const signUp = providame.createSignUp()
await signUp.create({ email, password, firstName, lastName })
await signUp.attemptVerification({ code })
if (signUp.isComplete) {
// signUp.session has { userId, accessToken, expiresAt }
}
// Sign in
const signIn = providame.createSignIn()
await signIn.create({ identifier: email })
await signIn.attemptFirstFactor({ strategy: 'password', password })
if (signIn.status === 'needs_second_factor') {
await signIn.attemptSecondFactor({ strategy: 'totp', code })
}
// Restore a session on page load
const session = await providame.session()
await providame.signOut()
providame.onAuthStateChange((event, { session }) => {
if (event === 'signed-in') { /* ... */ }
})session() is the authoritative check: it tries GET /me, and — since access
tokens live ~60 seconds — falls back to a silent refresh before reporting
signed-out. You don't manage refresh timing yourself; the client runs a
background refresh loop once a session exists.
Other sign-in strategies
await providame.signInWithIdp('google', { returnUrl: window.location.href })
const flow = providame.createSignIn()
await flow.createWithOTPEmail({ identifier: email }) // emailed code
await flow.resendOTPEmail() // if it didn't arrive
await flow.createWithOTPSMS({ identifier: email }) // texted to the phone on file
await flow.resendOTPSMS()
await flow.createWithMagicLink({ identifier: email, returnUrl })
await flow.createWithPasskey({ identifier: email, domain: location.hostname })
await flow.completeWithPasskey() // drives navigator.credentials.get() for youPassword reset
Starting a reset always succeeds from the caller's point of view — no account enumeration. A code is only emailed if the address exists.
const flow = await providame.resetPassword({ email })
await flow.attempt({ code, password: newPassword })
// An MFA-protected account still has to prove factor possession — resetting
// the password doesn't bypass MFA
if (flow.status === 'needs_second_factor') {
await flow.attemptSecondFactor({ strategy: 'totp', code: mfaCode })
}
await flow.resend() // re-send the code if it expired or never arrivedMid-flow second factor and forced TOTP enrollment
attemptSecondFactor/enrollTotp/verifyTotpEnrollment are identical across
SignInFlow, SignUpFlow, and ResetPasswordFlow — all three share one
backend flow-state machine keyed by flowId, not by which route created it.
if (flow.status === 'needs_second_factor') {
await flow.attemptSecondFactor({ strategy: 'totp', code })
}
// Forced-MFA environment, account has no second factor yet:
if (flow.status === 'needs_mfa_enrollment') {
const { secret, otpauthUri } = await flow.enrollTotp()
await flow.verifyTotpEnrollment(code) // activates the factor AND completes the flow
flow.recoveryCodes // one-time backup codes — shown once, never retrievable again
}Branding & enabled sign-in providers
Every prebuilt component fetches these automatically on mount — use them directly only if you're rendering your own sign-in screen.
const branding = await providame.branding() // never throws — {} on failure
const idps = await providame.listEnabledIdps() // never throws — [] on failure
// [{ provider: 'google', displayName: 'Google' }, ...]branding() is memoized per client instance, so mounting several components
on one page costs one request, not one per component. listEnabledIdps()
deliberately isn't — the enabled-provider set can change between mounts (e.g.
an admin edits it) and the fetch is cheap.
Account management
providame.user is what <UserProfile> in the framework packages renders a UI
over — use it directly for a custom account page.
await providame.user.update({ givenName, familyName })
await providame.user.changePassword({ currentPassword, newPassword })
await providame.user.changeEmail('[email protected]') // code goes to the NEW address
await providame.user.verifyEmailChange(code)
await providame.user.setPhone('+15551234567')
const { avatarUrl } = await providame.user.uploadAvatar(file) // a Blob/File
await providame.user.removeAvatar()
// hasPassword is false for a social-login account — gate any password
// prompt on it, or it's an unsatisfiable dead end for those users
const { hasTotp, hasPassword } = await providame.user.mfaState()
await providame.user.hasPassword() // same data, just the one flag
if (!(await providame.user.totp.isEnrolled())) {
const { secret, otpauthUri } = await providame.user.totp.enroll()
const recoveryCodes = await providame.user.totp.verify(code)
}
await providame.user.totp.remove()
await providame.user.passkeys.list()
const { passkeyId, creationOptions } = await providame.user.passkeys.register(location.hostname)
// credential = navigator.credentials.create(toCreationOptions(creationOptions))
await providame.user.passkeys.verify(passkeyId, credential, name)
await providame.user.passkeys.registerWithBrowser(location.hostname, name) // drives that whole ceremony for you
await providame.user.passkeys.remove(passkeyId)
await providame.user.sessions.list()
await providame.user.sessions.revoke(sessionId)
const myData = await providame.user.exportData() // self-service GDPR export — never private_metadata
// deleteAccount() takes proof of a recent re-authentication and calls
// reauth() internally, so most callers only need to pass it straight in:
await providame.user.deleteAccount(currentPassword)
// A social-login account has no password — request an emailed code instead:
await providame.user.requestReauthCode()
await providame.user.deleteAccount({ code })Admin-issued passkey registration link
For a user who is not signed in — an admin generates a registration link from the dashboard, and the emailed code is itself the credential:
const { passkeyId, creationOptions } = await providame.user.passkeys.registerWithCode(
identifier, codeId, code, domain,
)
const credential = await navigator.credentials.create(toCreationOptions(creationOptions))
// identifier must be the SAME email passed to registerWithCode()
await providame.user.passkeys.verifyWithCode(passkeyId, identifier, credentialToJSON(credential), name)Every failure mode — unknown identifier, wrong/expired code, a bad credential
— collapses into the same generic invalid_code rejection, so don't rely on
the error to distinguish "no such account" from "wrong code".
Organizations
Providame's B2B tenancy layer: your customers are companies with members and roles. Every environment has organizations — there's no feature switch to enable.
const orgs = await providame.organizations.list()
await providame.organizations.setActive(orgs[0].id) // null clears it
await providame.organizations.setActiveBySlug('acme')
const org = await providame.organizations.get(orgs[0].id)
await providame.organizations.create({ name: 'Acme Inc', slug: 'acme' })
await providame.organizations.update(org.id, { name: 'Acme Corp' })
await providame.organizations.delete(org.id)
await providame.organizations.members(org.id)
await providame.organizations.setMemberRoles(org.id, userId, [
{ key: 'org:admin', scope: 'environment' },
])
await providame.organizations.removeMember(org.id, userId) // a member may always remove themselves
// Org-local roles draw their permissions from the ENVIRONMENT's catalog
await providame.organizations.roles(org.id)
await providame.organizations.createRole(org.id, {
roleKey: 'billing-owner', displayName: 'Billing Owner', permissionKeys: ['billing:manage'],
})
await providame.organizations.updateRole(org.id, 'billing-owner', {
permissionKeys: ['billing:manage', 'billing:view'], // omit to leave unchanged, [] revokes all
})
await providame.organizations.deleteRole(org.id, 'billing-owner')
// Invitations — the token itself is never returned here, only emailed
await providame.organizations.invite(org.id, { email: '[email protected]' })
await providame.organizations.invitations(org.id) // this org's invitations
await providame.organizations.revokeInvitation(org.id, invitationId)
await providame.organizations.myInvitations() // addressed to the signed-in user
await providame.organizations.acceptInvitation(token)
// Verified domains + domain-based suggestions
await providame.organizations.domains(org.id)
await providame.organizations.addDomain(org.id, { domain: 'acme.com' })
await providame.organizations.setDomainEnrollmentMode(org.id, domainId, 'automatic_invitation')
await providame.organizations.verifyDomain(org.id, domainId)
await providame.organizations.removeDomain(org.id, domainId)
await providame.organizations.suggestions()
await providame.organizations.joinSuggested(suggestedOrgId)setActive() returns a freshly minted token already carrying the new org_*
claims, so you don't wait out the previous token's lifetime. You don't need to
call it after every sign-in: the choice is remembered, so a returning user is
put back in the organization they last used, a user who has never chosen starts
in the one they joined first, and clearing it is remembered too. A member's role
carries a scope: "environment" roles come from the environment's shared
catalog, "organization" roles exist only inside one org, and setMemberRoles
replaces across both tiers at once.
Reading roles and permissions
These read the access token's claims and make no request:
import {
hasRole, hasPermission, hasOrgRole, hasOrgPermission, activeOrganization,
} from '@providame/core'
const claims = await providame.getClaims()
hasRole(claims, 'admin') // environment-level grant
hasPermission(claims, 'posts:write')
activeOrganization(claims) // { id, slug, name } | null
hasOrgRole(claims, 'org:admin') // ACTIVE organization only
hasOrgPermission(claims, 'billing:manage')All four return true for an undefined argument, so an omitted constraint is
not a constraint. This is display gating only — real enforcement is your
backend verifying the token.
Object-scoped access
The claim helpers above answer "is this user an admin" for free, from the
token. providame.authz asks about one specific resource — a real network
call, for "can this user edit this document":
const allowed = await providame.authz.can({ permission: 'edit', resource: 'document:42' })
const [access] = await providame.authz.listResources({ permission: 'edit', resourceType: 'document' })
// access.scopes is a UNION of shapes: [{ kind: 'all' }] means every document;
// [{ kind: 'instances', instances: [...] }] is the complete, enumerable list, e.g. ['document:42', 'document:107']
const editableDocumentIds = access.scopes.some((s) => s.kind === 'all')
? allDocumentIds
: access.scopes.flatMap((s) => s.instances ?? [])
// The cache already clears itself on sign-in/out and organization switch —
// call this yourself only after something YOUR app did could have changed an
// answer (e.g. your backend just recorded a new grant).
providame.authz.invalidate()There is no user parameter on either call — the subject is always the
signed-in caller, forced server-side; a customer-supplied user id would turn
this into an authorization oracle readable by anyone holding the publishable
key. can() never resolves false on a network failure — it rejects, so a
dropped connection can't be mistaken for a real denial; handle that in your
own error boundary rather than treating a caught error as "not allowed" and
moving on silently. Answers are cached per-question for the life of the
session (cleared on sign-in/out and organization switch); sdk/vue/sdk/react
wrap this in a <Can> component with a loading state built in.
Getting a token for your backend
const token = await providame.getToken() // cached — a network call only
// happens once it's actually expired
if (token) {
await fetch('/api/whatever', { headers: { Authorization: `Bearer ${token}` } })
}
await providame.getToken({ skipCache: true }) // force a live round tripLike Clerk's own getToken(), the cached token is decoded from its own exp
claim, so it's safe to call before every request — a network round trip only
happens once it's actually expired.
Verifying tokens
import { verifyToken } from '@providame/core'
const config = await providame.configuration()
const { userId, claims } = await verifyToken(accessToken, config)A local JWKS check that never calls Providame. If your server also needs to read
end-users or manage organizations, use @providame/backend instead — it wraps
this plus the Backend API.
Native & hybrid apps
For React Native/Expo, or a Capacitor/Ionic hybrid app — a nk_ (native)
publishable key instead of pk_, and an options.native block instead of
cookies. createProvidame detects native mode from the key prefix.
import "react-native-get-random-values"; // React Native only, before @providame/core
import * as SecureStore from "expo-secure-store";
import { createProvidame } from "@providame/core";
export const providame = createProvidame({
publishableKey: "nk_live_...",
native: {
storage: {
getItem: (k) => SecureStore.getItemAsync(k),
setItem: (k, v) => SecureStore.setItemAsync(k, v),
removeItem: (k) => SecureStore.deleteItemAsync(k),
},
redirectUrl: "myapp://auth-callback",
},
});options.native.storage is required — the SDK stores, refreshes and sends
the session handle for you from there. Sign-in flows, session(),
getToken(), and signOut() all work exactly as in a browser. Only a 401
discards the stored handle; offline or a transient server error keeps it and
retries, so a dropped connection never signs anyone out.
A 401 with code invalid_key is the exception: the server refused your
app's key (revoked, rotated, or a WebView origin not on the environment's
native app origins), not the user's session. The SDK keeps the handle,
retries with backoff (15s, doubling to every 5 minutes), and emits
key-rejected once through onAuthStateChange — the place to show a
"please update the app" prompt:
providame.onAuthStateChange((event) => {
if (event === "key-rejected") showUpdatePrompt()
})Social sign-in runs through the system browser, not an embedded WebView:
const { authorizeUrl } = await providame.startIdpSignIn("google", {
returnUrl: "myapp://auth-callback",
})
// open authorizeUrl with expo-web-browser's openAuthSessionAsync, or @capacitor/browser
// on the deep link your app receives back:
const session = await providame.completeIdpSignIn(callbackUrl)options.native.openAuthUrl (an (url: string) => void | Promise<void>) lets
signInWithIdp() and the prebuilt components' own social buttons drive this
for you instead of calling startIdpSignIn/completeIdpSignIn by hand.
React Native's Hermes engine has no crypto.subtle, so the PKCE challenge
this uses is computed by a dependency-free SHA-256 implementation in this
package — but it still needs crypto.getRandomValues, which React Native
requires react-native-get-random-values for (imported once, before this
package, as shown above).
Device attestation
If the environment requires device attestation, a native app has to prove it is a genuine build on a genuine device before FAPI will serve it. The flow is nonce, platform proof, verify:
const { nonce } = await providame.attestation.nonce()
// Produced by YOUR native code, not by this package:
// iOS — App Attest: pass SHA256(UTF-8 bytes of the nonce string) as
// clientDataHash to attestKey (first time) or generateAssertion.
// Android — a classic Play Integrity request with the nonce string as its nonce.
const result = await providame.attestation.verify({
platform: "ios",
nonce,
deviceId, // a stable id for this install, chosen by your app
keyId, // App Attest key id (base64)
attestation, // first time; later calls send `assertion` instead
})
if (!result.valid) console.warn(result.reasons)JavaScript cannot produce these proofs itself — obtain them through your own
native module or a React Native / Capacitor plugin that wraps App Attest and
Play Integrity. A passing verification returns a short-lived attestation
token, which the SDK keeps in options.native.storage and sends as
X-Attestation-Token on every request until it expires (providame.attestation.token
reads it; providame.attestation.clear() forgets it). A purpose: "check_in"
verification — for reporting a device your app decided to block — is
recorded but never earns a token.
When the token is missing or expired, requests fail with a 401
attestation_required. Like invalid_key, that does not sign the user out:
the SDK keeps the session, backs off, and emits attestation-required once.
Re-attest, then call session() again (a sleeping refresh retries at once):
providame.onAuthStateChange(async (event) => {
if (event === "attestation-required") await attestAgain()
})Forced updates
Pass your build's version as native.appVersion; the SDK sends it on every
request as X-App-Version. When the environment sets a minimum app version
in the dashboard, requests from an older build fail with 426
upgrade_required. The session is kept; the SDK backs off and emits
upgrade-required once. appConfig() is always reachable, so check it at
launch and on returning to the foreground:
const providame = createProvidame({
publishableKey: "nk_live_...",
native: { storage, appVersion: "4.2.1" },
})
const policy = await providame.appConfig()
// { minClientVersion, recommendedVersion, iosStoreUrl, androidStoreUrl }
providame.onAuthStateChange((event) => {
if (event === "upgrade-required") showUpdateScreen()
})Audit events
sendAuditEvents(events) posts up to 100 of your app's audit events for the
signed-in user. Every event comes back in exactly one of accepted or
rejected — both are final, so delete it from your local buffer once it
appears in either — and an event the same user resends is not stored twice.
Anything past the first 100 is in neither list; send it again. Each user may
send 30 batches a minute; past that the call throws a ProvidameError with
status 429 and nothing is stored — keep the events and retry after
err.retryAfterSeconds.
const { accepted, rejected, errors } = await providame.sendAuditEvents([
{ eventId: crypto.randomUUID(), action: "trip.viewed", timestamp: new Date().toISOString(), resourceId: "trip_981" },
])The vanilla, zero-dependency @providame/sdk-js package does not support
native mode.
Full walkthrough, including the raw HTTP calls for Swift/Kotlin and how to allow a Capacitor/Ionic WebView to present a native key: the Native apps guide in the Providame docs.
Also exported
resolveAppearance,ELEMENT_KEYS— the theming vocabulary the framework packages'appearanceprop is built on.generateQRMatrix,qrDarkCells— a dependency-free QR encoder, for renderingotpauthUriduring TOTP enrollment.toCreationOptions,toRequestOptions,credentialToJSON,isWebAuthnSupported— WebAuthn plumbing for custom passkey UI.renderTurnstile— bot-check widget mounting.safeHref,safeAbsoluteHttpUrl,safeMailto— URL guards to run before rendering any developer-supplied link.ProvidameError,messageForIdpErrorCode,messageForWebAuthnError.
Docs
Full reference: /docs/sdk/core and /docs/api/fapi in your Providame dashboard.
Build
npm run buildLicense
MIT
