Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add client side auth #147

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
94 changes: 81 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ Custom redirect URIs will be used over a redirect URI configured in the environm

### Wrap your app in `AuthKitProvider`

Use `AuthKitProvider` to wrap your app layout, which adds some protections for auth edge cases.
Use `AuthKitProvider` to wrap your app layout, which provides client side auth methods adds protections for auth edge cases.

```jsx
import { AuthKitProvider } from '@workos-inc/authkit-nextjs';
import { AuthKitProvider } from '@workos-inc/authkit-nextjs/components';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
Expand All @@ -159,9 +159,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
```

### Get the current user
### Get the current user in a server component

For pages where you want to display a signed-in and signed-out view, use `withAuth` to retrieve the user profile from WorkOS.
For pages where you want to display a signed-in and signed-out view, use `withAuth` to retrieve the user session from WorkOS.

```jsx
import Link from 'next/link';
Expand Down Expand Up @@ -200,16 +200,84 @@ export default async function HomePage() {
}
```

### Get the current user in a client component

For client components, use the `useAuth` hook to get the current user session.

```jsx
// Note the updated import path
import { useAuth } from '@workos-inc/authkit-nextjs/components';

export default function MyComponent() {
// Retrieves the user from the session or returns `null` if no user is signed in
const { user, loading } = useAuth();

if (loading) {
return <div>Loading...</div>;
}

return <div>{user?.firstName}</div>;
}
```

### Requiring auth

For pages where a signed-in user is mandatory, you can use the `ensureSignedIn` option:

```jsx
// Server component
const { user } = await withAuth({ ensureSignedIn: true });

// Client component
const { user, loading } = useAuth({ ensureSignedIn: true });
```

Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to access the page without being authenticated.

### Refreshing the session

Use the `refreshSession` method in a server action or route handler to fetch the latest session details, including any changes to the user's roles or permissions.

The `organizationId` parameter can be passed to `refreshSession` in order to switch the session to a different organization. If the current session is not authorized for the next organization, an appropriate [authentication error](https://workos.com/docs/reference/user-management/authentication-errors) will be returned.

In client components, you can refresh the session with the `refreshAuth` hook.

```tsx
'use client';

import { useAuth } from '@workos-inc/authkit-nextjs/components';
import React, { useEffect } from 'react';

export function SwitchOrganizationButton() {
const { user, organizationId, loading, refreshAuth } = useAuth();

useEffect(() => {
// This will log out the new organizationId after refreshing the session
console.log('organizationId', organizationId);
}, [organizationId]);

if (loading) {
return <div>Loading...</div>;
}

const handleRefreshSession = async () => {
const result = await refreshAuth({
// Provide the organizationId to switch to
organizationId: 'org_123',
});
if (result?.error) {
console.log('Error refreshing session:', result.error);
}
};

if (user) {
return <button onClick={handleRefreshSession}>Refresh session</button>;
} else {
return <div>Not signed in</div>;
}
}
```

### Middleware auth

The default behavior of this library is to request authentication via the `withAuth` method on a per-page basis. There are some use cases where you don't want to call `withAuth` (e.g. you don't need user data for your page) or if you'd prefer a "secure by default" approach where every route defined in your middleware matcher is protected unless specified otherwise. In those cases you can opt-in to use middleware auth instead:
Expand Down Expand Up @@ -267,13 +335,15 @@ Render the `Impersonation` component in your app so that it is clear when someon
The component will display a frame with some information about the impersonated user, as well as a button to stop impersonating.

```jsx
import { Impersonation } from '@workos-inc/authkit-nextjs';
import { Impersonation, AuthKitProvider } from '@workos-inc/authkit-nextjs/components';

export default function App() {
return (
<div>
<Impersonation />
{/* Your app content */}
<AuthKitProvider>
<Impersonation />
{/* Your app content */}
</AuthKitProvider>
</div>
);
}
Expand Down Expand Up @@ -303,12 +373,6 @@ export default async function HomePage() {
}
```

### Refreshing the session

Use the `refreshSession` method in a server action or route handler to fetch the latest session details, including any changes to the user's roles or permissions.

The `organizationId` parameter can be passed to `refreshSession` in order to switch the session to a different organization. If the current session is not authorized for the next organization, an appropriate [authentication error](https://workos.com/docs/reference/user-management/authentication-errors) will be returned.

### Sign up paths

The `signUpPaths` option can be passed to `authkitMiddleware` to specify paths that should use the 'sign-up' screen hint when redirecting to AuthKit. This is useful for cases where you want a path that mandates authentication to be treated as a sign up page.
Expand Down Expand Up @@ -336,3 +400,7 @@ export default authkitMiddleware({ debug: true });
#### NEXT_REDIRECT error when using try/catch blocks

Wrapping a `withAuth({ ensureSignedIn: true })` call in a try/catch block will cause a `NEXT_REDIRECT` error. This is because `withAuth` will attempt to redirect the user to AuthKit if no session is detected and redirects in Next must be [called outside a try/catch](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting).

#### Module build failed: UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins (Unhandled scheme).

You may encounter this error if you attempt to import server side code from authkit-nextjs into a client component. Likely you are using `withAuth` in a client component instead of the `useAuth` hook. Either move the code to a server component or use the `useAuth` hook.
49 changes: 48 additions & 1 deletion __tests__/actions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
import { checkSessionAction, handleSignOutAction } from '../src/actions.js';
import {
checkSessionAction,
handleSignOutAction,
getOrganizationAction,
getAuthAction,
refreshAuthAction,
} from '../src/actions.js';
import { signOut } from '../src/auth.js';
import { workos } from '../src/workos.js';
import { withAuth, refreshSession } from '../src/session.js';

jest.mock('../src/auth.js', () => ({
signOut: jest.fn().mockResolvedValue(true),
}));

jest.mock('../src/workos.js', () => ({
workos: {
organizations: {
getOrganization: jest.fn().mockResolvedValue({ id: 'org_123', name: 'Test Org' }),
},
},
}));

jest.mock('../src/session.js', () => ({
withAuth: jest.fn().mockResolvedValue({ user: 'testUser' }),
refreshSession: jest.fn().mockResolvedValue({ session: 'newSession' }),
}));

describe('actions', () => {
describe('checkSessionAction', () => {
it('should return true for authenticated users', async () => {
Expand All @@ -19,4 +40,30 @@ describe('actions', () => {
expect(signOut).toHaveBeenCalled();
});
});

describe('getOrganizationAction', () => {
it('should return organization details', async () => {
const organizationId = 'org_123';
const result = await getOrganizationAction(organizationId);
expect(workos.organizations.getOrganization).toHaveBeenCalledWith(organizationId);
expect(result).toEqual({ id: 'org_123', name: 'Test Org' });
});
});

describe('getAuthAction', () => {
it('should return auth details', async () => {
const result = await getAuthAction();
expect(withAuth).toHaveBeenCalled();
expect(result).toEqual({ user: 'testUser' });
});
});

describe('refreshAuthAction', () => {
it('should refresh session', async () => {
const params = { ensureSignedIn: true, organizationId: 'org_123' };
const result = await refreshAuthAction(params);
expect(refreshSession).toHaveBeenCalledWith(params);
expect(result).toEqual({ session: 'newSession' });
});
});
});
62 changes: 1 addition & 61 deletions __tests__/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,12 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';

import { getSignInUrl, getSignUpUrl, signOut } from '../src/auth.js';
import { workos } from '../src/workos.js';

// These are mocked in jest.setup.ts
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { sealData } from 'iron-session';
import { generateTestToken } from './test-helpers.js';
import { User } from '@workos-inc/node';

// jest.mock('../src/workos', () => ({
// workos: {
// userManagement: {
// getLogoutUrl: jest.fn().mockReturnValue('https://example.com/logout'),
// getJwksUrl: jest.fn().mockReturnValue('https://api.workos.com/sso/jwks/client_1234567890'),
// },
// },
// }));

describe('auth.ts', () => {
const mockSession = {
accessToken: 'access-token',
oauthTokens: undefined,
sessionId: 'session_123',
organizationId: 'org_123',
role: 'member',
permissions: ['posts:create', 'posts:delete'],
entitlements: ['audit-logs'],
impersonator: undefined,
user: {
object: 'user',
id: 'user_123',
email: '[email protected]',
emailVerified: true,
profilePictureUrl: null,
firstName: null,
lastName: null,
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
} as User,
};

beforeEach(async () => {
// Clear all mocks between tests
jest.clearAllMocks();
Expand Down Expand Up @@ -80,32 +45,7 @@ describe('auth.ts', () => {
});

describe('signOut', () => {
it('should delete the cookie and redirect to the logout url if there is a session', async () => {
const nextCookies = await cookies();
const nextHeaders = await headers();

mockSession.accessToken = await generateTestToken();

nextHeaders.set('x-workos-middleware', 'true');
nextHeaders.set(
'x-workos-session',
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
);

nextCookies.set('wos-session', 'foo');

jest.spyOn(workos.userManagement, 'getLogoutUrl').mockReturnValue('https://example.com/logout');

await signOut();

const sessionCookie = nextCookies.get('wos-session');

expect(sessionCookie).toBeUndefined();
expect(redirect).toHaveBeenCalledTimes(1);
expect(redirect).toHaveBeenCalledWith('https://example.com/logout');
});

it('should delete the cookie and redirect to the root path if there is no session', async () => {
it('should delete the cookie and redirect', async () => {
const nextCookies = await cookies();
const nextHeaders = await headers();

Expand Down
Loading
Loading