@nuria-tech/auth-sdk
v6.0.0
Published
Browser OAuth 2.1 (Authorization Code + PKCE) client SDK. Google login uses Google Identity Services (FedCM); AWS IAM Identity Center uses code+PKCE; password and 2FA login also supported.
Downloads
2,300
Maintainers
Readme
@nuria-tech/auth-sdk
TypeScript SDK for OAuth 2.1 Authorization Code + PKCE (S256), focused on browser apps and framework integrations (React, Vue, Nuxt, Next, Angular). PKCE is mandatory on every flow; there is no client_secret pathway. Native CLI/desktop apps authenticate via loopback redirect (RFC 8252). Headless devices use the RFC 8628 device authorization grant — see Device authorization below for the verification-side helpers this SDK exposes.
Why this SDK
- PKCE S256 + state validation by default
- Redirect (PKCE) and direct credential flows (password, Google, code)
- Optional automatic refresh with concurrency dedupe
- Storage adapters for browser/SSR scenarios
- Framework helpers in dedicated entrypoints
Minimal Requirements
- ECMAScript Target: ES2018
- Browsers: Any modern browser with support for Fetch API, URLSearchParams, and Web Crypto API
- Node.js: >= 20.0.0 (required natively by package.json)
Important note on legacy Node.js (e.g., Node 18 or other past versions)
The Node.js >= 20.0.0 exists because the SDK relies onn the global fetch and crypto (Web Crypto API) objects. Older Node versions lack native support for these APIs.
- If you use SSR: Executing the authentication flow on server using Node < 20.0.0 will cause fatal errors.
- If you have an SPA or legacy project (Nuxt2, Vue2): If the authentication runs completely in the user's browser, it is safe to use this SDK. Node will be used strictly to compile the project.
Because modern framework versions are mapped as optional peer dependencies, modern npm versions (v7+) might throw an ERESOLVE conflict when installing this SDK iln egacy projects. To bypass both the engine lock - using Node < 20 strictly for build purposes - and the peer dependency conflicts, use the --legacy-peer-deps flag.
Frameworks and libraries for specific entrypoints (See 'Entrypoints' flag below):
- React: >= 18.0
- Vue: >= 3.3
- Angular: >= 16.0
- Next.js: >= 13.0
- Nuxt: >= 3.0
- RxJS: >= 7.8
Installation
npm install @nuria-tech/auth-sdkor for older node versions (Node < 20), strictly for build purposes without SSR:
npm install @nuria-tech/auth-sdk --legacy-peer-depsPublished on npm.
Entrypoints
@nuria-tech/auth-sdk: core client + adapters + utilities (extractRoles,extractCompanyOrigin,extractAvatarUrl,extractDisplayName,getInitials,buildOAuthAuthorizeUrl, Google OAuth helpers)@nuria-tech/auth-sdk/react:useAuthSession,AuthProvider,useAuth@nuria-tech/auth-sdk/vue:useAuthSessioncomposable@nuria-tech/auth-sdk/nuxt: Nuxt cookie adapter helpers@nuria-tech/auth-sdk/next: Next cookie adapter helpers@nuria-tech/auth-sdk/angular:createAngularAuthFacade(RxJS facade) +createBearerInterceptor(HttpInterceptorFn)
Auth flows matrix
| Flow | Backend endpoint(s) | SDK method(s) | Result |
|---|---|---|---|
| OAuth Authorization Code + PKCE (recommended for consumer SPAs) | GET /v2/oauth/authorize + POST /v2/oauth/token | startLogin() + handleRedirectCallback(...) | Session tokens after redirect roundtrip |
| Google (auth code / custom button) | POST /v2/google/code | loginWithGoogleCode({ code, redirectUri? }) | Session tokens |
| Code sent (passwordless OTP) | POST /v2/login-code/challenge + POST /v2/2fa/verify-login | startLoginCodeChallenge(...) + verifyLoginCode(...) | Session tokens after code verify |
| Login + password (portal-only) | POST /v2/login | loginWithPassword(...) | Session tokens |
| Password reset request | POST /v2/password/reset | resetPassword({ email }) | void — sends reset email |
| Password recovery | POST /v2/password/recover | recoverPassword({ token, newPassword }) | void — resets password using token |
| Change password | PATCH /v2/me/password | changePassword({ oldPassword, newPassword }) | void — requires active session |
loginWithPassword is intended for the SSO portal (accounts.nuria.com.br)
only — consumer SPAs should use startLogin() so the user sees a single
sign-in surface across all apps. AWS IAM Identity Center is supported via
startAwsLogin (OAuth code + PKCE redirect, see "Federated login" below).
Login methods config
Pass a loginMethods block to createAuthClient to tell login UIs which
buttons to render — both your own (if you build a custom login screen) and
the centralized Nuria accounts SPA (when you use startLogin() to redirect
there, which is the standard path):
const auth = createAuthClient({
clientId: '...',
redirectUri: '...',
loginMethods: {
enabled: ['password', 'google', 'passwordless'],
comingSoon: ['aws_sso'],
},
});
// Custom login UI: read the resolved config back synchronously.
const cfg = auth.getLoginMethods();
if (cfg.enabled.includes('google')) renderGoogleButton();
if (cfg.comingSoon.includes('aws_sso')) renderAwsSsoTeaser();
// Standard path (redirect to Nuria accounts): startLogin() automatically
// serializes loginMethods into `?login_methods_enabled=` and
// `?login_methods_coming_soon=` on the redirect URL. Accounts reads them
// and renders the right buttons for *your* app — no extra plumbing.
await auth.startLogin();Either field can be omitted — missing fields fall back to
DEFAULT_LOGIN_METHODS (enabled: ['password', 'google'],
comingSoon: ['passwordless', 'aws_sso']). Unknown values are dropped;
methods listed in enabled are stripped from comingSoon automatically.
Security note: this is a UI hint, not an auth gate. The kernel is the
authoritative boundary. A crafted URL with arbitrary login_methods_*
params can only change which buttons accounts renders — it cannot bypass
authentication.
Example apps
examples/reactexamples/vueexamples/nuxtexamples/nextexamples/angular
Core quick start
import { createAuthClient } from '@nuria-tech/auth-sdk';
const auth = createAuthClient({
clientId: 'your-client-id',
redirectUri: `${window.location.origin}/callback`,
});
await auth.startLogin();
// callback route
await auth.handleRedirectCallback(window.location.href);
const token = await auth.getAccessToken();
console.log(token);Default login flow (login code sent)
const challenge = await auth.startLoginCodeChallenge({
email: '[email protected]',
// optional: channel defaults to 'email'
// channel: 'sms',
});
const session = await auth.verifyLoginCode({
challengeId: challenge.challengeId,
code: '123456',
});The verification destination is resolved server-side from the user's
stored email or cellphone based on channel. The challenge request body
carries only email, channel, and purpose — no client-supplied
destination, since honoring it would let an attacker who knows only an
email divert the OTP to themselves.
Federated login: Google (OAuth 2.0 Authorization Code)
For Google sign-in with a fully custom button, use createGoogleCodeClient,
which wraps Google's google.accounts.oauth2.initCodeClient. The code
client supports programmatic invocation, fits the OAuth 2.1 Authorization
Code flow, and returns an authorization code that the backend exchanges
for tokens at /v2/google/code.
import { createGoogleCodeClient } from '@nuria-tech/auth-sdk';
const client = await createGoogleCodeClient({
clientId: 'google-app-client-id',
scope: 'openid email profile', // default; override for extra APIs
uxMode: 'popup', // 'popup' (default) | 'redirect'
// loginHint: '[email protected]',
// hd: 'nuria.com.br',
// selectAccount: true,
// prompt: 'consent',
onCode: async ({ code }) => {
// Backend exchanges the code at oauth2.googleapis.com/token using
// client_secret and returns a session.
await auth.loginWithGoogleCode({ code });
},
onError: (err) => console.error(err),
});
// Wire the SDK call to a real user-gesture handler — popup mode is blocked
// by the browser otherwise.
document.getElementById('my-google-btn')!.addEventListener('click', () => {
client.requestCode();
});Popup vs redirect. popup (default) opens an OAuth consent window via
GIS' internal window.open. After the user picks an account and consents,
the popup closes and onCode fires in the parent window — the user never
leaves the page. redirect is a full-page redirect to Google and back to
the configured redirectUri with ?code=...; use this only if popup
blockers are a concern.
State / CSRF. The SDK auto-generates a state parameter, stores it in
sessionStorage under GOOGLE_OAUTH2_STORAGE_KEYS.state, and verifies the
round-trip value with timingSafeEqual before invoking onCode. Pass
state explicitly only to embed correlation IDs.
No client-side token exchange. The SDK never POSTs to Google's /token
endpoint — Google's /token does not have reliable CORS for SPAs and the
exchange requires the GCP client's client_secret. The backend is the
only place that can safely exchange the code.
Removed in v6.
loginWithGoogle({ idToken })(Google ID token via GIS / FedCMgoogle.accounts.id) andloginWithAws({ idToken })(AWS IAM Identity Center direct id-token submission) were removed in v6 — both were "implicit flow" patterns that proved unreliable in production (FedCM cooldown, inert iframe). Migrate tocreateGoogleCodeClient+loginWithGoogleCodefor Google. AWS IAM Identity Center is no longer covered by the SDK; consumers that need it should drive the OAuth 2.1 Authorization Code + PKCE flow themselves and POST the result to a backend that handles session issuance.
Native CLI / desktop apps — loopback redirect (RFC 8252)
Native apps don't run in a browser, but they have a browser available.
The standard pattern is to bind to an ephemeral loopback port and use
that as the OAuth redirect_uri. The SDK is browser-only and does not
ship a CLI runtime; the flow lives in your CLI/desktop code, but it
talks to the same backend endpoints (/v2/oauth/authorize +
/v2/oauth/token) the SDK uses.
1. Native app starts an HTTP listener on http://127.0.0.1:<random-port>/callback
2. Open the system browser at:
https://auth.nuria.com.br/v2/oauth/authorize
?response_type=code
&client_id=<oauth-client-guid>
&redirect_uri=http%3A%2F%2F127.0.0.1%3A<port>%2Fcallback
&state=<random>
&code_challenge=<S256(verifier)>
&code_challenge_method=S256
3. User authenticates in the browser; it redirects to the loopback URL.
4. App exchanges code at /v2/oauth/token with the verifier.Client allow-list. Register the canonical port-less URI on the
OAuth client once: http://127.0.0.1/callback. Any port the app picks
at request time is accepted; path and query must be exact. localhost
is not treated as loopback (RFC 8252 §8.3) — clients must use the
IP literal.
Device authorization (RFC 8628)
For headless devices (TV apps, IoT, SSH terminals, CI runners) that have
no local browser, the device authorization grant lets the user complete
authentication on a separate device. The polling side (the device) is
out of scope for this browser SDK — it runs in your CLI/embedded code.
The SDK provides the verification-side helpers needed by SPAs that
host the user-facing approval page (e.g. accounts.nuria.com.br/device).
End-to-end shape:
device → POST /v2/oauth/device/authorize (form: client_id)
device ← { device_code, user_code, verification_uri, verification_uri_complete,
expires_in, interval }
device shows: "Open <verification_uri> and enter <user_code>"
user → opens https://accounts.nuria.com.br/device?user_code=WDJB-MJHT
user → confirms on the page (which uses the SDK helpers below)
device polls POST /v2/oauth/token with
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=...
client_id=...
until it gets 200 + tokens (or access_denied / expired_token).Verification-page helpers
import { createAuthClient } from '@nuria-tech/auth-sdk';
const auth = createAuthClient({
clientId: 'accounts-spa-client-id',
redirectUri: 'https://accounts.nuria.com.br/callback',
});
// 1. User lands on /device?user_code=WDJB-MJHT — show what they're approving.
const lookup = await auth.lookupDeviceUserCode('WDJB-MJHT');
// → { userCode, clientId, clientName, scope, expiresAt }
// (anti-enumeration: throws on unknown / expired / non-pending codes)
// 2. User clicks "Authorize" — the current session approves the row.
await auth.approveDeviceUserCode('WDJB-MJHT');
// 3. User clicks "Cancel" — deny the row instead.
await auth.denyDeviceUserCode('WDJB-MJHT');approveDeviceUserCode and denyDeviceUserCode require an active
session (Bearer access token). The SDK uses getAccessToken() so
silent refresh is handled automatically. Call lookupDeviceUserCode
before rendering the confirm button so the user sees the client
name and scope they're authorizing.
React quick start
import { createAuthClient } from '@nuria-tech/auth-sdk';
import { AuthProvider, useAuth } from '@nuria-tech/auth-sdk/react';
const auth = createAuthClient({
clientId: 'your-client-id',
redirectUri: `${window.location.origin}/callback`,
});
function AppContent() {
const { session, isLoading, login, logout } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!session) return <button onClick={() => login()}>Login</button>;
return <button onClick={() => logout()}>Logout</button>;
}
export function App() {
return (
<AuthProvider auth={auth}>
<AppContent />
</AuthProvider>
);
}Vue quick start
import { createAuthClient } from '@nuria-tech/auth-sdk';
import { useAuthSession } from '@nuria-tech/auth-sdk/vue';
const auth = createAuthClient({
clientId: 'your-client-id',
redirectUri: `${window.location.origin}/callback`,
});
export function usePageAuth() {
const { session, isLoading, refresh } = useAuthSession(auth);
return { session, isLoading, refresh };
}Nuxt quick start
import { createNuxtAuthClient } from '@nuria-tech/auth-sdk/nuxt';
import { useCookie } from '#app';
const auth = createNuxtAuthClient(
{
clientId: process.env.NUXT_PUBLIC_AUTH_CLIENT_ID!,
redirectUri: process.env.NUXT_PUBLIC_AUTH_CALLBACK_URL!,
},
{
get: (name) => useCookie<string | null>(name).value,
set: (name, value) => {
useCookie<string | null>(name).value = value;
},
remove: (name) => {
useCookie<string | null>(name).value = null;
},
},
);Next quick start
import { createNextAuthClient } from '@nuria-tech/auth-sdk/next';
import { cookies } from 'next/headers';
export function createServerAuth() {
const cookieStore = cookies();
return createNextAuthClient(
{
clientId: process.env.NEXT_PUBLIC_AUTH_CLIENT_ID!,
redirectUri: process.env.NEXT_PUBLIC_AUTH_CALLBACK_URL!,
},
{
get: (name) => cookieStore.get(name)?.value,
set: (name, value) => cookieStore.set(name, value),
remove: (name) => cookieStore.delete(name),
},
);
}Angular quick start
import { Injectable } from '@angular/core';
import { createAuthClient } from '@nuria-tech/auth-sdk';
import { createAngularAuthFacade } from '@nuria-tech/auth-sdk/angular';
@Injectable({ providedIn: 'root' })
export class AuthService {
private auth = createAuthClient({
clientId: 'your-client-id',
redirectUri: `${window.location.origin}/callback`,
});
private facade = createAngularAuthFacade(this.auth);
state$ = this.facade.state$;
login() {
return this.facade.login();
}
/** Clears local session only — no server call. */
logout() {
return this.facade.logout();
}
/** Clears local session + calls server logout endpoint, then redirects. */
globalLogout(returnTo?: string) {
return this.facade.globalLogout({ returnTo });
}
}Full Angular example (service + guard + callback route + status component):
examples/angular
Defaults
baseUrl:https://auth.nuria.com.brauthorizationEndpoint:${baseUrl}/v2/oauth/authorizetokenEndpoint:${baseUrl}/v2/oauth/tokenuserinfoEndpoint:${baseUrl}/v2/oauth/userinfoscope:openid profile emailenableRefreshToken:true
Configuration
interface AuthConfig {
clientId: string;
redirectUri: string;
baseUrl?: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
scope?: string;
logoutEndpoint?: string;
userinfoEndpoint?: string;
storage?: StorageAdapter;
transport?: AuthTransport;
onRedirect?: (url: string) => void | Promise<void>;
enableRefreshToken?: boolean;
now?: () => number;
}Storage strategy
| Adapter | Persists reload | JS-readable | SSR |
|---|---|---|---|
| MemoryStorageAdapter | No | Yes | No |
| WebStorageAdapter(sessionStorage) | Per tab | Yes | No |
| WebStorageAdapter(localStorage) | Yes | Yes | No |
| CookieStorageAdapter | Configurable | Depends on cookie flags | Yes |
Session health check
checkSession() validates the current session against the server by calling the userinfoEndpoint. Use it to detect revoked tokens or deactivated users without waiting for a 401 on a regular request.
const valid = await auth.checkSession();
if (!valid) {
// session was invalidated server-side — redirect to login
}If the server rejects the token, the local session is cleared and onAuthStateChanged listeners are notified. If userinfoEndpoint is not configured, falls back to isAuthenticated().
Typical usage: poll every few minutes to catch server-side revocation.
setInterval(async () => {
if (!auth.isAuthenticated()) return;
const valid = await auth.checkSession();
if (!valid) router.navigate(['/signin']);
}, 5 * 60 * 1000);Logout & re-authentication
When the user clicks "Sair" in your app, you almost always want them to
see a real login screen the next time they sign in — not be silently
re-issued a token by the upstream SSO session. As of 4.0.0 this is
the default:
// 1. User clicks "Sair"
await auth.logout();
// → local session cleared
// → SDK persists a one-shot "force re-login" marker
// 2. Later, user clicks "Entrar"
await auth.startLogin();
// → SDK reads the marker, adds ?prompt=login to the authorize URL,
// clears the marker
// → IdP renders its login form even if the SSO session is still warm
// → User sees and performs an explicit loginThe marker is one-shot: a second startLogin() without an intervening
logout() does not add prompt=login, so normal SSO continues to
work for in-flow navigations. If onRedirect (or the redirect path) throws
before the navigation succeeds, the marker is preserved so the user's
retry still goes through prompt=login.
The marker is also storage-scoped to the SDK instance (its
StorageAdapter), so logging out of app A on origin a.example.com
does not influence the next sign-in to app B on b.example.com —
each app independently arms its own re-authentication.
Storage caveat. With
MemoryStorageAdapterthe marker lives only in-memory and is lost on page reload. If your app callslogout()and then the user refreshes before clicking "Entrar", silent SSO returns. For the force-relogin guarantee to survive reload, use a persistent adapter (WebStorageAdapter,CookieStorageAdapter, etc.).
Opting out — keepSso: true
Pass { keepSso: true } when the app deliberately wants classic silent
SSO across logout. The clearest legitimate case is a background
refresh failure: the user's refresh token expired, but you want them
to glide back into the same identity without retyping credentials.
// Background refresh failed — clear local state but let the next
// startLogin() use SSO to re-establish the same identity.
await auth.logout({ keepSso: true });
await auth.startLogin();Pass keepSso: true also if your component is itself the IdP UI
(no upstream SSO above it for the marker to influence) — this is what
the accounts.nuria.com.br portal does internally.
Forcing re-authentication explicitly
Set prompt directly on startLogin() to override or bypass the
marker. Explicit prompt always wins:
await auth.startLogin({ prompt: 'login' }); // force form
await auth.startLogin({ prompt: 'select_account' }); // force chooser
await auth.startLogin({ prompt: 'consent' }); // re-show consent
await auth.startLogin({ prompt: 'none' }); // SSO-only, error if no sessionFor OIDC space-separated combos (e.g. "login consent"), pass through
extraParams.prompt — that path also overrides both the marker and
the typed option.
Google Identity Services interaction
Google's FedCM / One Tap can silently re-issue an id_token independent
of your app's session. After logout, call disableGoogleAutoSelect()
so GIS forgets the auto-select hint:
import { disableGoogleAutoSelect } from '@nuria-tech/auth-sdk';
await auth.logout();
disableGoogleAutoSelect();The accounts.nuria.com.br portal does this in its own logout handler.
Security notes
- Do not use
clientSecretin browser/mobile apps. - Prefer memory storage when possible.
- Keep refresh on cookies (
HttpOnly) server-side when available. logout()clears the local session only — no server call, no redirect. Use this for in-app sign-out where the user stays in the same app. By default it also forces re-authentication on the nextstartLogin()— see Logout & re-authentication below. Pass{ keepSso: true }to preserve classic silent-SSO across logout.globalLogout({ returnTo })calls the server logout endpoint and redirects.returnTomust behttps://(orhttp://localhostfor dev); URLs with embedded credentials are rejected.isAuthenticated()returnstruewhen the token is expired butenableRefreshToken: true—getAccessToken()will silently renew it.getClaims()decodes the JWT payload client-side viaatob()without verifying the signature — trust comes from the server that issued the token.getActor()returns the RFC 8693 §4.1actclaim when the current session was minted via support impersonation (shape:{ sub, name?, email? }); returnsnullfor regular sessions and malformed payloads. UIs should render an "acting as" banner whenever it is non-null so the impersonator is never invisible to the end user.- Browser cookie storage encodes/decodes values safely (
encodeURIComponent/decodeURIComponent).
Full policy and reporting process: SECURITY.md.
Public API
interface AuthClient {
init(): Promise<void>;
startLogin(options?: StartLoginOptions): Promise<void>;
handleRedirectCallback(callbackUrl?: string): Promise<Session>;
getSession(): Session | null;
getAccessToken(): Promise<string | null>;
/**
* Clears the local session only. No server call, no redirect.
* Default arms `prompt=login` for the next startLogin(); pass
* `{ keepSso: true }` to preserve silent SSO across logout.
*/
logout(options?: LogoutOptions): Promise<void>;
/** Clears the local session AND calls the server logout endpoint, then redirects. */
globalLogout(options?: { returnTo?: string }): Promise<void>;
/** Best-effort POST /v2/logout to revoke the current session's refresh token server-side. Does NOT clear local state. Pair with logout() for full sign-out without redirect. */
revokeSession(): Promise<void>;
/** Best-effort POST /v2/logout/global (Bearer) to revoke EVERY refresh token of the authenticated subject across all devices and OAuth-integrated apps. Intended for the SSO portal sign-out; per-app callers should use revokeSession(). Dev tokens (with jti) are unaffected. Does NOT clear local state. */
revokeAllSessions(): Promise<void>;
isAuthenticated(): boolean;
onAuthStateChanged(handler: (session: Session | null) => void): () => void;
getClaims(): TokenClaims | null;
/** Returns the RFC 8693 `act` claim when the session is impersonated, else null. */
getActor(): ActorClaim | null;
hasRole(role: string): boolean;
hasGroup(group: string): boolean;
getUserinfo(): Promise<Record<string, unknown>>;
checkSession(): Promise<boolean>;
startLoginCodeChallenge(options: LoginCodeChallengeOptions): Promise<TwoFactorChallenge>;
verifyLoginCode(options: VerifyLoginCodeOptions): Promise<Session>;
loginWithGoogleCode(options: GoogleCodeLoginOptions): Promise<Session>;
/** Direct password login against /v2/login. Portal-only — consumer SPAs should use startLogin (OAuth + PKCE). */
loginWithPassword(options: PasswordLoginOptions): Promise<Session>;
resetPassword(options: { email: string }): Promise<void>;
recoverPassword(options: { token: string; newPassword: string }): Promise<void>;
changePassword(options: { oldPassword: string; newPassword: string }): Promise<void>;
/** Static, synchronous — returns the resolved loginMethods from createAuthClient (defaults applied). */
getLoginMethods(): LoginMethodsConfig;
}
interface LoginMethodsConfig {
enabled: ('password' | 'google' | 'passwordless' | 'aws_sso')[];
comingSoon: ('password' | 'google' | 'passwordless' | 'aws_sso')[];
}
interface LoginCodeChallengeOptions {
email: string;
channel?: 'email' | 'sms';
purpose?: string;
}CI and publish
- PR/main runs: typecheck, lint, test, build
- Tag
v*runs publish workflow with Trusted Publishing
Publish flow:
- Update
versioninpackage.json - Tag and push (
git tag vX.Y.Z && git push --tags) - Workflow validates and publishes
License
MIT - see LICENSE.
