diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b3e46a..5ae77cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: version: latest web: - name: Web package + name: Web packages runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -38,12 +38,12 @@ jobs: bun-version: latest - name: Install run: bun install --frozen-lockfile - - name: Build package - run: bun run --filter '@rxtech-lab/admin-generator-next' build - - name: Typecheck - run: bun run --filter '@rxtech-lab/admin-generator-next' typecheck - - name: Test - run: bun run --filter '@rxtech-lab/admin-generator-next' test + - name: Build packages + run: bun run build + - name: Typecheck packages + run: bun run --filter '@rxtech-lab/*' typecheck + - name: Test packages + run: bun run test - name: Build example app (RSC / directive regression net) run: bun run --filter 'web-example' build env: @@ -62,8 +62,8 @@ jobs: bun-version: latest - name: Install run: bun install --frozen-lockfile - - name: Build package - run: bun run --filter '@rxtech-lab/admin-generator-next' build + - name: Build packages + run: bun run build - name: Install Playwright browser run: bunx playwright install --with-deps chromium # Root test:e2e builds the app, then Playwright boots the Go API + Next app. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5562ba5..042578b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,13 +25,22 @@ jobs: registry-url: "https://registry.npmjs.org" package-manager-cache: false + - name: Upgrade npm for trusted publishing + run: npm install --global "npm@^11.5.1" + - name: Install run: bun install --frozen-lockfile - - name: Build package - run: bun run --filter '@rxtech-lab/admin-generator-next' build + - name: Build packages + run: bun run build + + - name: Typecheck packages + run: bun run --filter '@rxtech-lab/*' typecheck + + - name: Test packages + run: bun run test - # Sets packages/admin-next/package.json to the release tag version, then - # publishes to npm through the package's trusted publisher configuration. - - name: Publish to NPM + # Sets every public package to the release tag version, then publishes + # through each package's npm trusted-publisher configuration. + - name: Publish packages to NPM run: node scripts/publish.js ${{ github.event.release.tag_name }} diff --git a/README.md b/README.md index 09d6a08..b893acb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ catch-all route — no per-resource frontend code. - **Go module** — `github.com/rxtech-lab/admin-generator` - **npm package** — `@rxtech-lab/admin-generator-next` +- **Auth.js package** — `@rxtech-lab/authjs-rxlab` ## Repository layout @@ -26,6 +27,7 @@ catch-all route — no per-resource frontend code. | `datasource/gormds/` | GORM adapter (`DataSource`) | | `datasource/memory/` | In-memory adapter for tests/demos | | `packages/admin-next/` | The npm package (React 19 / Next 15, RJSF forms, shadcn-style theme) | +| `packages/authjs-rxlab/` | Auth.js v5 configuration for RxLab OIDC sessions and refresh-token rotation | | `examples/server/` | Runnable Go demo (SQLite, seeded Authors + Posts) | | `examples/web/` | Runnable Next.js demo consuming the package | @@ -143,10 +145,22 @@ adminhttp.New(reg, adminhttp.WithAuthenticator(auth)) Gate resources on app-scoped roles with `oidc.RequireRole("admin")`. -**Frontend** — use Auth.js's generic OIDC provider (`issuer: "https://auth.rxlab.app"`, -PKCE + state), store the access token on the session, and return it from -`getToken`. A client must be pre-registered in the rxlab-auth dashboard with your -redirect URI and allowed scopes. +**Frontend** — use `@rxtech-lab/authjs-rxlab` to configure Auth.js with the +RxLab OIDC provider, refresh-token rotation, session access tokens, and +app-scoped roles: + +```ts +import { createRxLabAuth } from "@rxtech-lab/authjs-rxlab"; + +export const { handlers, signIn, signOut, auth } = createRxLabAuth({ + issuer: process.env.AUTH_ISSUER!, + clientId: process.env.AUTH_CLIENT_ID!, + clientSecret: process.env.AUTH_CLIENT_SECRET!, +}); +``` + +A client must be pre-registered in the rxlab-auth dashboard with the Auth.js +callback URI and the `openid email profile offline_access` scopes. ## Run the demo @@ -164,18 +178,25 @@ cd examples/web && bun run dev ```bash go test ./... # Go core -bun run --filter '@rxtech-lab/admin-generator-next' test # package tests -bun run --filter '@rxtech-lab/admin-generator-next' build # package build +bun run test # npm package tests +bun run build # npm package builds +bun run --filter '@rxtech-lab/*' typecheck # npm package types ``` ## Publishing -- **npm** — add a changeset (`bun run changeset`); merging to `main` opens a - Version Packages PR, and merging that publishes `@rxtech-lab/admin-generator-next` with - provenance (needs `NPM_TOKEN`). +- **npm** — manually run the `Create Release` workflow. Semantic-release creates + a versioned GitHub Release from conventional commits; `release.yml` then + builds, tests, stamps, and publishes both npm packages through npm trusted + publishing with OIDC. - **Go** — tag the repo: `git tag v0.1.0 && git push --tags`, then `go get github.com/rxtech-lab/admin-generator@v0.1.0`. +Each npm package must trust `rxtech-lab/admin-generator` and `release.yml` in +its npm settings. npm requires a package to exist before trusted publishing can +be configured, so bootstrap a brand-new package once with maintainer +credentials, configure the trusted publisher, and use CI for later releases. + ## License MIT diff --git a/bun.lock b/bun.lock index 10fa930..379cb9e 100644 --- a/bun.lock +++ b/bun.lock @@ -56,12 +56,29 @@ "react-dom": ">=19", }, }, + "packages/authjs-rxlab": { + "name": "@rxtech-lab/authjs-rxlab", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.13.1", + "next": "^15.1.6", + "next-auth": "5.0.0-beta.31", + "react": "^19.0.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5", + }, + "peerDependencies": { + "next-auth": ">=5.0.0-beta.31 <6", + }, + }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@auth/core": ["@auth/core@0.41.2", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -210,6 +227,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.20", "", { "os": "win32", "cpu": "x64" }, "sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], @@ -304,6 +323,8 @@ "@rxtech-lab/admin-generator-next": ["@rxtech-lab/admin-generator-next@workspace:packages/admin-next"], + "@rxtech-lab/authjs-rxlab": ["@rxtech-lab/authjs-rxlab@workspace:packages/authjs-rxlab"], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -516,6 +537,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], @@ -576,8 +599,12 @@ "next": ["next@15.5.20", "", { "dependencies": { "@next/env": "15.5.20", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.20", "@next/swc-darwin-x64": "15.5.20", "@next/swc-linux-arm64-gnu": "15.5.20", "@next/swc-linux-arm64-musl": "15.5.20", "@next/swc-linux-x64-gnu": "15.5.20", "@next/swc-linux-x64-musl": "15.5.20", "@next/swc-win32-arm64-msvc": "15.5.20", "@next/swc-win32-x64-msvc": "15.5.20", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA=="], + "next-auth": ["next-auth@5.0.0-beta.31", "", { "dependencies": { "@auth/core": "0.41.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q=="], + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], + "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -596,6 +623,10 @@ "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], + + "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], diff --git a/package.json b/package.json index 3946706..cb925fe 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "examples/web" ], "scripts": { - "build": "bun run --filter '@rxtech-lab/admin-generator-next' build", + "build": "bun run --filter '@rxtech-lab/*' build", "typecheck": "bun run --filter '*' typecheck", - "test": "bun run --filter '@rxtech-lab/admin-generator-next' test", + "test": "bun run --filter '@rxtech-lab/*' test", "test:e2e": "bun run --filter 'web-example' test:e2e", "test:e2e:ui": "bun run --filter 'web-example' test:e2e:ui" } diff --git a/packages/authjs-rxlab/LICENSE b/packages/authjs-rxlab/LICENSE new file mode 100644 index 0000000..bc1882c --- /dev/null +++ b/packages/authjs-rxlab/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RxTech Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/authjs-rxlab/README.md b/packages/authjs-rxlab/README.md new file mode 100644 index 0000000..2926d6a --- /dev/null +++ b/packages/authjs-rxlab/README.md @@ -0,0 +1,78 @@ +# @rxtech-lab/authjs-rxlab + +Auth.js v5 integration for the RxLab OIDC service. It configures the RxLab +provider, keeps the OAuth access token and app-specific roles on the session, +and rotates expired access tokens without shortening the Auth.js session. + +## Install + +```bash +bun add @rxtech-lab/authjs-rxlab next-auth@beta +``` + +## Usage + +```ts +// lib/auth.ts +import { createRxLabAuth } from "@rxtech-lab/authjs-rxlab"; + +export const { handlers, signIn, signOut, auth } = createRxLabAuth({ + issuer: process.env.AUTH_ISSUER!, + clientId: process.env.AUTH_CLIENT_ID!, + clientSecret: process.env.AUTH_CLIENT_SECRET!, + signInPage: "/login", +}); +``` + +```ts +// app/api/auth/[...nextauth]/route.ts +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; +``` + +Sign in with the provider ID `rxlab`: + +```ts +await signIn("rxlab", { redirectTo: "/admin" }); +``` + +The resulting session includes the access token and RxLab roles: + +```ts +const session = await auth(); +const bearer = session?.accessToken; +const isAdmin = session?.user?.roles?.includes("admin") ?? false; +``` + +## Environment + +The application still owns Auth.js's standard `AUTH_SECRET` and public base URL +configuration. Pass the RxLab client values explicitly so missing configuration +fails at startup instead of during an OAuth callback. + +```dotenv +AUTH_ISSUER=https://auth.rxlab.app +AUTH_CLIENT_ID=your-client-id +AUTH_CLIENT_SECRET=your-client-secret +AUTH_SECRET=replace-with-a-random-secret +``` + +The RxLab OAuth client must allow your Auth.js callback URL and the scopes +`openid email profile offline_access`. + +## Refresh behavior + +- Uses an encrypted Auth.js JWT session lasting 30 days by default. +- Stores OAuth expiry in `expiresAt`, separate from Auth.js's reserved `exp`. +- Refreshes access tokens 60 seconds before expiry. +- Persists a rotated refresh token, or keeps the previous token when the server + does not return a replacement. +- Exposes `RefreshTokenError` on `session.error` when re-authentication is + required. +- Logs only token presence, expiry, event, and HTTP status. Token values are + never passed to the optional logger. + +All defaults can be adjusted through `RxLabAuthOptions`. Advanced applications +can call `createRxLabAuthConfig(options)` and inspect or extend the resulting +Auth.js configuration before passing it to `NextAuth`. diff --git a/packages/authjs-rxlab/package.json b/packages/authjs-rxlab/package.json new file mode 100644 index 0000000..e7f8244 --- /dev/null +++ b/packages/authjs-rxlab/package.json @@ -0,0 +1,44 @@ +{ + "name": "@rxtech-lab/authjs-rxlab", + "version": "0.1.0", + "description": "Auth.js integration for RxLab OIDC with refresh-token rotation and typed sessions.", + "license": "MIT", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/rxtech-lab/admin-generator.git", + "directory": "packages/authjs-rxlab" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "next-auth": ">=5.0.0-beta.31 <6" + }, + "devDependencies": { + "@types/node": "^22.13.1", + "next": "^15.1.6", + "next-auth": "5.0.0-beta.31", + "react": "^19.0.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/authjs-rxlab/src/index.test.ts b/packages/authjs-rxlab/src/index.test.ts new file mode 100644 index 0000000..0865522 --- /dev/null +++ b/packages/authjs-rxlab/src/index.test.ts @@ -0,0 +1,238 @@ +import type { Account, Profile, Session } from "next-auth"; +import type { JWT } from "next-auth/jwt"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-auth", () => ({ + default: vi.fn(() => ({ + handlers: {}, + auth: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), + })), +})); + +import { + createRxLabAuthConfig, + RX_LAB_PROVIDER_ID, + RX_LAB_REFRESH_TOKEN_ERROR, + type RxLabAuthLogger, +} from "./index"; + +const baseOptions = { + issuer: "https://auth.rxlab.app/", + clientId: "admin-client", + clientSecret: "client-secret", + debug: false, +}; + +function callbacks(options = baseOptions) { + const config = createRxLabAuthConfig(options); + if (!config.callbacks?.jwt || !config.callbacks.session) { + throw new Error("Auth callbacks were not configured"); + } + return config.callbacks; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createRxLabAuthConfig", () => { + it("configures the RxLab OIDC provider and long-lived JWT session", () => { + const config = createRxLabAuthConfig(baseOptions); + const provider = config.providers[0]; + + expect(provider).toMatchObject({ + id: RX_LAB_PROVIDER_ID, + name: "RxLab", + type: "oidc", + issuer: "https://auth.rxlab.app", + clientId: "admin-client", + clientSecret: "client-secret", + client: { token_endpoint_auth_method: "client_secret_post" }, + authorization: { + params: { scope: "openid email profile offline_access" }, + }, + }); + expect(config.pages).toEqual({ signIn: "/login" }); + expect(config.session).toEqual({ + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, + }); + expect(config.trustHost).toBe(true); + }); + + it("rejects missing credentials and invalid refresh leeway", () => { + expect(() => + createRxLabAuthConfig({ ...baseOptions, issuer: " " }), + ).toThrow("issuer is required"); + expect(() => + createRxLabAuthConfig({ ...baseOptions, refreshLeeway: -1 }), + ).toThrow("refreshLeeway must be a non-negative number"); + }); + + it("stores initial login state without preserving the reserved exp claim", async () => { + const result = await callbacks().jwt({ + token: { exp: 123, name: "Admin" }, + account: { + access_token: "access-one", + refresh_token: "refresh-one", + expires_at: 2_000, + } as Account, + profile: { sub: "oauth:user-1", roles: ["admin"] } as Profile, + trigger: "signIn", + user: {} as never, + isNewUser: false, + }); + + expect(result).toMatchObject({ + name: "Admin", + accessToken: "access-one", + refreshToken: "refresh-one", + expiresAt: 2_000, + userId: "oauth:user-1", + roles: ["admin"], + }); + expect(result).not.toHaveProperty("exp"); + }); + + it("keeps an access token that is outside the refresh window", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_000_000); + const token: JWT = { + accessToken: "access-one", + refreshToken: "refresh-one", + expiresAt: 2_000, + }; + + const result = await callbacks().jwt({ token, trigger: "update" }); + + expect(result).toBe(token); + }); + + it("refreshes an expired access token and persists rotation", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + access_token: "access-two", + refresh_token: "refresh-two", + expires_in: 3_600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const result = await callbacks({ ...baseOptions, fetch: fetchMock }).jwt({ + token: { + exp: 99, + accessToken: "access-one", + refreshToken: "refresh-one", + expiresAt: 1_900, + roles: ["admin"], + }, + trigger: "update", + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://auth.rxlab.app/api/oauth/token"); + expect(init.method).toBe("POST"); + expect(init.body?.toString()).toBe( + "grant_type=refresh_token&refresh_token=refresh-one&client_id=admin-client&client_secret=client-secret", + ); + expect(result).toMatchObject({ + accessToken: "access-two", + refreshToken: "refresh-two", + expiresAt: 5_600, + roles: ["admin"], + }); + expect(result).not.toHaveProperty("exp"); + }); + + it("preserves the old refresh token when the endpoint does not rotate it", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ access_token: "access-two", expires_in: 3_600 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const result = await callbacks({ ...baseOptions, fetch: fetchMock }).jwt({ + token: { refreshToken: "refresh-one", expiresAt: 1_900 }, + trigger: "update", + }); + + expect(result.refreshToken).toBe("refresh-one"); + }); + + it("sets a session error when no refresh token is available", async () => { + const result = await callbacks().jwt({ + token: { exp: 99, expiresAt: 1 }, + trigger: "update", + }); + + expect(result.error).toBe(RX_LAB_REFRESH_TOKEN_ERROR); + expect(result).not.toHaveProperty("exp"); + }); + + it("reports refresh failures without exposing credentials to the logger", async () => { + const logger = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: "invalid_grant", + leaked_value: "refresh-one", + }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ), + ); + const result = await callbacks({ + ...baseOptions, + fetch: fetchMock, + logger, + }).jwt({ + token: { refreshToken: "refresh-one", expiresAt: 1 }, + trigger: "update", + }); + + expect(result.error).toBe(RX_LAB_REFRESH_TOKEN_ERROR); + expect(logger).toHaveBeenLastCalledWith("error", { + event: "refresh-failed", + hasRefreshToken: true, + expiresAt: 1, + status: 401, + }); + expect(JSON.stringify(logger.mock.calls)).not.toContain("refresh-one"); + expect(JSON.stringify(logger.mock.calls)).not.toContain("client-secret"); + }); + + it("projects the access token, identity, roles, and error onto the session", async () => { + const session: Session = { + user: { id: "temporary", name: "Old", email: "old@example.com" }, + expires: "2099-01-01T00:00:00.000Z", + }; + const result = await callbacks().session({ + session, + token: { + userId: "oauth:user-1", + name: "Admin", + email: "admin@example.com", + roles: ["admin"], + accessToken: "access-one", + error: RX_LAB_REFRESH_TOKEN_ERROR, + }, + trigger: "update", + newSession: undefined, + }); + + expect(result).toMatchObject({ + accessToken: "access-one", + error: RX_LAB_REFRESH_TOKEN_ERROR, + user: { + id: "oauth:user-1", + name: "Admin", + email: "admin@example.com", + roles: ["admin"], + }, + }); + }); +}); diff --git a/packages/authjs-rxlab/src/index.ts b/packages/authjs-rxlab/src/index.ts new file mode 100644 index 0000000..f0ef4ae --- /dev/null +++ b/packages/authjs-rxlab/src/index.ts @@ -0,0 +1,290 @@ +import NextAuth, { + type NextAuthConfig, + type NextAuthResult, +} from "next-auth"; + +export const RX_LAB_PROVIDER_ID = "rxlab"; +export const RX_LAB_REFRESH_TOKEN_ERROR = "RefreshTokenError"; + +export type RxLabAuthLogLevel = "debug" | "warn" | "error"; + +export interface RxLabAuthLogEntry { + event: + | "initial-login" + | "refresh-needed" + | "refresh-succeeded" + | "refresh-missing-token" + | "refresh-failed"; + hasRefreshToken: boolean; + expiresAt: number | null; + status?: number; +} + +export type RxLabAuthLogger = ( + level: RxLabAuthLogLevel, + entry: RxLabAuthLogEntry, +) => void; + +export interface RxLabAuthOptions { + issuer: string; + clientId: string; + clientSecret: string; + /** Auth.js sign-in page. Defaults to `/login`. */ + signInPage?: string; + /** OAuth scopes. Defaults to `openid email profile offline_access`. */ + scope?: string; + /** Auth.js session lifetime in seconds. Defaults to 30 days. */ + sessionMaxAge?: number; + /** Refresh this many seconds before access-token expiry. Defaults to 60. */ + refreshLeeway?: number; + /** Forwarded to Auth.js. Defaults to true in development. */ + debug?: boolean; + /** Forwarded to Auth.js. Defaults to true for reverse-proxy deployments. */ + trustHost?: boolean; + /** Receives redacted lifecycle metadata. Token values are never included. */ + logger?: RxLabAuthLogger; + /** Injectable for tests and runtimes with a custom fetch implementation. */ + fetch?: typeof globalThis.fetch; +} + +interface RxLabTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; +} + +interface RxLabJWTFields { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + userId?: string; + roles?: string[]; + error?: string; +} + +declare module "next-auth" { + interface Session { + accessToken?: string; + error?: string; + } + + interface User { + roles?: string[]; + } +} + +function requireOption(name: string, value: string): string { + const normalized = value.trim(); + if (!normalized) throw new TypeError(`${name} is required`); + return normalized; +} + +function normalizeIssuer(issuer: string): string { + return requireOption("issuer", issuer).replace(/\/+$/, ""); +} + +function isTokenResponse(value: unknown): value is RxLabTokenResponse { + if (!value || typeof value !== "object") return false; + const token = value as Record; + return ( + typeof token.access_token === "string" && + typeof token.expires_in === "number" + ); +} + +function defaultLogger( + enabled: boolean, +): RxLabAuthLogger | undefined { + if (!enabled) return undefined; + return (level, entry) => { + const message = `[rxlab auth] ${entry.event}`; + if (level === "error") console.error(message, entry); + else if (level === "warn") console.warn(message, entry); + else console.log(message, entry); + }; +} + +/** + * Build an Auth.js configuration for the RxLab OIDC provider. + * + * Access-token expiry is deliberately stored under `expiresAt`, not Auth.js's + * reserved JWT `exp` claim, so the longer-lived session can refresh tokens. + */ +export function createRxLabAuthConfig( + options: RxLabAuthOptions, +): NextAuthConfig { + const issuer = normalizeIssuer(options.issuer); + const clientId = requireOption("clientId", options.clientId); + const clientSecret = requireOption("clientSecret", options.clientSecret); + const debug = + options.debug ?? + (typeof process !== "undefined" && process.env.NODE_ENV === "development"); + const logger = options.logger ?? defaultLogger(debug); + const fetchImpl = options.fetch ?? globalThis.fetch; + const refreshLeeway = options.refreshLeeway ?? 60; + + if (!Number.isFinite(refreshLeeway) || refreshLeeway < 0) { + throw new TypeError("refreshLeeway must be a non-negative number"); + } + if (typeof fetchImpl !== "function") { + throw new TypeError("A fetch implementation is required"); + } + + async function refreshAccessToken(refreshToken: string) { + const response = await fetchImpl(`${issuer}/api/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }), + }); + + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + + if (!response.ok || !isTokenResponse(body)) { + const error = new Error(`RxLab token refresh failed (${response.status})`); + Object.assign(error, { status: response.status }); + throw error; + } + + return { + accessToken: body.access_token, + refreshToken: body.refresh_token ?? refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + body.expires_in, + }; + } + + return { + debug, + providers: [ + { + id: RX_LAB_PROVIDER_ID, + name: "RxLab", + type: "oidc", + issuer, + clientId, + clientSecret, + client: { + token_endpoint_auth_method: "client_secret_post", + }, + authorization: { + params: { + scope: options.scope ?? "openid email profile offline_access", + }, + }, + }, + ], + callbacks: { + async jwt({ token, account, profile }) { + const rxLabToken = token as typeof token & RxLabJWTFields; + if (account) { + if (!account.access_token || !account.expires_at) { + throw new TypeError( + "RxLab did not return an access token and expiry", + ); + } + const { exp: _sessionExpiry, ...rest } = rxLabToken; + logger?.("debug", { + event: "initial-login", + hasRefreshToken: Boolean(account.refresh_token), + expiresAt: account.expires_at, + }); + return { + ...rest, + accessToken: account.access_token, + refreshToken: account.refresh_token, + expiresAt: account.expires_at, + userId: profile?.sub, + roles: Array.isArray(profile?.roles) + ? (profile.roles as string[]) + : [], + }; + } + + if ( + rxLabToken.expiresAt && + Date.now() < rxLabToken.expiresAt * 1000 - refreshLeeway * 1000 + ) { + return token; + } + + const { exp: _sessionExpiry, ...rest } = rxLabToken; + logger?.("debug", { + event: "refresh-needed", + hasRefreshToken: Boolean(rxLabToken.refreshToken), + expiresAt: rxLabToken.expiresAt ?? null, + }); + + if (!rxLabToken.refreshToken) { + logger?.("warn", { + event: "refresh-missing-token", + hasRefreshToken: false, + expiresAt: rxLabToken.expiresAt ?? null, + }); + return { ...rest, error: RX_LAB_REFRESH_TOKEN_ERROR }; + } + + try { + const fresh = await refreshAccessToken(rxLabToken.refreshToken); + logger?.("debug", { + event: "refresh-succeeded", + hasRefreshToken: true, + expiresAt: fresh.expiresAt, + }); + return { + ...rest, + accessToken: fresh.accessToken, + refreshToken: fresh.refreshToken, + expiresAt: fresh.expiresAt, + error: undefined, + }; + } catch (error) { + const status = + error instanceof Error && + "status" in error && + typeof error.status === "number" + ? error.status + : undefined; + logger?.("error", { + event: "refresh-failed", + hasRefreshToken: true, + expiresAt: rxLabToken.expiresAt ?? null, + ...(status === undefined ? {} : { status }), + }); + return { ...rest, error: RX_LAB_REFRESH_TOKEN_ERROR }; + } + }, + async session({ session, token }) { + const rxLabToken = token as typeof token & RxLabJWTFields; + if (session.user) { + if (rxLabToken.userId) session.user.id = rxLabToken.userId; + if (token.name) session.user.name = token.name; + if (token.email) session.user.email = token.email; + session.user.roles = rxLabToken.roles ?? []; + } + session.accessToken = rxLabToken.accessToken; + session.error = rxLabToken.error; + return session; + }, + }, + pages: { signIn: options.signInPage ?? "/login" }, + trustHost: options.trustHost ?? true, + session: { + strategy: "jwt", + maxAge: options.sessionMaxAge ?? 30 * 24 * 60 * 60, + }, + }; +} + +/** Create the complete Auth.js result used by route handlers and applications. */ +export function createRxLabAuth(options: RxLabAuthOptions): NextAuthResult { + return NextAuth(createRxLabAuthConfig(options)); +} diff --git a/packages/authjs-rxlab/tsconfig.json b/packages/authjs-rxlab/tsconfig.json new file mode 100644 index 0000000..21672c3 --- /dev/null +++ b/packages/authjs-rxlab/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": false, + "isolatedModules": true, + "moduleDetection": "force" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/authjs-rxlab/vitest.config.ts b/packages/authjs-rxlab/vitest.config.ts new file mode 100644 index 0000000..f624398 --- /dev/null +++ b/packages/authjs-rxlab/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/scripts/publish.js b/scripts/publish.js index 0af4438..0bae861 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -1,4 +1,4 @@ -const { execSync } = require("child_process"); +const { execSync, spawnSync } = require("child_process"); const fs = require("fs"); const path = require("path"); @@ -11,19 +11,48 @@ if (!version) { // Strip any leading "v" so package.json holds a clean semver. version = version.replace(/^v/, ""); -// The publishable package lives in the monorepo, not the private root. -const pkgDir = path.join(__dirname, "..", "packages", "admin-next"); -const pkgPath = path.join(pkgDir, "package.json"); - -// Set the package version from the release tag. -const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); -pkg.version = version; -fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); -console.log(`Set ${pkg.name} version to ${version}`); - -// Publish from the package directory. In GitHub Actions, npm authenticates via -// the package's trusted publisher OIDC configuration. -execSync("npm publish --access public", { - cwd: pkgDir, - stdio: "inherit", -}); +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error(`Invalid release version: ${version}`); + process.exit(1); +} + +// Public packages share the repository's semantic-release version. +const packageDirectories = ["admin-next", "authjs-rxlab"]; + +function isPublished(name, packageVersion) { + const result = spawnSync( + "npm", + ["view", `${name}@${packageVersion}`, "version", "--json"], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status !== 0) return false; + + try { + return JSON.parse(result.stdout) === packageVersion; + } catch { + return false; + } +} + +for (const directory of packageDirectories) { + const pkgDir = path.join(__dirname, "..", "packages", directory); + const pkgPath = path.join(pkgDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + + pkg.version = version; + fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + console.log(`Set ${pkg.name} version to ${version}`); + + // A release rerun may follow a partial multi-package publish. Skip versions + // already present so the remaining package can finish publishing. + if (isPublished(pkg.name, version)) { + console.log(`${pkg.name}@${version} is already published; skipping`); + continue; + } + + // In GitHub Actions, npm authenticates via this package's trusted publisher. + execSync("npm publish --access public", { + cwd: pkgDir, + stdio: "inherit", + }); +}