Skip to content

Commit

Permalink
Add client side auth (#147)
Browse files Browse the repository at this point in the history
* Added tests and made a start to auth.ts

* Add tests for cookie and callback route

* Tests for session and actions

* Add jsdom tests for tsx files

* Add new workflow

* Clean up jest config file

* Didn't mean to add this

* Add jest config and setup scripts to ts exclude

* Impersonation shouldn't be a client component for now

* 100% test coverage

* Add debug flag

* Add another test and change coverage engine to have local and github show the same results

* Should actually add the test

* Restructured components, added provider hooks

* Update readme and make sure refreshAuth works as expected

* Tests

* Fix coverage

* Remove oauthTokens from provider

* Exclude all test files from build
  • Loading branch information
Paul Asjes authored Jan 13, 2025
1 parent e695170 commit 3714fb8
Show file tree
Hide file tree
Showing 22 changed files with 687 additions and 233 deletions.
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

0 comments on commit 3714fb8

Please sign in to comment.