npm package: @loop8id/auth-spa-js
Description: Auth SDK for Single Page Applications using OpenID Connect with https://auth.l8p8.com
Author: L8P8
Latest version: 1.0.0

# @loop8id/auth-spa-js

> OpenID Connect / OAuth 2.0 authentication SDK for Single Page Applications  
> Authority: **https://auth.l8p8.com**

---

## Features

- 🔐 **PKCE flow** — Authorization Code + PKCE (S256), the most secure SPA auth flow
- 🔄 **Token refresh** — Refresh token rotation or silent iframe renewal
- 🪟 **Popup login** — `loginWithPopup()` for popup-based auth
- 💾 **Flexible caching** — In-memory (default) or `localStorage`
- 📜 **ID token validation** — iss / aud / exp / nonce checks
- 🔌 **OIDC discovery** — Auto-fetches and caches `/.well-known/openid-configuration`
- 🧩 **TypeScript-first** — Full type definitions included
- 🪶 **Zero dependencies** — Uses only native browser APIs (Web Crypto, fetch)

---

## Installation

```bash
npm install @loop8id/auth-spa-js
# or
yarn add @loop8id/auth-spa-js
# or
pnpm add @loop8id/auth-spa-js
```

Or via CDN:

```html
<script src="https://unpkg.com/@loop8id/auth-spa-js/dist/loop8id-auth-spa-js.production.js"></script>
<!-- window.l8p8Auth is now available -->
```

---

## Quick Start (Vanilla JS)

### 1. Register your application

Go to **https://developer.l8p8.com/projects**, create a **Single Page Application**, and configure:

| Setting | Value |
|---|---|
| Allowed Callback URLs | `http://localhost:5173` |
| Allowed Logout URLs | `http://localhost:5173` |
| Allowed Web Origins | `http://localhost:5173` |

### 2. Create the client

```js
import { createLoop8IdClient } from '@loop8id/auth-spa-js';

const client = await createLoop8IdClient({
  clientId: 'YOUR_CLIENT_ID',
  // authority defaults to 'https://auth.l8p8.com'
});
```

### 3. Handle the redirect callback

```js
// On page load — check if we're returning from the login page
if (client.isRedirectCallback()) {
  const { appState } = await client.handleRedirectCallback();
  // Redirect to the original page (if stored in appState)
  window.history.replaceState({}, '', appState?.returnTo || '/');
}
```

### 4. Login

```js
// Redirect to the login page
await client.loginWithRedirect({
  appState: { returnTo: window.location.pathname },
});

// --- or via popup ---
await client.loginWithPopup();
```

### 5. Check auth state & get user

```js
const isAuthenticated = await client.isAuthenticated();

if (isAuthenticated) {
  const user = await client.getUser();
  console.log(user.name, user.email, user.picture);
}
```

### 6. Get an id token

```js
const itToken = await client.getIdToken();

### 7. Logout

```js
await client.logout({
  logoutParams: { returnTo: window.location.origin },
});
```

---

## API Reference

### `createLoop8IdClient(options)` → `Promise<Loop8IdClient>`

Factory function — creates a client and pre-fetches the OIDC discovery document.

```ts
const client = await createLoop8IdClient({
  clientId: string;           // required
  authority?: string;         // default: 'https://auth.l8p8.com'
  redirectUri?: string;       // default: window.location.origin
  scope?: string;             // default: 'openid profile email'
  audience?: string;          // API identifier for the id token
  cacheLocation?: 'memory' | 'localstorage'; // default: 'memory'
  useRefreshTokens?: boolean; // default: false
  leeway?: number;            // clock skew tolerance in seconds, default: 60
});
```

### `client.loginWithRedirect(options?)`

Redirects to the OIDC login page. After authentication, the user is redirected back to `redirectUri`.

```ts
await client.loginWithRedirect({
  authorizationParams: {
    prompt: 'login',          // force re-authentication
    scope: 'openid',
  },
  appState: { returnTo: '/dashboard' }, // passed back in handleRedirectCallback
});
```

### `client.handleRedirectCallback(url?)`

Call after the user is redirected back. Exchanges the authorization code for tokens.

```ts
const { appState } = await client.handleRedirectCallback();
// Cleans up ?code=&state= from the URL automatically
```

<!-- ### `client.loginWithPopup(options?)`

Opens a popup window for login. Resolves when the user completes authentication.

```ts
await client.loginWithPopup({
  timeoutInSeconds: 120,
  authorizationParams: { prompt: 'select_account' },
});
```

Requires a popup callback page — see [Popup Callback](#popup-callback). -->

### `client.isAuthenticated()` → `Promise<boolean>`

Returns `true` if the user has a valid id token (automatically attempts silent renewal if expired).

### `client.getUser<T>()` → `Promise<T | undefined>`

Returns the user's profile. Uses ID token claims if available, otherwise calls the UserInfo endpoint.

### `client.getIdTokenClaims()` → `Promise<IdTokenClaims | undefined>`

Returns all decoded claims from the ID token.

### `client.logout(options?)`

Clears the session and redirects to the end_session_endpoint.

```ts
await client.logout({
  logoutParams: { returnTo: 'https://myapp.com' },
  localOnly: false,  // set true to skip redirect, only clear local session
});
```

### `client.isRedirectCallback(url?)` → `boolean`

Returns `true` if the URL contains `code` and `state` parameters.

---

## Silent Authentication Callback

For silent renewal via iframes, serve this page at a registered redirect URI:

```html
<!-- /silent-callback.html -->
<!DOCTYPE html>
<html>
  <body>
    <script type="module">
      import { handleSilentCallback } from '@loop8id/auth-spa-js';
      handleSilentCallback();
    </script>
  </body>
</html>
```

---

## Popup Callback

For popup-based login, serve this page at the redirect URI used for popups:

```html
<!-- /popup-callback.html -->
<!DOCTYPE html>
<html>
  <body>
    <script type="module">
      import { handlePopupCallback } from '@loop8id/auth-spa-js';
      handlePopupCallback();
    </script>
  </body>
</html>
```

---

## Framework Examples

### React

```jsx
import { createLoop8IdClient } from '@loop8id/auth-spa-js';
import { createContext, useContext, useEffect, useState } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [client, setClient] = useState(null);
  const [user, setUser] = useState(null);
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    (async () => {
      const c = await createLoop8IdClient({ clientId: 'YOUR_CLIENT_ID' });

      if (c.isRedirectCallback()) {
        await c.handleRedirectCallback();
        window.history.replaceState({}, '', '/');
      }

      const auth = await c.isAuthenticated();
      if (auth) setUser(await c.getUser());
      setIsAuthenticated(auth);
      setClient(c);
      setLoading(false);
    })();
  }, []);

  return (
    <AuthContext.Provider value={{ client, user, isAuthenticated, loading }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);
```

### Vue 3 (Composable)

```js
// composables/useAuth.js
import { createLoop8IdClient } from '@loop8id/auth-spa-js';
import { ref, readonly } from 'vue';

const client = ref(null);
const user = ref(null);
const isAuthenticated = ref(false);
const loading = ref(true);

export function useAuth() {
  async function init() {
    client.value = await createLoop8IdClient({ clientId: 'YOUR_CLIENT_ID' });

    if (client.value.isRedirectCallback()) {
      await client.value.handleRedirectCallback();
      window.history.replaceState({}, '', '/');
    }

    isAuthenticated.value = await client.value.isAuthenticated();
    if (isAuthenticated.value) user.value = await client.value.getUser();
    loading.value = false;
  }

  return {
    client: readonly(client),
    user: readonly(user),
    isAuthenticated: readonly(isAuthenticated),
    loading: readonly(loading),
    init,
    login: (opts) => client.value?.loginWithRedirect(opts),
    logout: (opts) => client.value?.logout(opts),
    getToken: (opts) => client.value?.getIdToken(opts),
  };
}
```

---

## Migrating from `@auth0/auth0-spa-js`

`@loop8id/auth-spa-js` mirrors the Auth0 SPA SDK API. For a drop-in migration:

```diff
- import { createAuth0Client } from '@auth0/auth0-spa-js';
+ import { createLoop8IdClient as createAuth0Client } from '@loop8id/auth-spa-js';

  const client = await createAuth0Client({
-   domain: 'YOUR_AUTH0_DOMAIN',
+   clientId: 'YOUR_CLIENT_ID',
-   clientId: 'YOUR_CLIENT_ID',
  });
```

The `createAuth0Client` alias is also exported directly:

```js
import { createAuth0Client } from '@loop8id/auth-spa-js';
// Works as a drop-in replacement
```

---

## Error Types

| Class | `error` code | When thrown |
|---|---|---|
| `L8P8Error` | various | Base error class |
| `OAuthError` | from server | Server returned an OAuth error |
| `MissingRefreshTokenError` | `missing_refresh_token` | Silent renewal attempted with no refresh token |
| `TimeoutError` | `timeout` | Silent iframe timed out |
| `PopupCancelledError` | `popup_cancelled` | User closed the popup |
| `PopupTimeoutError` | `popup_timeout` | Popup timed out |

---

## Security Considerations

- **PKCE** is always used — authorization code interception attacks are prevented
- **In-memory token storage** (`cacheLocation: 'memory'`) is the default and most secure option — tokens are not accessible to other scripts and are cleared on page refresh
- **`cacheLocation: 'localstorage'`** survives page refreshes but is accessible to any JavaScript on the page — only use if your site has a strong Content Security Policy
- **ID token validation** checks `iss`, `aud`, `exp`, `iat`, and `nonce` on every token exchange
- Always register exact **Allowed Callback URLs** and **Allowed Web Origins** in your dashboard

---

## License

MIT © L8P8