Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Expand All @@ -17,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -30,6 +33,12 @@ jobs:

- run: pnpm typecheck

- run: pnpm lint
- name: Lint
run: |
pnpm lint
if ! git diff --exit-code; then
echo "::error::pnpm lint changed the files above. Run it locally and commit the result."
exit 1
fi

- run: pnpm test
133 changes: 108 additions & 25 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
name: Publish

# Publishes when a push to main changes a package version, or on demand.
# Only versions npm doesn't have yet go out, so any other edit to a package.json publishes nothing.
# Pull requests never trigger it, and a manual run from any branch but main is a dry run.
on:
push:
branches: [main]
paths:
- packages/*/package.json
workflow_dispatch:
inputs:
packages:
description: "'all', or one pnpm filter such as @wilsoon/auth-machine"
type: string
default: all
dry_run:
description: "Run every check and pack the tarballs, but publish nothing"
type: boolean
Expand All @@ -17,51 +20,131 @@ concurrency:
cancel-in-progress: false

permissions:
contents: read
id-token: write
contents: write # pushes the release tags
id-token: write # npm provenance and trusted publishing

jobs:
publish:
# Forks have no business publishing, whatever their workflows say.
if: github.repository == 'Wilsoon7721/node-oidc-kit'
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile --ignore-scripts
# Decides what to release, and refuses a release that looks unfinished:
# - auth-core, auth-react and auth-next must share one version (auth-machine is independent);
# - every version going out needs its entry in CHANGELOG.md.
- name: Find unpublished versions
id: plan
run: |
node <<'JS'
const fs = require("fs");
const { execFileSync } = require("child_process");

const LOCKSTEP = ["@wilsoon/auth-core", "@wilsoon/auth-react", "@wilsoon/auth-next"];
const ORDER = [...LOCKSTEP, "@wilsoon/auth-machine"];
const changelog = fs.readFileSync("CHANGELOG.md", "utf8").split(/\r?\n/);

const packages = fs.readdirSync("packages")
.map((dir) => JSON.parse(fs.readFileSync(`packages/${dir}/package.json`, "utf8")))
.filter((pkg) => !pkg.private)
.sort((a, b) => ORDER.indexOf(a.name) - ORDER.indexOf(b.name));

const fail = (message) => {
console.log(`::error::${message}`);
process.exit(1);
};

const kit = new Set(packages.filter((pkg) => LOCKSTEP.includes(pkg.name)).map((pkg) => pkg.version));
if (kit.size > 1) fail(`${LOCKSTEP.join(", ")} must share one version, but have ${[...kit].join(", ")}. Bump all three together.`);

// Only a definite "not found" counts as unpublished. Any other failure stops the run rather than guessing.
const onNpm = (pkg) => {
try {
execFileSync("npm", ["view", `${pkg.name}@${pkg.version}`, "version", "--registry", "https://registry.npmjs.org"], { stdio: "pipe" });
return true;
} catch (error) {
const output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
if (/E404|404 Not Found/.test(output)) return false;
fail(`Could not ask npm about ${pkg.name}@${pkg.version}: ${output.trim().split("\n")[0]}`);
}
};

const pending = packages.filter((pkg) => !onNpm(pkg));
for (const pkg of pending) {
const documented = LOCKSTEP.includes(pkg.name)
? changelog.includes(`## ${pkg.version}`)
: changelog.some((line) => line.startsWith("#") && line.includes(pkg.name.split("/")[1]) && line.includes(pkg.version));
if (!documented) fail(`${pkg.name}@${pkg.version} has no entry in CHANGELOG.md. Add one, or revert the version bump.`);
}

- run: pnpm build
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
if (pending.length === 0) console.log("::notice::Every package version is already on npm. Nothing to publish.");
for (const pkg of pending) console.log(`::notice::Will publish ${pkg.name}@${pkg.version}`);

// One tag per release: v<version> for the kit, <short name>@<version> for anything versioned on its own.
const tags = new Set(pending.map((pkg) => (LOCKSTEP.includes(pkg.name) ? `v${pkg.version}` : `${pkg.name.split("/")[1]}@${pkg.version}`)));

const out = fs.createWriteStream(process.env.GITHUB_OUTPUT, { flags: "a" });
out.write(`pending=${pending.map((pkg) => `${pkg.name}@${pkg.version}`).join(" ")}\n`);
out.write(`tags=${[...tags].join(" ")}\n`);
out.end();
JS

- name: Build and check
if: steps.plan.outputs.pending != ''
run: |
pnpm install --frozen-lockfile --ignore-scripts
pnpm build
pnpm typecheck
pnpm lint
git diff --exit-code
pnpm test

- name: Show what would be published
if: steps.plan.outputs.pending != ''
env:
PACKAGES: ${{ inputs.packages }}
PENDING: ${{ steps.plan.outputs.pending }}
run: |
if [ "$PACKAGES" = "all" ]; then
pnpm -r publish --dry-run --no-git-checks
else
pnpm --filter "$PACKAGES" publish --dry-run --no-git-checks
fi
for spec in $PENDING; do
pnpm --filter "${spec%@*}" publish --dry-run --no-git-checks
done

# A version with a pre-release part (3.1.0-beta.1) goes to the `next` tag, so `npm install` keeps getting the last stable one.
- name: Publish
if: ${{ !inputs.dry_run }}
if: steps.plan.outputs.pending != '' && !inputs.dry_run && github.ref == 'refs/heads/main'
env:
PACKAGES: ${{ inputs.packages }}
PENDING: ${{ steps.plan.outputs.pending }}
# Empty when the repo has no NPM_TOKEN secret, which is the intended end state
# once trusted publishing is confirmed working.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ "$PACKAGES" = "all" ]; then
pnpm -r publish --provenance --access public --no-git-checks
else
pnpm --filter "$PACKAGES" publish --provenance --access public --no-git-checks
fi
for spec in $PENDING; do
name="${spec%@*}"
version="${spec##*@}"
tag=latest
case "$version" in *-*) tag=next ;; esac
pnpm --filter "$name" publish --provenance --access public --no-git-checks --tag "$tag"
done

- name: Tag the release
if: steps.plan.outputs.pending != '' && !inputs.dry_run && github.ref == 'refs/heads/main'
env:
TAGS: ${{ steps.plan.outputs.tags }}
run: |
for tag in $TAGS; do
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then
echo "::notice::Tag $tag already exists."
continue
fi
git tag "$tag" "$GITHUB_SHA"
git push origin "refs/tags/$tag"
done
134 changes: 134 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,140 @@ The three original packages (`@wilsoon/auth-core`, `@wilsoon/auth-react`,
`@wilsoon/auth-next`) are versioned together. `@wilsoon/auth-machine` has no dependency on
them and is versioned independently, starting at 0.x.

## 3.0.0

The core is now standards-only. Everything specific to one identity provider moved behind a
**provider profile**, which adapts claims, picks storage names and adds functions that are in
no spec, namespaced under the profile's name. A provider quirk no longer needs a kit release.

### Migrating

Install the WilsoonID profile, which ships separately from the kit:

```sh
pnpm add @wilsoon/auth-provider-wilsoon
```

Then one line per app. Pass your provider's profile, and 2.x behaviour and storage names come back:

```ts
import { wilsoon } from "@wilsoon/auth-provider-wilsoon";

new AuthClient({ ...config, profile: wilsoon() }); // 2.x behaviour, same storage keys
const client = createAuthClient({ ...config, profile: wilsoon() });
await client.wilsoon.reauthorize(["passkey"]); // typed extensions
```

Do not annotate the config object as `AuthConfig` if you want `client.wilsoon` typed. The
annotation widens `profile` and loses its name; spread the profile in last, or use `satisfies`.

The 2.x methods below still work on `AuthClient` when a profile provides them. They warn once
and forward to the profile, and are removed in 4.0. Without a profile they throw
`PROFILE_REQUIRED`.

| 2.x | 3.0 |
|---|---|
| `client.createEscalation()`, `pollEscalation()`, `reauthorize()`, `verifyEscalationToken()` | `client.wilsoon.*`, or the portable `createStepUpRequest()` / `login({ acrValues, maxAge })` |
| `client.verifyPlatformSession()` | `client.wilsoon.verifyPlatformSession()`; `resolveSession()` still routes to it |
| `client.isSessionCurrent()` | `client.wilsoon.isSessionCurrent()`, or `enforce: "live"` in the middleware |
| `client.hydrateSession()` | `client.silentAuthorize()`; with a profile, `hydrateSession()` forwards to its cookie restore |
| `user.role === "admin"` | `hasRole(user, "admin")` / `hasPermission(user, "<slug>.<key>")` |
| `requireSession(config, { roles: ["admin"] })` | `requireSession(config, { permissions: ["<slug>.admin"] })` (`roles` still works, any-of) |
| `STORAGE_KEYS.tokens` | `client.storageKeys.tokens` |
| `AuthConfig.cookieDomain` | `cookieDomain` on `createAuthMiddleware` options or `ServerCookieStorage` |
| `AuthConfig.escalationEndpoint`, `resolveSessionVersion`, `platformSessionCacheSeconds` | the WilsoonID profile's options (still read as fallbacks) |

### Changed - `@wilsoon/auth-core`

- **Storage names default to `oidc_tokens`, `oidc_state`, `oidc_nonce`, `oidc_verifier`.**
`storagePrefix` changes the prefix. A profile can set exact names; the WilsoonID profile
keeps `wilsoon_id_tokens` and `wilsoon_auth_*`. An app that upgrades without a profile
signs its users out once.
- **`AuthenticatedUser.role` is optional and deprecated.** Users gain `roles`, `permissions`
and `sid`; `roles` and `permissions` are empty unless `rolesClaim`, `permissionsClaim` or
a profile fills them. `sessionVersion` is deprecated and set only by the WilsoonID profile.
- **An unrecognised role no longer fails the login.** 2.x threw on any role outside
`admin`/`user`. It now lands in `roles` and never maps onto a known `role`.
- **Machine detection without a profile is RFC 9068 only** (`sub === client_id`). The
`token_use: "client"` check moved to the WilsoonID profile. `isMachineToken()` is unchanged.
- **`resolveSession()` without a profile refuses a sibling client's session** with
`FOREIGN_SESSION`. Shared sessions need a profile that resolves them, `apiAudience` and a
`clientSecret`.
- `getLogoutUrl()` accepts no ID token and sends `client_id` instead (RP-Initiated Logout 1.0).
- Log prefix is `[node-oidc-kit]`.

### Removed - `@wilsoon/auth-core`

Everything deprecated before 3.0:

- `CookieStorage`, a no-op since 1.2.0. Use `silentAuthorize()` in the browser, or a
server-side adapter such as `ServerCookieStorage`.
- `parseIdToken()`. Use `verifyIdToken()`, or `decodeIdTokenUnsafe()` for display only.
- `isTokenExpired()`. Use `isTokenNearExpiry()`.
- The `User` type. Use `AuthenticatedUser`.

### Documentation

- `INTEGRATION.md` is gone. Its framework recipes, troubleshooting table and pre-ship checklist
moved to a new Recipes chapter on the docs site, rewritten for 3.0. The 1.x migration notes
were dropped.

### Added - `@wilsoon/auth-core`

- `ProviderProfile`, `ProfileContext`, `defineProfile()` and `createAuthClient()`, which types
the profile's extensions under `client.<name>`.
- `rolesClaim` / `permissionsClaim` (a dot path or a function), with `hasRole`,
`hasPermission`, `requireRole`, `requirePermission`, `readClaimPath`, `readClaimList`. These
cover Auth0 (`permissions`), Entra ID (`roles`) and Keycloak (`resource_access.<client>.roles`)
with no profile.
- The session's own access token adds its roles and permissions to the user, in
`handleCallback()` and `resolveSession()`. It counts only when it verifies, names this client
as `client_id` and has the same subject. WilsoonID carries `permissions` only there.
- `silentAuthorize()`: session restore through `prompt=none`, in a hidden iframe (default) or a
top-level redirect. `isSilentAuthError()` and `SILENT_AUTH_ERRORS` read `login_required` and
its siblings as "not signed in".
- `liveAccess(token)`: whether the provider still honours a token, and the user's permissions
right now. Uses the profile's hook, or plain introspection.
- `handleCallback()` checks an `iss` on the authorization response (RFC 9207), and requires it
when discovery advertises `authorization_response_iss_parameter_supported`.
- `client.storageKeys`, `client.profileName`, `DEFAULT_STORAGE_KEYS`, `storageKeysFor()`.
- `roles`, `permissions` and `sid` on `AccessTokenClaims`; `permissions` and `sid` on
`IntrospectionResponse`.

### Changed - `@wilsoon/auth-react`

- **`AuthProvider` restores sessions with `silentAuthorize()`** and falls back to the profile's
restore, such as WilsoonID's cookie, when that finds nothing. `restore="profile"` keeps the
2.x behaviour; `restore="none"` turns restore off. The `redirectUri` page must allow being
framed by its own origin.
- Rendered inside the silent-sign-in iframe, `AuthProvider` does nothing, so the parent finishes
the exchange.
- Logout without an ID token uses the discovered `end_session_endpoint` with `client_id`,
instead of a hardcoded `/api/logout`.

### Added - `@wilsoon/auth-react`

- `profile`, `rolesClaim`, `permissionsClaim`, `storagePrefix`, `restore` and `silentTimeoutMs`
props.
- `useAuth()` exposes `client` and the profile's extensions under its name;
`useAuth<typeof profile>()` types them.

### Changed - `@wilsoon/auth-next`

- The default session cookie name is the client's `storageKeys.tokens`. With the WilsoonID
profile that is still `wilsoon_id_tokens`.
- `roles` on the middleware and `requireSession()` is `string[]`, matched with `hasRole` (any-of).
- `ServerCookieStorage` treats a key ending in `tokens` as the long-lived token cookie, so any
prefix works. Pass `storageKeys` for a name that does not.

### Added - `@wilsoon/auth-next`

- `permissions` (all-of) on the middleware and `requireSession()`.
- `enforce: "live"` on the middleware. It asks the provider on every request, signs the user out
when the session is gone, and guards `permissions` on what the user holds now. If the provider
cannot be asked it answers 503 and keeps the cookie, so an outage signs nobody out.
- The client cache keys on the profile and claim selectors, so define them once at module level.

## 2.3.0

One fix, in the Next.js middleware: it was ending the sessions it was meant to be keeping.
Expand Down
Loading
Loading