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
16 changes: 16 additions & 0 deletions .github/workflows/lint-pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Lint PR Title

on:
pull_request_target:
types: [opened, edited, synchronize]

permissions:
pull-requests: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 changes: 35 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Release Please

on:
push:
branches:
- main

permissions:
contents: write
pull-requests: write
id-token: write

jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- name: Generate token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
app-id: ${{ vars.SDK_BOT_APP_ID }}
private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }}

- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
id: release
with:
token: ${{ steps.generate-token.outputs.token }}

publish:
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
uses: ./.github/workflows/release.yml
34 changes: 34 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Release

on:
workflow_dispatch:
workflow_call:

defaults:
run:
shell: bash

jobs:
test:
name: Publish to NPM
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'

- name: Install Dependencies
run: pnpm install

- name: Build project
run: pnpm run build

- name: Publish
run: pnpm publish --tag latest --access=public --provenance --no-git-checks
2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"singleQuote": true,
"ignorePatterns": ["dist", "coverage", "node_modules", "docs"]
"ignorePatterns": ["dist", "coverage", "node_modules"]
}
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
50 changes: 34 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,22 +327,23 @@ function Profile() {
}
```

| Property | Type | Description |
| ---------------------- | ------------------------------------------------- | --------------------------------------------------------------- |
| `user` | `User \| null` | The signed-in user, or `null` when signed out |
| `isLoading` | `boolean` | `true` until the first `getUser()` resolves |
| `sessionId` | `string \| undefined` | Session ID (signed in only) |
| `organizationId` | `string \| undefined` | Active organization (signed in only) |
| `role` / `roles` | `string` / `string[] \| undefined` | Role claim(s) |
| `permissions` | `string[] \| undefined` | Permission claims |
| `entitlements` | `string[] \| undefined` | Entitlement claims |
| `featureFlags` | `string[] \| undefined` | Feature-flag claims |
| `impersonator` | `Impersonator \| undefined` | Present when the session is being impersonated |
| `claims` | `AuthKitClaims \| undefined` | The full decoded access-token claims |
| `signIn` | `(opts?: SignInOptions) => Promise<void>` | Begin a sign-in ceremony |
| `signOut` | `(opts?: { returnTo?: string }) => Promise<void>` | Clear the local session AND end the hosted AuthKit session |
| `switchToOrganization` | `(organizationId: string) => Promise<void>` | Force-refresh into a different organization |
| `getAccessToken` | `() => Promise<string \| null>` | The current short-lived access token, or `null` when signed out |
| Property | Type | Description |
| ---------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `user` | `User \| null` | The signed-in user, or `null` when signed out |
| `isLoading` | `boolean` | `true` until the first `getUser()` resolves |
| `sessionId` | `string \| undefined` | Session ID (signed in only) |
| `organizationId` | `string \| undefined` | Active organization (signed in only) |
| `role` / `roles` | `string` / `string[] \| undefined` | Role claim(s) |
| `permissions` | `string[] \| undefined` | Permission claims |
| `entitlements` | `string[] \| undefined` | Entitlement claims |
| `featureFlags` | `string[] \| undefined` | Feature-flag claims |
| `impersonator` | `Impersonator \| undefined` | Present when the session is being impersonated |
| `claims` | `AuthKitClaims \| undefined` | The full decoded access-token claims |
| `error` | `AuthErrorPayload \| null` | The last sign-in failure (denied/cancelled/exchange/open error), else `null`; cleared on the next successful sign-in |
| `signIn` | `(opts?: SignInOptions) => Promise<void>` | Begin a sign-in ceremony |
| `signOut` | `(opts?: { returnTo?: string }) => Promise<void>` | Clear the local session AND end the hosted AuthKit session |
| `switchToOrganization` | `(organizationId: string) => Promise<void>` | Force-refresh into a different organization |
| `getAccessToken` | `() => Promise<string \| null>` | The current short-lived access token, or `null` when signed out |

`signIn` accepts `SignInOptions`:

Expand All @@ -354,6 +355,23 @@ signIn({ organizationId: 'org_123' }); // scope the sign-in to an org
> [!NOTE]
> The main process is the single source of truth. Auth changes (sign-in, sign-out, org switch) **broadcast to every window**, so signing out in one window updates all of them.

> [!TIP]
> When a sign-in is denied, cancelled, or the code exchange fails, the callback completes with no auth change — and when the sign-in window can't load the auth server at all (e.g. offline), the attempt never reaches the callback. Rather than leaving the user silently signed out, in every one of these cases the main process broadcasts a safe `{ code, message }` error (never tokens) that surfaces as `useAuth().error`. (`signIn()` also still rejects on the open failure, so existing `try`/`catch` code keeps working.)
>
> ```tsx
> function SignInPanel() {
> const { error, signIn } = useAuth();
> return (
> <>
> <button onClick={() => signIn()}>Sign in</button>
> {error && <p role="alert">Sign-in failed: {error.message}</p>}
> </>
> );
> }
> ```
Comment thread
greptile-apps[bot] marked this conversation as resolved.
>
> `error` is cleared automatically on the next successful sign-in.

### Guards: `<SignedIn>` / `<SignedOut>`

Declarative guards, modeled on `@clerk/clerk-react`. Neither renders its children until the initial `getUser()` has resolved (no flash of the wrong state).
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"prepack": "npm run rebuild",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"lint": "oxlint .",
"lint": "oxlint --deny-warnings .",
"typecheck": "tsc --noEmit",
"test": "vitest --run",
"test:coverage": "vitest --run --coverage",
Expand Down Expand Up @@ -85,6 +85,6 @@
}
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.11.0"
}
}
12 changes: 12 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"include-component-in-tag": false,
"packages": {
".": {
"release-type": "node",
"changelog-path": "CHANGELOG.md",
"versioning": "default",
"bump-minor-pre-major": true
}
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type { IpcResult } from './shared/ipc.js';
// never redeclare them).
export { toRendererAuthPayload } from './shared/types.js';
export type {
AuthErrorPayload,
AuthKitClaims,
AuthKitElectronConfig,
AuthResult,
Expand Down
8 changes: 6 additions & 2 deletions src/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ export type { Ceremony, CreateCeremonyOptions, ShellLike } from './main/ceremony
export { parseCallback, registerProtocol, wireDeepLinks } from './main/deep-link.js';
export type { AppLike, ProcessLike, WireDeepLinksOptions } from './main/deep-link.js';

// IPC handlers + auth-change broadcast.
export { broadcastAuthChange, registerIpcHandlers } from './main/ipc-handlers.js';
// IPC handlers + auth-change / auth-error broadcast.
export {
broadcastAuthChange,
broadcastAuthError,
registerIpcHandlers,
} from './main/ipc-handlers.js';
export type {
BroadcastOptions,
BrowserWindowsLike,
Expand Down
146 changes: 140 additions & 6 deletions src/main/__tests__/create-auth-kit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,14 @@ describe('createAuthKit — wiring', () => {
expect(JSON.stringify(payload)).not.toContain('refresh_SECRET');
});

it('swallows a callback with an unknown state (no broadcast, no crash)', async () => {
it('a callback with an unknown state surfaces an auth-error, not an auth-change', async () => {
const app = makeApp();
const sendSpy = vi.fn();
const authenticateWithCode = vi.fn();
const kit = createAuthKit(config, {
storage: makeStorage(),
client: {
userManagement: { authenticateWithCode: vi.fn() },
userManagement: { authenticateWithCode },
} as unknown as WorkOS,
ipcMain: makeIpcMain(),
app,
Expand All @@ -299,10 +300,16 @@ describe('createAuthKit — wiring', () => {
listener({ preventDefault: () => {} }, 'workos-auth://callback?code=c&state=unknown');
await flushAsync();

expect(sendSpy).not.toHaveBeenCalled();
// State verification fails before the network, so the code is never
// exchanged and no signed-in payload is broadcast — but the renderer is told
// the attempt failed via a safe auth-error (no crash).
expect(authenticateWithCode).not.toHaveBeenCalled();
expect(sendSpy).toHaveBeenCalledTimes(1);
const [channel] = sendSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(channel).toBe(IPC_CHANNELS.authError);
});

it('ignores a callback URL carrying an error param (no throw, no broadcast)', async () => {
it('broadcasts an auth-error (not an auth-change) for a callback error param', async () => {
const app = makeApp();
const sendSpy = vi.fn();
const kit = createAuthKit(config, {
Expand All @@ -318,10 +325,137 @@ describe('createAuthKit — wiring', () => {
kit.registerProtocol();

const listener = app.listeners['open-url'] as (e: unknown, url: string) => void;
listener({ preventDefault: () => {} }, 'workos-auth://callback?error=access_denied');
listener(
{ preventDefault: () => {} },
'workos-auth://callback?error=access_denied&error_description=User+said+no',
);
await flushAsync();

expect(sendSpy).not.toHaveBeenCalled();
// No code to exchange → no auth-change broadcast, but the renderer learns of
// the failure via a safe auth-error payload (code + message, no tokens).
expect(sendSpy).toHaveBeenCalledTimes(1);
const [channel, payload] = sendSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(channel).toBe(IPC_CHANNELS.authError);
expect(payload).toEqual({ code: 'access_denied', message: 'User said no' });
});

it('broadcasts an auth-error when the code exchange fails', async () => {
const app = makeApp();
const ipc = makeIpcMain();
const sendSpy = vi.fn();

let openedUrl = '';
const ceremony: Ceremony = {
open: vi.fn(async (url: string) => {
openedUrl = url;
}),
endSession: vi.fn(async () => {}),
onCallback: () => () => {},
};

const client = createWorkOS({ clientId: 'client_test' }) as unknown as WorkOS;
// `authenticateWithCode` lives on the shared prototype, so spy with a *once*
// rejection and restore afterwards to avoid leaking into sibling specs.
const spy = vi
.spyOn(client.userManagement, 'authenticateWithCode')
.mockRejectedValueOnce(
Object.assign(new Error('code already used'), { name: 'OAuthException' }),
);

const kit = createAuthKit(config, {
storage: makeStorage(),
client,
ceremony,
ipcMain: ipc,
app,
browserWindow: {
getAllWindows: () => [{ webContents: { isDestroyed: () => false, send: sendSpy } }],
},
process: { argv: ['electron'], execPath: '/e' },
});
kit.registerProtocol();

await (ipc.handlers.get(IPC_CHANNELS.signIn) as (...a: unknown[]) => Promise<unknown>)(
{},
undefined,
);
const state = new URL(openedUrl).searchParams.get('state');

const listener = app.listeners['open-url'] as (e: unknown, url: string) => void;
listener(
{ preventDefault: () => {} },
`workos-auth://callback?code=auth_code&state=${encodeURIComponent(state as string)}`,
);
await flushAsync();

expect(sendSpy).toHaveBeenCalledTimes(1);
const [channel, payload] = sendSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(channel).toBe(IPC_CHANNELS.authError);
expect(payload).toEqual({ code: 'OAuthException', message: 'code already used' });

spy.mockRestore();
});

it('broadcasts an auth-error when the ceremony fails to open, and the IPC result is ok: false', async () => {
const app = makeApp();
const ipc = makeIpcMain();
const sendSpy = vi.fn();

// The window ceremony's open() rejects when win.loadURL() fails (e.g. the
// auth server is unreachable). The failure must BOTH reject the renderer's
// signIn() promise (ok: false) AND broadcast an auth-error, so
// useAuth().error is populated like every other failure mode.
const ceremony: Ceremony = {
open: vi.fn(async () => {
throw Object.assign(new Error('ERR_NAME_NOT_RESOLVED'), { name: 'SignInOpenError' });
}),
endSession: vi.fn(async () => {}),
onCallback: () => () => {},
};

const client = createWorkOS({ clientId: 'client_test' }) as unknown as WorkOS;
const kit = createAuthKit(config, {
storage: makeStorage(),
client,
ceremony,
ipcMain: ipc,
app,
browserWindow: {
getAllWindows: () => [{ webContents: { isDestroyed: () => false, send: sendSpy } }],
},
process: { argv: ['electron'], execPath: '/e' },
});
kit.registerProtocol();

const result = (await (
ipc.handlers.get(IPC_CHANNELS.signIn) as (...a: unknown[]) => Promise<unknown>
)({}, undefined)) as { ok: boolean; error?: { code: string; message: string } };

expect(result.ok).toBe(false);
expect(result.error).toEqual({ code: 'SignInOpenError', message: 'ERR_NAME_NOT_RESOLVED' });
expect(sendSpy).toHaveBeenCalledTimes(1);
const [channel, payload] = sendSpy.mock.calls[0] as [string, Record<string, unknown>];
expect(channel).toBe(IPC_CHANNELS.authError);
expect(payload).toEqual({ code: 'SignInOpenError', message: 'ERR_NAME_NOT_RESOLVED' });
});

it('registerProtocol is idempotent — repeated calls do not re-wire listeners', () => {
const app = makeApp();
const kit = createAuthKit(config, {
storage: makeStorage(),
client: { userManagement: {} } as unknown as WorkOS,
ipcMain: makeIpcMain(),
app,
process: { argv: ['electron'], execPath: '/e' },
});

kit.registerProtocol();
kit.registerProtocol();
kit.registerProtocol();

// setAsDefaultProtocolClient + the single deep-link wiring happen exactly
// once, so duplicate open-url/second-instance listeners can't leak.
expect(app.setAsDefaultProtocolClient).toHaveBeenCalledTimes(1);
});

it('cleanup removes handlers without throwing', () => {
Expand Down
Loading
Loading