# @sedibyte/auth

A small, provider-agnostic access-token manager. It owns two cross-cutting
concerns — a **registry** of auth providers and in-memory **caching** of tokens
until shortly before they expire — so each provider only has to know how to
request a fresh token.

Two providers ship today — Microsoft (app-only / client credentials) and
Salesforce (OAuth 2.0 JWT bearer) — but the core knows nothing about either;
adding another provider doesn't touch `auth.js`.

Written in TypeScript; ships compiled ESM plus type declarations.

## Requirements

- Node.js >= 18 (uses the built-in `fetch`)
- ESM (`"type": "module"`)

## Build

```sh
npm install
npm run build   # tsc → dist/
```

The published package exposes the compiled output in `dist/`; consumers import
from the package name and get bundled `.d.ts` types automatically.

## Usage

```js
import { registerProvider } from "@sedibyte/auth";
import { createMicrosoftProvider, createGraphClient } from "@sedibyte/auth/microsoft";

registerProvider(
  createMicrosoftProvider({
    tenantId: "...",
    clientId: "...",
    clientSecret: "...",
  })
);

const graph = createGraphClient(); // or createGraphClient({ graphBase: ".../beta" })
const users = await graph.graphJson("users");
```

All configuration is passed in explicitly — nothing is read from the
environment. The caller owns loading credentials (from env, a secrets vault,
wherever) and passing them in.

### Just a token

Skip the Graph client and get a raw bearer token for any registered provider:

```js
import { getAccessToken } from "@sedibyte/auth";

const token = await getAccessToken("microsoft");
```

### Multiple Microsoft tenants

`name` is configurable, so you can register several tenants side by side and
point a Graph client at whichever you need:

```js
registerProvider(createMicrosoftProvider({ name: "tenant-a", tenantId: "...", clientId: "...", clientSecret: "..." }));
registerProvider(createMicrosoftProvider({ name: "tenant-b", tenantId: "...", clientId: "...", clientSecret: "..." }));

const graphA = createGraphClient({ providerName: "tenant-a" });
const graphB = createGraphClient({ providerName: "tenant-b" });
```

### Validating inbound tokens

The provider above is the *outbound* direction — acquiring a token so you can
call Graph. To go the other way and verify an Entra bearer token that arrived
at **your own** API, use the validator:

```js
import { createMicrosoftValidator, TokenValidationError } from "@sedibyte/auth/microsoft";

const validator = createMicrosoftValidator({
  tenantId: "<tenant-guid>",
  audience: "<your-api-client-id>", // or App ID URI, or an array of both
});

// e.g. inside an Express middleware
try {
  const { claims } = await validator.validateToken(req.headers.authorization);
  req.user = claims; // iss/aud/exp/nbf and RS256 signature already verified
} catch (err) {
  if (err instanceof TokenValidationError) return res.sendStatus(401);
  throw err;
}
```

Signature verification and JWKS fetching/caching (including key rotation) are
handled by [`jose`](https://github.com/panva/jose). By default both v2.0
(`.../v2.0`) and v1.0 (`sts.windows.net/{tid}/`) issuers are accepted; pass
`issuer` to pin one. `validateToken` accepts a raw JWT or a full
`Authorization` header value (a leading `Bearer ` is stripped).

### Salesforce (JWT bearer)

Server-to-server auth with no interactive login and no client secret. You sign a
short-lived assertion with the private key whose certificate is uploaded to a
[connected app](https://help.salesforce.com/s/articleView?id=sf.connected_app_create.htm);
Salesforce exchanges it for an access token.

```js
import { registerProvider } from "@sedibyte/auth";
import { createSalesforceProvider, createSalesforceClient } from "@sedibyte/auth/salesforce";
import { readFileSync } from "node:fs";

registerProvider(
  createSalesforceProvider({
    clientId: "<connected-app-consumer-key>", // JWT `iss`
    username: "integration@example.com",       // JWT `sub` (pre-authorized user)
    privateKey: readFileSync("server.key", "utf8"), // PKCS#8 PEM
    // loginUrl: "https://test.salesforce.com",     // sandbox; defaults to prod
  })
);

const sf = createSalesforceClient(); // uses the org's instance_url automatically
const { records } = await sf.query("SELECT Id, Name FROM Account LIMIT 5");
const account = await sf.restJson("sobjects/Account/001...");
```

The private key must be **PKCS#8** (a `-----BEGIN PRIVATE KEY-----` block).
Convert a legacy PKCS#1 key with
`openssl pkcs8 -topk8 -nocrypt -in old.key -out server.key`.

The token endpoint doesn't return `expires_in` for this flow, so the provider
caches for `tokenLifetimeSec` (default 3600); set it at or below your org's
session timeout. As with Microsoft, `name` is configurable, so you can register
several orgs side by side and point a client at each with `providerName`.

## API

### `@sedibyte/auth`

- `registerProvider(provider)` — registers a provider `{ name, requestToken() }`.
- `getAccessToken(providerName)` — returns a cached token, or requests a fresh one.
- `clearTokenCache(providerName?)` — clears one provider's cached token, or all.
- `getProvider(name)` — looks up a registered provider (or `undefined`).

A provider's `requestToken()` returns `{ accessToken: string, expiresIn: number }`
(`expiresIn` in seconds). Tokens are refreshed 60s before real expiry.

### `@sedibyte/auth/microsoft`

- `createMicrosoftProvider({ tenantId, clientId, clientSecret, name?, scope? })`
  — creates a Microsoft app-only provider. `name` defaults to `"microsoft"`,
  `scope` to `https://graph.microsoft.com/.default`.
- `createGraphClient({ providerName?, graphBase? })` — returns `{ graphFetch, graphJson }`.
  `providerName` defaults to `"microsoft"`, `graphBase` to the Graph v1.0 endpoint.
  - `graphFetch(path, init?)` — authenticated `fetch`; `path` may be relative or an absolute URL.
  - `graphJson(path, init?)` — same, but parses JSON and throws on non-2xx.
- `createMicrosoftValidator({ tenantId, audience, authority?, issuer?, jwksUri?, clockToleranceSec? })`
  — returns `{ validateToken(token) }` for verifying inbound Entra tokens.
  `authority` defaults to `https://login.microsoftonline.com`, `issuer` to the
  v2.0 and v1.0 issuers for the tenant, `jwksUri` to the tenant's v2.0 signing
  keys, and `clockToleranceSec` to `300`.
  - `validateToken(token)` — verifies RS256 signature, issuer, audience, and
    `exp`/`nbf`. Resolves with `{ header, claims }` or rejects with a
    `TokenValidationError`. Accepts a raw JWT or a `Bearer …` header value.
- `TokenValidationError` — thrown when a token is missing, malformed, or fails
  a check; map it to a 401. The underlying `jose` error is on `.cause`.

### `@sedibyte/auth/salesforce`

- `createSalesforceProvider({ clientId, username, privateKey, loginUrl?, audience?, name?, tokenLifetimeSec? })`
  — creates a Salesforce JWT-bearer provider. `clientId` is the connected-app
  consumer key (`iss`), `username` the user to impersonate (`sub`), `privateKey`
  a PKCS#8 PEM RSA key. `loginUrl` defaults to `https://login.salesforce.com`
  (use `https://test.salesforce.com` for sandboxes), `audience` to `loginUrl`,
  `name` to `"salesforce"`, and `tokenLifetimeSec` to `3600`. The returned
  provider also exposes `getInstanceUrl()` — the `instance_url` from the last
  token response.
- `createSalesforceClient({ providerName?, apiVersion? })` — returns
  `{ restFetch, restJson, query }`, resolving the org's `instance_url` from the
  registered provider. `providerName` defaults to `"salesforce"`, `apiVersion`
  to `"v60.0"`.
  - `restFetch(path, init?)` — authenticated `fetch`; `path` may be a versioned
    resource (`sobjects/...`), an instance-relative path (`/services/...`), or
    an absolute URL.
  - `restJson(path, init?)` — same, but parses JSON and throws on non-2xx.
  - `query(soql)` — runs a SOQL query and returns the parsed `QueryResult`.

## Adding a provider

Create `providers/<name>/provider.js` exporting a factory that returns
`{ name, requestToken() }`:

```js
export function createGoogleProvider({ clientId, clientSecret }) {
  return {
    name: "google",
    async requestToken() {
      // ...fetch a token...
      return { accessToken, expiresIn }; // seconds
    },
  };
}
```

The registry, caching, and expiry-skew logic are inherited for free — nothing in
`auth.js` changes.
