npm package: @phila/sso-core
Description: Framework-agnostic SSO client for Azure AD B2C / Entra External ID
Author: City of Philadelphia
Latest version: 0.0.9

# @phila/sso-core

Framework-agnostic SSO client for Azure AD B2C / Entra External ID, built on top of `@azure/msal-browser`.

## Installation

```bash
pnpm add @phila/sso-core @azure/msal-browser
```

## Quick Start

```ts
import { SSOClient, B2CProvider } from "@phila/sso-core";

const client = new SSOClient({
  provider: new B2CProvider({
    clientId: "your-client-id",
    b2cEnvironment: "YourTenant",
    authorityDomain: "YourTenant.b2clogin.com",
    redirectUri: "http://localhost:3000",
  }),
  debug: true,
});

await client.initialize();

if (!client.state.isAuthenticated) {
  await client.signIn();
}
```

## Providers

### B2CProvider

Azure AD B2C authentication provider.

```ts
import { B2CProvider } from "@phila/sso-core";

const provider = new B2CProvider({
  clientId: "your-client-id",
  b2cEnvironment: "YourTenant",
  authorityDomain: "YourTenant.b2clogin.com",
  redirectUri: "http://localhost:3000",
  postLogoutRedirectUri: "http://localhost:3000", // optional, defaults to redirectUri
  apiScopes: ["https://YourTenant.onmicrosoft.com/api/read"], // optional
  policies: {
    signUpSignIn: "B2C_1A_SIGNUP_SIGNIN", // optional, shown values are defaults
    signInOnly: "B2C_1A_AD_SIGNIN_ONLY",
    resetPassword: "B2C_1A_PASSWORDRESET",
  },
  cacheLocation: "sessionStorage", // optional, "sessionStorage" | "localStorage"
});
```

### CIAMProvider

Entra External ID (CIAM) provider.

```ts
import { CIAMProvider } from "@phila/sso-core";

const provider = new CIAMProvider({
  clientId: "your-client-id",
  tenantSubdomain: "yoursubdomain",
  redirectUri: "http://localhost:3000",
  scopes: ["api://your-api/scope"], // optional
});
```

### EntraProvider

Entra workforce (Azure AD) provider.

```ts
import { EntraProvider } from "@phila/sso-core";

const provider = new EntraProvider({
  clientId: "your-client-id",
  tenantId: "your-tenant-guid",
  redirectUri: "http://localhost:3000",
  scopes: ["User.Read"], // optional
});
```

## SSOClient API

### Constructor

```ts
const client = new SSOClient({
  provider: B2CProvider | CIAMProvider | EntraProvider,
  debug?: boolean,
  state?: Record<string, unknown>, // custom state preserved across redirects
});
```

### Lifecycle

| Method         | Returns                         | Description                                                |
| -------------- | ------------------------------- | ---------------------------------------------------------- |
| `initialize()` | `Promise<AuthResponse \| null>` | Initialize MSAL, process redirect, check existing sessions |
| `destroy()`    | `void`                          | Clean up resources and reset state                         |

### Authentication

| Method                         | Returns                   | Description                                           |
| ------------------------------ | ------------------------- | ----------------------------------------------------- |
| `signIn(options?)`             | `Promise<void>`           | Start sign-in redirect flow                           |
| `signInCityEmployee(options?)` | `Promise<void>`           | Sign in with the sign-in-only policy (B2C)            |
| `signOut(options?)`            | `Promise<void>`           | Sign out and redirect                                 |
| `forgotPassword()`             | `Promise<void>`           | Start password reset flow (B2C)                       |
| `acquireToken(options?)`       | `Promise<string \| null>` | Get access token (silent first, interactive fallback) |

### Options

```ts
interface SignInOptions {
  scopes?: string[];
  loginHint?: string;
  domainHint?: string;
  state?: Record<string, unknown>;
  prompt?: string;
}

interface SignOutOptions {
  postLogoutRedirectUri?: string;
}

interface TokenOptions {
  scopes?: string[];
  forceRefresh?: boolean;
}
```

### State

Access the current auth state via `client.state`:

```ts
interface SSOClientState {
  isAuthenticated: boolean;
  isLoading: boolean;
  user: AccountInfo | null;
  token: string | null;
  error: Error | null;
  activePolicy: string | null;
  authReady: boolean;
}
```

### Events

Subscribe to auth lifecycle events:

```ts
const unsubscribe = client.events.on("auth:signedIn", response => {
  console.log("User signed in:", response.account);
});

// Clean up
unsubscribe();
```

| Event                 | Payload          | Description              |
| --------------------- | ---------------- | ------------------------ |
| `auth:stateChanged`   | `SSOClientState` | Any state change         |
| `auth:signedIn`       | `AuthResponse`   | Sign-in completed        |
| `auth:signedOut`      | `void`           | Sign-out initiated       |
| `auth:tokenAcquired`  | `string`         | Token acquired silently  |
| `auth:error`          | `Error`          | Authentication error     |
| `auth:forgotPassword` | `void`           | Password reset completed |
| `auth:loading`        | `boolean`        | Loading state changed    |

## Custom State

Preserve arbitrary data across auth redirects:

```ts
const client = new SSOClient({
  provider,
  state: { returnUrl: "/dashboard" },
});

const response = await client.initialize();
if (response?.customPostbackObject) {
  console.log(response.customPostbackObject.returnUrl); // "/dashboard"
}
```

## License

MIT