Skip to content

Commit

Permalink
Merge pull request #1038 from lens-protocol/cesare/T-22637-js-seamles…
Browse files Browse the repository at this point in the history
…s-rollover-token-refresh

feat: seamless rollover token refresh
  • Loading branch information
cesarenaldi authored Dec 19, 2024
2 parents 8c5296a + 84a879b commit 410656c
Show file tree
Hide file tree
Showing 5 changed files with 488 additions and 64 deletions.
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@lens-network/sdk": "canary",
"@lens-protocol/metadata": "next",
"ethers": "^6.13.4",
"msw": "^2.7.0",
"tsup": "^8.3.5",
"typescript": "^5.6.3",
"viem": "^2.21.53",
Expand Down
110 changes: 102 additions & 8 deletions packages/client/src/clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { testnet } from '@lens-protocol/env';
import { url, assertErr, assertOk, evmAddress, signatureFrom } from '@lens-protocol/types';
import { HttpResponse, graphql, passthrough } from 'msw';
import { setupServer } from 'msw/node';

import { privateKeyToAccount } from 'viem/accounts';
import { describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { HealthQuery, Role } from '@lens-protocol/graphql';
import { CurrentSessionQuery, HealthQuery, RefreshMutation, Role } from '@lens-protocol/graphql';
import { createGraphQLErrorObject, createPublicClient } from '../testing-utils';
import { currentSession } from './actions';
import { PublicClient } from './clients';
import { UnexpectedError } from './errors';
import { GraphQLErrorCode, UnauthenticatedError, UnexpectedError } from './errors';
import { delay } from './utils';

const signer = privateKeyToAccount(import.meta.env.PRIVATE_KEY);
const owner = evmAddress(signer.address);
const account = evmAddress(import.meta.env.TEST_ACCOUNT);
const app = evmAddress(import.meta.env.TEST_APP);

describe(`Given an instance of the ${PublicClient.name}`, () => {
const client = PublicClient.create({
environment: testnet,
origin: 'http://example.com',
});
const client = createPublicClient();

describe('When authenticating via the low-level methods', () => {
it('Then it should authenticate and stay authenticated', async () => {
Expand Down Expand Up @@ -104,4 +104,98 @@ describe(`Given an instance of the ${PublicClient.name}`, () => {
expect(result.error).toBeInstanceOf(UnexpectedError);
});
});

describe('And a SessionClient created from it', () => {
describe('When a request fails with UNAUTHENTICATED extension code', () => {
const server = setupServer(
graphql.query(
CurrentSessionQuery,
(_) =>
HttpResponse.json({
errors: [createGraphQLErrorObject(GraphQLErrorCode.UNAUTHENTICATED)],
}),
{
once: true,
},
),
// Pass through all other operations
graphql.operation(() => passthrough()),
);

beforeAll(() => {
server.listen();
});

afterAll(() => {
server.close();
});

it(
'Then it should silently refresh credentials and retry the request',
{ timeout: 5000 },
async () => {
const authenticated = await client.login({
accountOwner: {
account,
owner,
app,
},
signMessage: (message) => signer.signMessage({ message }),
});
assertOk(authenticated);

// wait 1 second to make sure the new tokens have 'expiry at' different from the previous ones
await delay(1000);

const result = await currentSession(authenticated.value);

assertOk(result);
},
);
});

describe('When a token refresh fails', () => {
const server = setupServer(
graphql.query(CurrentSessionQuery, (_) =>
HttpResponse.json({
errors: [createGraphQLErrorObject(GraphQLErrorCode.UNAUTHENTICATED)],
}),
),
graphql.mutation(RefreshMutation, (_) =>
HttpResponse.json({
errors: [createGraphQLErrorObject(GraphQLErrorCode.BAD_USER_INPUT)],
}),
),
// Pass through all other operations
graphql.operation(() => passthrough()),
);

beforeAll(() => {
server.listen();
});

afterAll(() => {
server.close();
});
it(
`Then it should return a '${UnauthenticatedError.name}' to the original request caller`,
{ timeout: 5000 },
async () => {
const authenticated = await client.login({
accountOwner: {
account,
owner,
app,
},
signMessage: (message) => signer.signMessage({ message }),
});
assertOk(authenticated);

const result = await currentSession(authenticated.value);
assertErr(result);
expect(result.error).toBeInstanceOf(UnauthenticatedError);
},
);
});
});
});
106 changes: 61 additions & 45 deletions packages/client/src/clients.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthenticateMutation, ChallengeMutation } from '@lens-protocol/graphql';
import { AuthenticateMutation, ChallengeMutation, RefreshMutation } from '@lens-protocol/graphql';
import type {
AuthenticationChallenge,
ChallengeRequest,
Expand All @@ -12,24 +12,23 @@ import {
type TxHash,
errAsync,
invariant,
never,
okAsync,
signatureFrom,
} from '@lens-protocol/types';
import {
type AnyVariables,
type Operation,
type Exchange,
type OperationResult,
type OperationResultSource,
type TypedDocumentNode,
type Client as UrqlClient,
createClient,
fetchExchange,
mapExchange,
} from '@urql/core';
import { type Logger, getLogger } from 'loglevel';

import type { SwitchAccountRequest } from '@lens-protocol/graphql';
import { type AuthConfig, authExchange } from '@urql/exchange-auth';
import { type AuthenticatedUser, authenticatedUser } from './AuthenticatedUser';
import { switchAccount, transactionStatus } from './actions';
import type { ClientConfig } from './config';
Expand Down Expand Up @@ -75,25 +74,12 @@ abstract class AbstractClient<TContext extends Context, TError> {

this.urql = createClient({
url: context.environment.backend,
exchanges: [
mapExchange({
onOperation: async (operation: Operation) => {
this.logger.debug(
'Operation:',
// biome-ignore lint/suspicious/noExplicitAny: This is a debug log
(operation.query.definitions[0] as any)?.name?.value ?? 'Unknown',
);
return {
...operation,
context: {
...operation.context,
fetchOptions: await this.fetchOptions(),
},
};
},
}),
fetchExchange,
],
fetchOptions: {
headers: {
...(this.context.origin ? { Origin: this.context.origin } : {}),
},
},
exchanges: this.exchanges(),
});
}

Expand All @@ -119,12 +105,8 @@ abstract class AbstractClient<TContext extends Context, TError> {
return this.resultFrom(this.urql.mutation(document, variables)).map(takeValue);
}

protected fetchOptions(): RequestInit | Promise<RequestInit> {
return {
headers: {
...(this.context.origin ? { Origin: this.context.origin } : {}),
},
};
protected exchanges(): Exchange[] {
return [fetchExchange];
}

protected resultFrom<TData, TVariables extends AnyVariables>(
Expand Down Expand Up @@ -308,13 +290,15 @@ class SessionClient<TContext extends Context = Context> extends AbstractClient<

/**
* The current authentication tokens if available.
*
* @internal
*/
getCredentials(): ResultAsync<Credentials | null, UnexpectedError> {
return ResultAsync.fromPromise(this.credentials.get(), (err) => UnexpectedError.from(err));
}

/**
* @internal
* The AuthenticatedUser associated with the current session.
*/
getAuthenticatedUser(): ResultAsync<AuthenticatedUser, UnexpectedError> {
return this.getCredentials().andThen((credentials) => {
Expand Down Expand Up @@ -362,14 +346,14 @@ class SessionClient<TContext extends Context = Context> extends AbstractClient<
> {
return switchAccount(this, request)
.andThen((result) => {
if (result.__typename === 'ForbiddenError') {
return AuthenticationError.from(result.reason).asResultAsync();
if (result.__typename === 'AuthenticationTokens') {
return okAsync(result);
}
return okAsync(result);
return AuthenticationError.from(result.reason).asResultAsync();
})
.map(async (tokens) => {
await this.credentials.set(tokens);
return this;
return new SessionClient(this.parent);
});
}

Expand Down Expand Up @@ -457,18 +441,50 @@ class SessionClient<TContext extends Context = Context> extends AbstractClient<
throw TransactionIndexingError.from(`Timeout waiting for transaction ${txHash}`);
}

protected override async fetchOptions(): Promise<RequestInit> {
const base = await super.fetchOptions();
const credentials = (await this.credentials.get()) ?? never('No credentials found');
protected override exchanges(): Exchange[] {
return [
authExchange(async (utils): Promise<AuthConfig> => {
let credentials = await this.getCredentials().unwrapOr(null);

return {
...base,
headers: {
...base.headers,
'x-access-token': credentials.accessToken,
// Authorization: `Bearer ${this.tokens.accessToken}`,
},
};
return {
addAuthToOperation: (operation) => {
if (!credentials) return operation;

return utils.appendHeaders(operation, {
Authorization: `Bearer ${credentials.accessToken}`,
});
},

didAuthError: (error) => hasExtensionCode(error, GraphQLErrorCode.UNAUTHENTICATED),

refreshAuth: async () => {
const result = await utils.mutate(RefreshMutation, {
request: {
refreshToken: credentials?.refreshToken,
},
});

if (result.data) {
switch (result.data.value.__typename) {
case 'AuthenticationTokens':
credentials = result.data?.value;
await this.credentials.set(result.data?.value);
break;

case 'ForbiddenError':
throw AuthenticationError.from(result.data.value.reason);

default:
throw AuthenticationError.from(
`Unsupported refresh token response ${result.data.value}`,
);
}
}
},
};
}),
fetchExchange,
];
}

private handleAuthentication<
Expand Down
35 changes: 28 additions & 7 deletions packages/client/testing-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ import { chains } from '@lens-network/sdk/viem';
import { evmAddress } from '@lens-protocol/types';
import { http, type Account, type Transport, type WalletClient, createWalletClient } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { PublicClient, testnet } from './src';
import { GraphQLErrorCode, PublicClient, testnet } from './src';

const pk = privateKeyToAccount(import.meta.env.PRIVATE_KEY);
const account = evmAddress(import.meta.env.TEST_ACCOUNT);
const app = evmAddress(import.meta.env.TEST_APP);

export const signer = evmAddress(pk.address);

export function loginAsAccountOwner() {
const client = PublicClient.create({
export function createPublicClient() {
return PublicClient.create({
environment: testnet,
origin: 'http://example.com',
});
}

export function loginAsAccountOwner() {
const client = createPublicClient();

return client.login({
accountOwner: {
Expand All @@ -27,10 +31,7 @@ export function loginAsAccountOwner() {
}

export function loginAsOnboardingUser() {
const client = PublicClient.create({
environment: testnet,
origin: 'http://example.com',
});
const client = createPublicClient();

return client.login({
onboardingUser: {
Expand All @@ -48,3 +49,23 @@ export function signerWallet(): WalletClient<Transport, chains.LensNetworkChain,
transport: http(),
});
}

const messages: Record<GraphQLErrorCode, string> = {
[GraphQLErrorCode.UNAUTHENTICATED]:
"Unauthenticated - Authentication is required to access '<operation>'",
[GraphQLErrorCode.FORBIDDEN]: "Forbidden - You are not authorized to access '<operation>'",
[GraphQLErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error - Please try again later',
[GraphQLErrorCode.BAD_USER_INPUT]: 'Bad user input - Please check the input and try again',
[GraphQLErrorCode.BAD_REQUEST]: 'Bad request - Please check the request and try again',
};

export function createGraphQLErrorObject(code: GraphQLErrorCode) {
return {
message: messages[code],
locations: [],
path: [],
extensions: {
code: code,
},
};
}
Loading

0 comments on commit 410656c

Please sign in to comment.