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

feat: seamless rollover token refresh #1038

Merged
merged 1 commit into from
Dec 19, 2024
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
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
Loading