@locins/id
v3.0.3
Published
Official SDK for LocinsID — OAuth2/PKCE, hosted login UI, user & consent APIs for SPAs, Next.js, Expo & Capacitor
Maintainers
Readme
@locins/id
Official SDK for LocinsID — OAuth2/PKCE authentication, hosted login UI, user & consent APIs. Drop "Sign in with Locins" into any web or mobile app and let us run the password reset emails, MFA, social logins and consent screens.
Why LocinsID
You build the app. We run the identity stack: WorkOS-backed user accounts, password reset, MFA, social logins (Apple/Google), session management, OAuth2/PKCE, consent management — all behind one button.
- No backend required — PKCE means a SPA, mobile app, or AI-builder-generated site can authenticate users without you running a server.
- One package, four platforms — works in browser SPAs, React Native (Expo), Next.js, Capacitor, and any Node.js backend.
- TypeScript-first — full types ship in the package, autocomplete works out of the box.
- Hosted login UI —
https://id.locins.com/loginhandles the entire register/login/MFA/consent flow. Your user clicks "Sign in with Locins", we show our UI, then bounce them back to your callback with tokens.
Getting Credentials
Register your app in our developer portal — self-service, free.
- Sign up at
https://id.locins.com(you'll need a Locins account; create one if needed). - Go to
https://id.locins.com/developer/apps. - Click New App, fill in:
- App name (shown on the consent screen)
- App type:
public(SPA/mobile, no backend) orconfidential(with backend that can keep a secret) - Redirect URIs (the URL we send users back to after login)
- Scopes (which user data your app can access)
- Submit for review. Admins approve within 24-48 hours.
- Once approved, copy your
client_idfrom the app detail page. Confidential apps additionally generate aclient_secret.
No email, no Discord, no waiting list. The portal is the only way in.
App Types
Choose based on whether your app has a backend that can keep secrets.
Public (SPA / Mobile / Capacitor / AI-builders like Lovable)
Apps that run entirely on the user's device. You get a client_id only — no secret. PKCE protects the token exchange instead.
User Your App (Browser/Mobile) LocinsID
| click login | |
| | generate PKCE verifier |
| | |
| | redirect with code_challenge |
| |--------------------------------->|
| login + consent in hosted UI |
|<----- redirect with code ------------------------|
| | exchange code + verifier |
| |--------------------------------->|
| | access_token |
| |<---------------------------------|Use LocinsAuth (browser) or LocinsExpoAuth (React Native).
Confidential (Backend / SSR / Workers)
Apps with a backend that can keep a client_secret safely. Token exchange happens server-side; the SPA frontend only sees access tokens.
You get client_id + client_secret.
Use LocinsAuthCore on the server. Still requires PKCE (OAuth 2.1 mandates it for all clients).
Installation
npm install @locins/id
# or
pnpm add @locins/id
# or
yarn add @locins/idRequirements: Node.js ≥ 18 (Web Crypto API + fetch)
Subpath Exports
| Import | Platform | Peer deps |
| ------------------ | ------------------- | ------------------ |
| @locins/id | Universal | — |
| @locins/id/react | React 18+ | react |
| @locins/id/expo | Expo / React Native | expo-web-browser |
Quick Start — Browser SPA (public client)
PKCE is handled automatically.
import { LocinsSDK } from '@locins/id'
const sdk = new LocinsSDK({
clientId: 'app_abc123',
redirectUri: 'https://myapp.com/callback',
scope: ['identity', 'profile:read'],
})
// Popup login (recommended — preserves app state)
const { user, tokens } = await sdk.auth.loginWithPopup()
// Or redirect login (navigates away)
await sdk.auth.login()
// After redirect login, on your /callback route:
const result = await sdk.auth.handleCallback()
// Authenticated API calls
const profile = await sdk.users.me()
const consents = await sdk.consent.list()redirectUri defaults to window.location.origin + '/callback' when omitted — great for AI-builder previews.
Quick Start — Backend (confidential client)
import { LocinsAuthCore } from '@locins/id'
const auth = new LocinsAuthCore({
clientId: 'app_abc123',
clientSecret: process.env.LOCINS_CLIENT_SECRET!,
redirectUri: 'https://myapp.com/api/callback',
scope: ['identity', 'profile:read'],
})
// Build auth URL (you also need a PKCE code_challenge per session)
const authUrl = auth.buildAuthUrl(csrfState, codeChallenge)
// Exchange the code server-side
const { user, tokens } = await auth.exchangeCode(code, codeVerifier)
// Refresh when needed
const newTokens = await auth.refreshToken()React
import { LocinsProvider, useLocins } from '@locins/id/react'
function App() {
return (
<LocinsProvider clientId="app_abc123" redirectUri="https://myapp.com/callback">
<Dashboard />
</LocinsProvider>
)
}
function Dashboard() {
const { user, isLoggedIn, isLoading, loginWithPopup, logout } = useLocins()
if (isLoading) return <p>Loading...</p>
if (!isLoggedIn) {
return <button onClick={() => loginWithPopup()}>Sign in with Locins</button>
}
return (
<div>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Sign out</button>
</div>
)
}useLocins()
| Property | Type | Description |
| ------------------ | --------------------------------------------- | ---------------------------------------- |
| user | LocinsUser \| null | Authenticated user profile |
| isLoggedIn | boolean | Whether the user is authenticated |
| isLoading | boolean | true during init and callback handling |
| organizations | LocinsOrganization[] | User's organizations |
| login(opts?) | (o?: { state?, purpose? }) => Promise<void> | Redirect login |
| loginWithPopup() | (o?: { purpose? }) => Promise<void> | Popup login |
| logout() | () => Promise<void> | Clear tokens + best-effort server revoke |
| auth | LocinsAuth | Direct access to auth client |
LocinsProvider Props
| Prop | Type | Default | Description |
| ------------- | ------------------------ | ------------------------- | ------------------------------- |
| clientId | string | — | Your app's client ID (required) |
| redirectUri | string | window.origin + /callback | OAuth callback URL |
| baseUrl | string | https://id.locins.com | LocinsID API URL |
| scope | string[] | [] | Scopes to request |
| storage | TokenStorage | LocalStorageAdapter | Custom token storage |
| onError | (error: Error) => void | — | Error callback |
Expo / React Native
import { LocinsExpoAuth } from '@locins/id/expo'
import * as WebBrowser from 'expo-web-browser'
const auth = new LocinsExpoAuth({
clientId: 'app_abc123',
redirectUri: 'myapp://callback',
scope: ['identity'],
webBrowser: WebBrowser,
})
const { user, tokens } = await auth.login()PKCE is generated automatically. Tokens are stored in memory by default — pass a storage adapter (e.g. wrapping expo-secure-store) for persistence.
API Reference
LocinsSDK
Top-level convenience facade. Wraps LocinsAuth plus the typed API clients.
const sdk = new LocinsSDK({
clientId: string, // Required — issued by the developer portal
redirectUri?: string, // Optional — defaults to window.location.origin + '/callback' in browsers
baseUrl?: string, // Optional — defaults to https://id.locins.com
scope?: string[], // Optional — defaults to []
storage?: TokenStorage, // Optional — LocalStorageAdapter in browsers, MemoryStorageAdapter in Node
})
redirectUriis optional in browser contexts (the SDK readswindow.location.originand appends/callback). It is required in Node/SSR/Worker contexts where there is nowindowto read from.
LocinsSDKis browser-targeted and intentionally has noclientSecretfield. Confidential clients (those with a server-side secret) must useLocinsAuthCoredirectly — see the "Backend (confidential client)" example above.
| Property | Type | Description |
| --------------- | -------------- | -------------------------------- |
| sdk.auth | LocinsAuth | Login, logout, token management |
| sdk.users | UsersApi | User profile operations |
| sdk.consent | ConsentApi | Consent management |
| sdk.directory | DirectoryApi | Public LocinsID directory search |
LocinsAuth / LocinsAuthCore
| Method | Returns | Description |
| ------------------------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------- |
| login(opts?: {state?, purpose?}) | Promise<void> | Redirect login |
| loginWithPopup(opts?: {purpose?}) | Promise<AuthCallbackResult> | Popup login (browser only) |
| handleCallback() | Promise<AuthCallbackResult \| null> | Handle redirect callback |
| exchangeCode(code, verifier?) | Promise<AuthCallbackResult> | Exchange code for tokens (server-side) |
| refreshToken() | Promise<LocinsTokens> | Refresh access token |
| loadGrantedScopes() | Promise<string[]> | Fetch admin-approved scope set; throws AppNotReadyError if app is not approved |
| getUser(token?) | Promise<LocinsUser> | Fetch user from /oauth/userinfo |
| getTokens() | LocinsTokens \| null | Current token pair |
| initialize() | Promise<void> | Load tokens from storage (idempotent) |
| logout() | Promise<void> | Clear tokens + best-effort server revoke |
| buildAuthUrl(state, challenge, purpose?) | string | Build authorization URL (PKCE required) |
| authenticatedFetch(url, init?) | Promise<Response> | fetch with auto-refresh on 401 |
The purpose parameter
login() and buildAuthUrl() accept an optional purpose: 'private' | 'developer' | 'company'. LocinsID's hosted UI branches its onboarding/login flow based on this signal. Use it when your app is purpose-specific (e.g., a developer dashboard passes 'developer', a consumer app passes 'private').
UsersApi
| Method | Returns | Description |
| --------------------- | --------------------- | ------------------------------------ |
| me() | Promise<LocinsUser> | Current user profile |
| updateProfile(data) | Promise<LocinsUser> | Update name, bio, locale, avatar_url |
ConsentApi
| Method | Returns | Description |
| ---------------------- | ------------------------------- | ------------------------ |
| list() | Promise<ConsentEntry[]> | All active consents |
| getForApp(appId) | Promise<ConsentEntry \| null> | Consent for specific app |
| grant(appId, scopes) | Promise<ConsentEntry> | Grant consent |
| revoke(appId) | Promise<void> | Revoke consent |
DirectoryApi
Public, unauthenticated reads of LocinsID's directory.
| Method | Returns | Description |
| ----------------------------- | ---------------------------------- | ------------------------------------------------- |
| searchPublic({ q, limit? }) | Promise<DirectorySearchResult[]> | Search profiles by name/username |
| getPublicProfile(handle) | Promise<PublicProfile \| null> | Public profile by handle (null when not findable) |
| getPublicDeck(handle) | Promise<PublicDeck \| null> | A user's public ID-card deck (null when missing) |
Types
interface LocinsUser {
id: string
sub: string
email: string
email_verified: boolean
name: string | null
display_name: string | null
username: string | null
first_name: string | null
last_name: string | null
avatar_url: string | null
locale: string
bio?: string | null
occupation?: string | null
organizations?: LocinsOrganization[]
family_members?: LocinsFamilyMember[]
}
interface LocinsOrganization {
id: string
name: string
slug: string
role: 'owner' | 'member'
}
interface LocinsTokens {
access_token: string
refresh_token: string
expires_in?: number
issued_at?: number
}
interface AuthCallbackResult {
user: LocinsUser
tokens: LocinsTokens
}
interface TokenStorage {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
}
type LocinsPurpose = 'private' | 'developer' | 'company'Custom Token Storage
By default, tokens are stored in localStorage (browser) or in-memory (Node/Expo). Provide an adapter for SecureStore, AsyncStorage, etc.:
import { LocinsSDK } from '@locins/id'
const storage: TokenStorage = {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
}
const sdk = new LocinsSDK({
clientId: 'app_abc123',
redirectUri: 'https://myapp.com/callback',
storage,
})Built-in adapters exported by the package:
MemoryStorageAdapter— non-persistent (Node default, Expo default)SessionStorageAdapter—sessionStorage(clears on tab close)LocalStorageAdapter—localStorage(browser default forLocinsAuth)
Error Handling
All SDK errors throw LocinsAuthError with a code and message. App-status and scope mismatches throw narrower subclasses.
import { LocinsAuthError, AppNotReadyError, ScopeNotGrantedError } from '@locins/id'
try {
await sdk.auth.loginWithPopup()
} catch (err) {
if (err instanceof AppNotReadyError) {
// App is not yet approved by Locins admin
} else if (err instanceof ScopeNotGrantedError) {
// You requested a scope the admin did not approve
} else if (err instanceof LocinsAuthError) {
switch (err.code) {
case 'popup_blocked':
/* fallback to redirect login */ break
case 'popup_closed':
/* user closed the popup */ break
case 'auth_failed':
/* code exchange or state mismatch */ break
case 'refresh_failed':
/* refresh expired/revoked — re-login */ break
case 'timeout':
/* 10s timeout */ break
}
}
}| Code | Description |
| ----------------- | ------------------------------------------------- |
| auth_failed | Code exchange or authentication failed |
| no_token | No access/refresh token available |
| refresh_failed | Token refresh failed — user needs to log in again |
| userinfo_failed | Failed to fetch user profile |
| popup_blocked | Browser blocked the auth popup |
| popup_closed | User closed the auth popup |
| callback_failed | Native auth session cancelled or returned no code |
| invalid_config | Missing required config (e.g., PKCE challenge) |
| timeout | Request timed out (10s default) |
Scopes
Request only what you need. Users approve each scope on the consent screen.
| Scope | Description | Tier |
| ------------------- | ----------------------------------------------- | ------- |
| identity | ID, email, name, avatar | Public |
| profile:read | Bio, occupation, languages | Public |
| profile:write | Update profile information | Trusted |
| health:read | Allergies, blood type, medications | Public |
| health:write | Update health card | Trusted |
| family:read | Partner, children, pets | Public |
| family:write | Update family information | Trusted |
| company:read | Which companies the user is a member of | Public |
| consent:read | View which apps have access | Public |
| consent:manage | Grant or revoke app access | Trusted |
| travel:read | Travel identity: visited countries, bucket list | Public |
| travel:write | Update travel identity | Trusted |
| connections:read | Connections and encounters | Public |
| connections:write | Create and manage connections | Trusted |
| directory:search | Search the Locins directory (SDK-only) | Public |
Trusted tier requires app_type: confidential AND a verified domain. Public apps cannot request trusted scopes; they will be rejected at admin review.
Security
| | |
| --------------------- | ------------------------------------------------------------------------ |
| Authentication | Every app must have a registered, admin-approved client_id |
| PKCE | Mandatory for all clients (S256, OAuth 2.1) |
| Client Secret | Confidential clients only; bcrypt-hashed server-side; 24h rotation grace |
| Redirect URI | Exact-match against registered URIs (HTTPS in production) |
| State Validation | CSRF-protected via state parameter |
| Scope Enforcement | Server rejects scopes outside the admin-approved set |
| Token Storage | LocalStorageAdapter browser default — override for stricter contexts |
| Access Token | JWT, 15 min expiry |
| Refresh Token | Rotating UUID, 30 day expiry, single-use, bound to a WorkOS session |
| Token Refresh | Automatic on 401, concurrent-safe (deduplicated) |
| Request Timeouts | All API calls timeout after 10 seconds |
| Rate Limiting | 20 req/min per client_id on /oauth/token |
Versioning
Semver. Breaking changes only on majors. See CHANGELOG.md.
Sign in with Locins (one-liner)
import { LocinsProvider, SignInWithLocinsButton } from '@locins/id/react'
function App() {
return (
<LocinsProvider clientId="app_abc123">
<SignInWithLocinsButton />
</LocinsProvider>
)
}Props: variant: 'light' | 'dark' | 'outline', size: 'sm' | 'md' | 'lg', label?: string, purpose?: 'private' | 'developer' | 'company', usePopup?: boolean, onError?: (err) => void. Ships its own CSS — works without Tailwind. See brand/BUTTON_GUIDELINES.md for design rules.
Next.js (App Router)
LocinsProvider is a Client Component. Mark it explicitly with 'use client', then drop it into your root layout.
// app/locins-provider.tsx
'use client'
import { LocinsProvider } from '@locins/id/react'
export function Provider({ children }: { children: React.ReactNode }) {
return (
<LocinsProvider
clientId={process.env.NEXT_PUBLIC_LOCINS_CLIENT_ID!}
redirectUri="https://yourapp.com/callback"
scope={['identity', 'profile:read']}
>
{children}
</LocinsProvider>
)
}// app/layout.tsx
import { Provider } from './locins-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Provider>{children}</Provider>
</body>
</html>
)
}// app/callback/page.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useLocins } from '@locins/id/react'
export default function CallbackPage() {
const { isLoading, isLoggedIn } = useLocins()
const router = useRouter()
useEffect(() => {
if (!isLoading && isLoggedIn) router.replace('/dashboard')
}, [isLoading, isLoggedIn, router])
return <p>Signing you in…</p>
}Set NEXT_PUBLIC_LOCINS_CLIENT_ID=app_abc123 in .env.local. No backend required.
React SPA + NestJS Backend
Two patterns:
Pattern A — frontend-only PKCE (simplest): the SPA does the full login flow with LocinsSDK; the Nest.js backend validates each request by calling /oauth/userinfo with the access token, or by verifying JWTs locally against our JWKS at https://id.locins.com/.well-known/jwks.json.
Pattern B — confidential token exchange (most secure): the SPA redirects to LocinsID, the SPA forwards the code to your Nest.js backend, the backend uses LocinsAuthCore with clientSecret to exchange the code for tokens, and stores the refresh token in an HttpOnly cookie. Tokens never touch the browser.
Pattern A is enough for most apps. Use B if you need refresh tokens off the client.
// nest backend — pattern B
import { LocinsAuthCore } from '@locins/id'
const auth = new LocinsAuthCore({
clientId: process.env.LOCINS_CLIENT_ID!,
clientSecret: process.env.LOCINS_CLIENT_SECRET!,
redirectUri: 'https://yourapp.com/api/callback',
})
// POST /api/callback { code, codeVerifier }
@Post('callback')
async callback(@Body() body: { code: string; codeVerifier: string }) {
const { user, tokens } = await auth.exchangeCode(body.code, body.codeVerifier)
// store refresh in HttpOnly cookie, return access token to SPA
}Capacitor (iOS + Android)
Capacitor's WebView blocks cross-origin popups, so use the system browser via @capacitor/browser instead of loginWithPopup. Build the auth URL yourself with PKCE.
import { Browser } from '@capacitor/browser'
import { LocinsAuth } from '@locins/id'
const auth = new LocinsAuth({
clientId: 'app_abc123',
redirectUri: 'com.yourapp://callback', // deep link
})
await Browser.open({ url: await buildAuthUrlWithPkce(auth) })
// In your deep-link handler:
App.addListener('appUrlOpen', async (event) => {
const url = new URL(event.url)
const code = url.searchParams.get('code')
if (code) {
await Browser.close()
const result = await auth.exchangeCode(code, verifier)
}
})Register the deep-link scheme in capacitor.config.ts and add it as a redirect URI in the developer portal.
AI Builders (Lovable, Bolt, v0, Replit Agent)
@locins/id is designed to drop straight into AI-built React/Vite/Next sites. Three things to know:
- No backend required. PKCE means the SPA handles the entire flow.
- Inline the clientId. AI builders don't always set up
.envfiles.const CLIENT_ID = 'app_abc123'in a.tsfile is fine. - Register your preview AND production URLs. Builder preview URLs often live on a shared domain (e.g.
*.lovable.app). The developer portal accepts wildcard redirect URIs on trusted AI-builder hosts — registerhttps://*.lovable.app/callback(orbolt.new,v0.dev,replit.app) alongside your production URL.
LLM prompt for AI builders: copy https://id.locins.com/developer/docs → "Copy LLM prompt" → paste into your AI builder before asking it to integrate.
License
MIT
