@userspace-auth/userspace-js
v1.12.9
Published
Readme
Userspace JS SDK (@userspace-auth/userspace-js)
This SDK gives auth elements and session tokens to any web page. No framework is necessary.
The sign-in occurs on the hosted pages of your Userspace instance. This package puts auth UI into a page and gives you a session token for calls to your backend.
If you use React, use @userspace-auth/react. That package gives the same token
contract, and adds hooks and guards.
Install
Embed the SDK directly, and use the script tag to configure it:
<script
src="https://cdn.jsdelivr.net/npm/@userspace-auth/userspace-js/dist/userspace.browser.js"
data-base-url="https://acme.userspace.tech"
></script>Or install the package and configure it in your code:
npm i @userspace-auth/userspace-jsimport { configureUserspace } from '@userspace-auth/userspace-js'
await configureUserspace({
baseUrl: 'https://acme.userspace.tech',
})By default, the SDK also keeps the session token of the signed-in user in a
__Host-session cookie on your host. The SDK refreshes the cookie before the session
token expires. Thus your backend can read the session token, and your frontend sends
nothing. To use only getToken() and bearer headers, set cookie: false (or
data-cookie="false"). Then the SDK writes nothing to document.cookie and runs no
refresh timer.
The configuration step registers the custom elements. Thus nothing renders until the
configuration step is complete. The two install methods also make the API available on
window.Userspace, for a page that cannot use modules.
The script tag reads data-base-url, data-auto-login and data-cookie. For the
boolean attributes, true, on and 1 are on. data-cookie is the only attribute
that is on when it is absent, and data-cookie="false" sets it to off. The SDK reads
these attributes only from a classic script tag. A type="module" script has no
document.currentScript, and thus it must call configureUserspace.
The package includes type declarations (userspace.d.ts). Thus an editor shows the full
API after a plain npm install. This includes the strike-through on the deprecated
fetchToken.
Developing on localhost
An instance in the Development environment trusts loopback origins on all ports
(localhost, *.localhost, 127.0.0.1, [::1]). The instance trusts these origins
together with the app URL and the trusted origins that you configured. Thus a page on
http://localhost:5173 can call a hosted instance with only baseUrl. An instance in a
different environment trusts only its app URL and its trusted origins. Use a
Production instance for production, because its origin checks are the strictest that
the platform has.
Running on another site than baseUrl
Your page can be on a different site than baseUrl. Examples are localhost with a
hosted instance, or an application on its own domain. Then the browser does not send the
sign-in cookie to the auth host, and the SDK signs in with an authorization code and
PKCE. You set no option, because the SDK compares the two sites.
On the first page load of a tab, the SDK makes one full-page round trip to the auth host
and back. Then the SDK removes the code from the address bar. Each tab keeps its own
refresh token in memory, and also in sessionStorage between two pages of the tab. Thus
a reload or a link in your application stays signed in without that round trip. A new
tab signs in independently.
getToken(), the __Host-session cookie, signOut(), the settings popup and the
elements work the same as on the same site.
A tab can open while a different tab is signed in. Such a tab reads the session cookie
of the signed-in tab for getToken(). The tab makes the round trip when it first calls
the API. That first call occurs on load with autoLogin, or when an element or the
settings popup needs it.
At this time, this works only for Development instances.
Calling your backend
Your own backend needs no token at all
By default, the SDK keeps the session token of the signed-in user in a __Host-session
cookie on your host. The SDK refreshes the cookie before the session token expires. Your
backend reads the session token from the cookie. Thus a page load, a form post or a
plain fetch to your origin is authenticated:
// No Authorization header: the browser sends the cookie on its own.
const res = await fetch('/api/me')The cookie transport is off by default in your backend. Set it to on. For Go:
u, err := userspace.New(userspace.Options{CookieTransport: true})For a JavaScript backend, refer to Verifying sessions in a JavaScript backend.
The cookie transport has two effects. First, your backend refuses a request that changes
state (POST, PUT, PATCH, DELETE) when the request sends the cookie from a
different origin. This prevents an action by a different site in the name of your user.
Second, your backend gets a third answer together with signed in and signed out.
The third answer: "unknown". The session token in the cookie has a life of minutes.
A browser that was idle can send a request with no session token while the user is
signed in. This occurs because the SDK did not refresh the session token yet. Your
backend reports this state as unknown, not as signed out.
This is important for pages that you render on the server. After a pause longer than the
life of the session token, the first request renders unknown. The page becomes correct
a moment later, when this SDK refreshes the session token on the client. Thus render
unknown with your application shell, not with your sign-in page. If you render the
sign-in page, a signed-in user sees it for a moment. A server-side refresh that fully
removes this flicker is not available yet.
unknown never gives permission for an action. For each authorization decision, use
unknown the same as signed out. The backend SDK does this.
Verifying sessions in a JavaScript backend
The package has a server entry point, @userspace-auth/userspace-js/server. The server
entry point verifies the session token locally in your backend, with the published keys
of your instance. It uses no secret and cannot issue a token. It uses only the Fetch API
and WebCrypto, and thus it runs on Node, Deno, Bun and edge runtimes.
import { createVerifier } from '@userspace-auth/userspace-js/server'
// baseUrl is the same value the browser SDK uses; USERSPACE_BASE_URL works too.
const userspace = createVerifier({ baseUrl: 'https://acme.userspace.tech', cookieTransport: true })authenticate(request) accepts a standard Request. It never throws because of the
contents of the request. It returns one of two answers:
{ ok: true, session }, where the session hasuserId,sessionId,expiresAtandtransport.- A refusal with a
status, a fixedreasonand astate.
unauthorized(refusal) changes a refusal into a Response:
- 401 with the bearer challenge.
- 403 for a cookie that came from a different origin.
- 503 when the verifier did not fetch the keys of your instance yet.
In SvelteKit, use src/hooks.server.js:
import { createVerifier } from '@userspace-auth/userspace-js/server'
import { env } from '$env/dynamic/private'
const userspace = createVerifier({ baseUrl: env.USERSPACE_BASE_URL, cookieTransport: true })
export async function handle({ event, resolve }) {
const auth = await userspace.authenticate(event.request)
if (auth.preflight) return resolve(event)
// A 403 or a 503 is never a page to render.
if (!auth.ok && auth.status !== 401) return userspace.unauthorized(auth)
event.locals.session = auth.ok ? auth.session : null
// 'signed-in', 'signed-out' or 'unknown': render 'unknown' with your application shell,
// not your sign-in page, and let the browser SDK recover.
event.locals.sessionState = auth.state
if (event.url.pathname.startsWith('/api/') && !auth.ok) return userspace.unauthorized(auth)
return resolve(event)
}Your GET and HEAD handlers must not change state. The origin check applies only to
POST, PUT, PATCH and DELETE, because a browser sends the cookie with a
cross-site navigation. Nothing can do the check of your handlers for you.
The default clock skew is 10 seconds. You can increase it to a maximum of 60 seconds
with clockSkew. trustedOrigins is a list of more origins that can make requests that
change state with the cookie. Use it for a frontend on a different origin than this
backend. The list is empty by default. keyStatus() reports if the verifier fetched the
keys, for a readiness probe.
Running on bearer headers alone
Set cookie: false, or data-cookie="false" on the script tag. Then the SDK writes no
cookie, runs no refresh timer, and gets a session token when a call asks for one. Use
this mode if you only call an API on a different origin, or if you do not want a cookie
on your host.
The SDK also uses this mode automatically in one condition. __Host- cookies must have
Secure. If the browser does not store such a cookie on your page, the SDK continues in
the bearer-only mode. The SDK writes one message about this to the console. This is
important for development on plain http://localhost, where browsers do not agree if a
page can set a Secure cookie.
Sending the token yourself
getToken() returns a session token that your backend can verify locally, or null
when no user is signed in. Use it for an API on a different origin, where the browser
sends no cookie of yours. Send the session token as a bearer token:
import { getToken } from '@userspace-auth/userspace-js'
async function loadProfile() {
const token = await getToken()
if (!token) return // signed out
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
})
return res.json()
}The signed-out state is a value, not an error. getToken() rejects only when something
failed, for example the network or a broken response. Thus a catch here shows a real
problem, not an anonymous visitor. The SDK refreshes session tokens automatically. Each
call returns a cached session token until it expires, and after that the call fetches a
new one.
There is one limit. signOut() removes the cached session tokens. But a sign-out in a
different location (a different tab, the hosted pages) does not remove them. Thus
getToken() continues to return a session token from before that sign-out until the
session token expires.
Send this session token only to your backend. A session token gives access to no API of the platform. The Frontend API, which is the auth server of your instance, accepts only its own token with a separate signature. But each backend with the same base URL accepts the session token until it expires.
On the backend, verify the session token with the Userspace SDK for your stack. For Go,
that SDK is github.com/userspace-auth/sdk-go. Any JOSE library also works. The session
token is a standard RS256 JWT, and your instance publishes the keys that sign it at
https://<your-instance>/.well-known/jwks.json.
fetchToken() is deprecated
fetchToken() works at this time, and version 2.0.0 removes it. It returns the raw
{ token, ... } response and throws on a 401. Thus the signed-out state comes as an
error, not as a value. The React SDK always returned null, and the two SDKs did not
agree. Code for one SDK handled the signed-out state of the other SDK incorrectly.
// before
try {
const { token } = await fetchToken()
} catch (err) {
/* signed out, or a real failure, indistinguishable without inspecting err */
}
// after
const token = await getToken() // null when signed outElements
The configuration step registers fifteen custom elements. These include <x-signed-in>
and <x-signed-out>, which show content by auth state. They also include
<x-signin-button>, <x-signout-button> and <x-profile-settings>. Each element
accepts the theme and primary attributes.
<x-signed-in>
<p>You are signed in.</p>
<x-signout-button></x-signout-button>
</x-signed-in>
<x-signed-out>
<x-signin-button primary="emerald"></x-signin-button>
</x-signed-out><x-signin-button> returns the visitor to the page where the visitor clicked the
button. To send the visitor to a different address, give the button a redirect-uri.
This is useful for a marketing page that sends the visitor to your application:
<x-signin-button redirect-uri="https://app.example.com/"></x-signin-button>The target must be an absolute URL, and its origin must be on the allowlist of the instance. The server validates the target. If the server does not trust the target, the server ignores it and uses the app URL.
<x-authorized> and <x-non-authorized> are older names of <x-signed-in> and
<x-signed-out>. The SDK supports the old names and the new names, and it prefers none
of them.
Triggering Userspace from your own markup
<x-trigger> wraps your element and renders nothing itself. When the user clicks your
element, <x-trigger> starts a Userspace action (a tap is also a click). The actions
are:
settings, which opens the settings popup.sign-in, which goes to the hosted sign-in page.sign-out, which signs the user out.
Wrap a real interactive element, for example a button or a menu item of your design
system. Then keyboard activation works with no more code. Put the trigger in
<x-signed-in>. Without a session, settings logs a console warning and opens nothing.
sign-in and sign-out accept the same redirect-uri attribute as
<x-signin-button>. The attribute gives the address where the user goes after the
action.
<x-signed-out>
<x-trigger action="sign-in" redirect-uri="https://app.example.com/">
<button class="your-cta">Get started</button>
</x-trigger>
</x-signed-out>
<x-signed-in>
<x-trigger action="settings">
<button class="your-menu-item">Account settings</button>
</x-trigger>
<x-trigger action="sign-out">
<button class="your-menu-item">Sign out</button>
</x-trigger>
</x-signed-in>Each action is also available as a function. All settings triggers and callers share one popup instance.
await Userspace.openSettings()
await Userspace.signOut()signIn() goes to the hosted sign-in page. It sends the current URL as redirect_uri,
and thus the user returns to the same page. This is the same navigation that the
autoLogin option does, but on a click and not on each signed-out visit. To go to a
different address, give redirectUri. The same allowlist rule as for signOut applies:
Userspace.signIn()
Userspace.signIn({ redirectUri: 'https://app.example.com/' })signOut() stops each session in this browser, not only the current session. The server
stops the full cookie-scoped session, and thus other tabs also sign out. signOut()
sends a POST to the sign-out endpoint and then goes to the hosted sign-in page. If you
give redirectUri, it goes to that address. redirectUri must be an absolute URL, and
its origin must be on the allowlist of the instance. The server validates the target,
and a target that the server does not trust becomes the sign-in page:
await Userspace.signOut({ redirectUri: 'https://app.example.com/logged-out' })registerElements() registers the custom elements and does not configure the SDK. It is
for hosts that configure the SDK with a different method. configureUserspace calls it,
and most pages do not need to call it directly.
License
MIT.
