Skip to content

Commit

Permalink
feat: added profile caching support
Browse files Browse the repository at this point in the history
  • Loading branch information
meza committed Jul 19, 2023
1 parent 841fc9b commit 233af4a
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 14 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,33 @@ Ideally you will only store the session id in the cookie and leave the session d
This is where the `SessionStrategy.Server` comes in. It assumes that you have a session id stored in a cookie
and that once the ID is set upon login, you will be able to retrieve - and update - the session data from a database.

### Caching the user profile

Unfortunately with the current implementation of Remix and the lack of a proper middleware, every loader runs in parallel,
and they can generate a lot of noise towards Auth0.

In this situation you might want to have some sort of a "user profile caching" in place. Redis, Dyanmo, in-memory, you name it.

The `auth0-remix-server` offers you a way to do this.

```ts
// src/auth.server.ts
import {Auth0RemixServer} from 'auth0-remix-server';
import {getSessionStorage} from './sessionStorage.server';
import {UserProfile} from "./Auth0RemixTypes"; // this is where your session storage is configured

export const authenticator = new Auth0RemixServer({
...,
profileCacheGet: async (accessToken: string): Promise<UserProfile> => {
//return a UserProfile or throw an error if not found
},
profileCacheSet: async (accessToken: string, profile: UserProfile, expiresAt: number): Promise<void> => {
// use the expiresAt as a TTL value if your cache storage supports such a thing.
// Otherwise use it to invalidate the record yourself.
},
...,
});
```

## Securely decoding tokens

Expand Down
5 changes: 5 additions & 0 deletions src/Auth0RemixTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,18 @@ export interface SessionStore {
strategy?: SessionStrategy;
}

export type CacheGetFunction = (accessToken: string) => Promise<UserProfile>;
export type CacheSetFunction = (accessToken: string, profile: UserProfile, expiresAt: number) => Promise<void>;

export interface Auth0RemixOptions {
callbackURL: string;
failedLoginRedirect: string;
refreshTokenRotationEnabled?: boolean;
clientDetails: SetOptional<ClientCredentials, 'audience'> & { domain: string };
session: SetOptional<SessionStore, 'key'>;
credentialsCallback?: Auth0CredentialsCallback;
profileCacheGet?: CacheGetFunction;
profileCacheSet?: CacheSetFunction;
}

interface BaseAuthorizeOptions {
Expand Down
62 changes: 61 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,11 +734,71 @@ describe('Auth0 Remix Server', () => {
describe('when the access token is valid', () => {
beforeEach(() => {
vi.mocked(getCredentials).mockResolvedValueOnce({
accessToken: 'test-access-token'
accessToken: 'test-access-token',
expiresAt: 66669420
} as never);
vi.mocked(jose.jwtVerify).mockResolvedValue({} as never);
});

describe('and there is a user cache in place', () => {
beforeEach<LocalTestContext>((context) => {
context.authOptions.profileCacheGet = vi.fn();
});

it<LocalTestContext>('returns the cached result', async ({ authOptions }) => {
vi.mocked(authOptions.profileCacheGet!).mockResolvedValue({
email: '[email protected]'
} as never);

const request = new Request('https://it-doesnt-matter.com');

const authorizer = new Auth0RemixServer(authOptions);
const actual = await authorizer.getUser(request, {});

expect(actual.email).toEqual('[email protected]');
expect(authOptions.profileCacheGet).toHaveBeenCalledWith('test-access-token');
});

describe('when the cache misses', () => {
it<LocalTestContext>('calls out to auth0', async ({ authOptions }) => {
authOptions.profileCacheSet = vi.fn();
vi.mocked(authOptions.profileCacheGet!).mockRejectedValueOnce(new Error('Cache miss'));

authOptions.session = {
store: 'sessionStore',
key: 'sessionKey',
strategy: SessionStrategy.Browser
} as never;

const user = {
name: 'test-user',
first_name: 'test-first-name'
};

vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve(user)
} as never);

const request = new Request('https://it-doesnt-matter.com');

const authorizer = new Auth0RemixServer(authOptions);
const actual = await authorizer.getUser(request, {});

expect(actual.firstName).toEqual('test-first-name');
expect(actual.name).toEqual('test-user');

const fetchParams = vi.mocked(fetch).mock.calls[0];
expect(fetchParams[0]).toEqual('https://test.domain.com/userinfo');

expect(authOptions.profileCacheSet).toHaveBeenCalledWith('test-access-token', {
firstName: 'test-first-name',
name: 'test-user'
}, 66669420);
});
});
});

describe('and the user profile fetch succeeds', () => {
it<LocalTestContext>('returns the user', async ({ authOptions }) => {
authOptions.session = {
Expand Down
44 changes: 31 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
Auth0CredentialsCallback,
Auth0RemixOptions,
Auth0UserProfile,
AuthorizeOptions,
AuthorizeOptions, CacheGetFunction, CacheSetFunction,
ClientCredentials,
HandleCallbackOptions,
SessionStore,
Expand Down Expand Up @@ -38,6 +38,10 @@ interface Auth0Urls {

const noop = () => { /* empty */ };

const defaultCacheGet = () => {
throw new Error();
};

export class Auth0RemixServer {
private readonly domain: string;
private readonly refreshTokenRotationEnabled: boolean;
Expand All @@ -48,7 +52,10 @@ export class Auth0RemixServer {
private readonly session: SessionStore;
private readonly auth0Urls: Auth0Urls;
private readonly credentialsCallback: Auth0CredentialsCallback;
private readonly cacheGet: CacheGetFunction;
private readonly cacheSet: CacheSetFunction;

// eslint-disable-next-line complexity -- it's only simple parameter initialization
constructor(auth0RemixOptions: Auth0RemixOptions) {
this.domain = ensureDomain(auth0RemixOptions.clientDetails.domain);

Expand Down Expand Up @@ -84,6 +91,9 @@ export class Auth0RemixServer {
openIDConfigurationURL: `${this.domain}/.well-known/openid-configuration`
};

this.cacheGet = auth0RemixOptions.profileCacheGet || defaultCacheGet;
this.cacheSet = auth0RemixOptions.profileCacheSet || noop as unknown as CacheSetFunction;

// eslint-disable-next-line @typescript-eslint/no-empty-function
this.credentialsCallback = auth0RemixOptions.credentialsCallback || noop;

Expand Down Expand Up @@ -329,22 +339,30 @@ export class Auth0RemixServer {
}

private async getUserProfile(credentials: UserCredentials): Promise<UserProfile> {
const response = await fetch(this.auth0Urls.userProfileUrl, {
headers: {
Authorization: `Bearer ${credentials.accessToken}`
try {
return await this.cacheGet(credentials.accessToken);
} catch (e) {
const response = await fetch(this.auth0Urls.userProfileUrl, {
headers: {
Authorization: `Bearer ${credentials.accessToken}`
}
});

const searchParams = new URLSearchParams();

if (!response.ok) {
console.error('Failed to get user profile from Auth0');
searchParams.set('error', await this.getErrorReason(response));
throw redirect(this.failedLoginRedirect.concat('?', searchParams.toString()));
}
});

const searchParams = new URLSearchParams();
const data = (await response.json()) as Auth0UserProfile;
const profile = await transformUserData(data);

if (!response.ok) {
console.error('Failed to get user profile from Auth0');
searchParams.set('error', await this.getErrorReason(response));
throw redirect(this.failedLoginRedirect.concat('?', searchParams.toString()));
}
await this.cacheSet(credentials.accessToken, profile, credentials.expiresAt);

const data = (await response.json()) as Auth0UserProfile;
return transformUserData(data);
return profile;
}
}

private async getErrorReason(response: Response): Promise<string> {
Expand Down

0 comments on commit 233af4a

Please sign in to comment.