<!-- standard: asd-ste100 -->

# 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:

```html
<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:

```sh
npm i @userspace-auth/userspace-js
```

```js
import { 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:

```js
// 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:

```go
u, err := userspace.New(userspace.Options{CookieTransport: true})
```

For a JavaScript backend, refer to
[Verifying sessions in a JavaScript backend](#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.

```js
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 has `userId`, `sessionId`, `expiresAt` and
  `transport`.
- A refusal with a `status`, a fixed `reason` and a `state`.

`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`:

```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:

```js
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.

```js
// 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 out
```

## Elements

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.

```html
<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:

```html
<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.

```html
<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.

```js
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:

```js
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:

```js
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.
