Skip to content

Commit

Permalink
feat(node,next,express): get access token with resource (#420)
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie authored Oct 24, 2022
1 parent c79c375 commit 6fb22ea
Show file tree
Hide file tree
Showing 16 changed files with 93 additions and 85 deletions.
13 changes: 13 additions & 0 deletions packages/express-sample/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ app.get(
}
);

app.get(
'/fetch-access-token',
withLogto({
...config,
// Fetch access token from remote, this may slowdown the response time,
// you can also add "resource" if needed.
getAccessToken: true,
}),
(request, response) => {
response.json(request.user);
}
);

app.get('/protected', withLogto(config), requireAuth, (request, response) => {
response.end('protected resource');
});
Expand Down
6 changes: 5 additions & 1 deletion packages/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ export const withLogto =
(config: LogtoExpressConfig): Middleware =>
async (request: IncomingMessage, response: Response, next: NextFunction) => {
const client = createNodeClient(request, response, config);
const user = await client.getContext(config.getAccessToken, config.fetchUserInfo);
const user = await client.getContext({
getAccessToken: config.getAccessToken,
resource: config.resource,
fetchUserInfo: config.fetchUserInfo,
});
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(request, 'user', { enumerable: true, get: () => user });
next();
Expand Down
6 changes: 2 additions & 4 deletions packages/express/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LogtoConfig } from '@logto/node';
import { GetContextParameters, LogtoConfig } from '@logto/node';

declare module 'http' {
// Honor module definition
Expand All @@ -10,6 +10,4 @@ declare module 'http' {

export type LogtoExpressConfig = LogtoConfig & {
baseUrl: string;
getAccessToken?: boolean;
fetchUserInfo?: boolean;
};
} & GetContextParameters;
27 changes: 18 additions & 9 deletions packages/next-sample/pages/api/protected-resource.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { logtoClient } from '../../libraries/logto';

export default logtoClient.withLogtoApiRoute((request, response) => {
if (!request.user.isAuthenticated) {
response.status(401).json({ message: 'Unauthorized' });
export default logtoClient.withLogtoApiRoute(
(request, response) => {
if (!request.user.isAuthenticated) {
response.status(401).json({ message: 'Unauthorized' });

return;
}
return;
}

// Get an access token with the target resource
console.log(request.user.accessToken);

response.json({
data: 'this_is_protected_resource',
});
});
response.json({
data: 'this_is_protected_resource',
});
},
{
// (Optional) getAccessToken: true,
// (Optional) resource: 'https://the-resource.domain/'
}
);
33 changes: 14 additions & 19 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { IncomingMessage } from 'http';

import NodeClient from '@logto/node';
import NodeClient, { GetContextParameters } from '@logto/node';
import { withIronSessionApiRoute, withIronSessionSsr } from 'iron-session/next';
import { GetServerSidePropsContext, GetServerSidePropsResult, NextApiHandler } from 'next';

import NextStorage from './storage';
import { LogtoNextConfig, WithLogtoConfig } from './types';
import { LogtoNextConfig } from './types';

export { ReservedScope, UserScope } from '@logto/node';

Expand Down Expand Up @@ -53,13 +53,13 @@ export default class LogtoClient {
}
}, this.ironSessionConfigs);

handleUser = (config?: WithLogtoConfig) =>
handleUser = (configs?: GetContextParameters) =>
this.withLogtoApiRoute((request, response) => {
response.json(request.user);
}, config);
}, configs);

handleAuthRoutes =
(configs?: WithLogtoConfig): NextApiHandler =>
(configs?: GetContextParameters): NextApiHandler =>
(request, response) => {
const { action } = request.query;

Expand All @@ -82,13 +82,12 @@ export default class LogtoClient {
response.status(404).end();
};

withLogtoApiRoute = (handler: NextApiHandler, config: WithLogtoConfig = {}): NextApiHandler =>
withLogtoApiRoute = (
handler: NextApiHandler,
config: GetContextParameters = {}
): NextApiHandler =>
withIronSessionApiRoute(async (request, response) => {
const user = await this.getLogtoUserFromRequest(
request,
config.getAccessToken,
config.fetchUserInfo
);
const user = await this.getLogtoUserFromRequest(request, config);

// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(request, 'user', { enumerable: true, get: () => user });
Expand All @@ -100,10 +99,10 @@ export default class LogtoClient {
handler: (
context: GetServerSidePropsContext
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>,
config: WithLogtoConfig = {}
configs: GetContextParameters = {}
) =>
withIronSessionSsr(async (context) => {
const user = await this.getLogtoUserFromRequest(context.req, config.getAccessToken);
const user = await this.getLogtoUserFromRequest(context.req, configs);
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(context.req, 'user', { enumerable: true, get: () => user });

Expand Down Expand Up @@ -132,13 +131,9 @@ export default class LogtoClient {
};
}

private async getLogtoUserFromRequest(
request: IncomingMessage,
getAccessToken?: boolean,
fetchUserInfo?: boolean
) {
private async getLogtoUserFromRequest(request: IncomingMessage, configs: GetContextParameters) {
const nodeClient = this.createNodeClient(request);

return nodeClient.getContext(getAccessToken, fetchUserInfo);
return nodeClient.getContext(configs);
}
}
10 changes: 0 additions & 10 deletions packages/next/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,3 @@ export type LogtoNextConfig = LogtoConfig & {
cookieSecure: boolean;
baseUrl: string;
};

/**
* @getAccessToken: if set to true, will try to get an access token and attach to req.user,
* if unable to grant an access token, will set req.user.isAuthenticated to false,
* this can make sure the refresh token is not revoked and still valid, so is considered more secure.
*/
export type WithLogtoConfig = {
getAccessToken?: boolean;
fetchUserInfo?: boolean;
};
14 changes: 11 additions & 3 deletions packages/node/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,21 @@ describe('LogtoClient', () => {
it('should set isAuthenticated to false when "getAccessToken" is enabled and is unable to getAccessToken', async () => {
getAccessToken.mockRejectedValueOnce(new Error('Unauthorized'));
const client = new LogtoClient({ endpoint, appId }, { navigate, storage });
await expect(client.getContext(true)).resolves.toEqual({ isAuthenticated: false });
expect(getAccessToken).toHaveBeenCalled();
await expect(
client.getContext({ getAccessToken: true, resource: 'resource' })
).resolves.toEqual({
isAuthenticated: false,
});
expect(getAccessToken).toHaveBeenCalledWith('resource');
});

it('should fetch remote user info and return when "fetchUserInfo" is enabled', async () => {
const client = new LogtoClient({ endpoint, appId }, { navigate, storage });
await expect(client.getContext(false, true)).resolves.toMatchObject({
await expect(
client.getContext({
fetchUserInfo: true,
})
).resolves.toMatchObject({
claims: { sub: 'sub' },
userInfo: { name: 'name' },
});
Expand Down
12 changes: 8 additions & 4 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import BaseClient, { LogtoConfig, createRequester, ClientAdapter } from '@logto/
import { conditional } from '@silverhand/essentials';
import fetch from 'node-fetch';

import { LogtoContext } from './types';
import { GetContextParameters, LogtoContext } from './types';
import { generateCodeChallenge, generateCodeVerifier, generateState } from './utils/generators';

export type { LogtoContext } from './types';
export type { LogtoContext, GetContextParameters } from './types';

export type {
IdTokenClaims,
Expand Down Expand Up @@ -57,7 +57,11 @@ export default class LogtoClient extends BaseClient {
}

/* eslint-disable complexity */
getContext = async (getAccessToken = false, fetchUserInfo = false): Promise<LogtoContext> => {
getContext = async ({
getAccessToken,
resource,
fetchUserInfo,
}: GetContextParameters = {}): Promise<LogtoContext> => {
const isAuthenticated = await this.isAuthenticated();

if (!isAuthenticated) {
Expand All @@ -77,7 +81,7 @@ export default class LogtoClient extends BaseClient {
}

try {
const accessToken = await this.getAccessToken();
const accessToken = await this.getAccessToken(resource);

return {
isAuthenticated,
Expand Down
6 changes: 6 additions & 0 deletions packages/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ export type LogtoContext = {
accessToken?: string;
userInfo?: UserInfoResponse;
};

export type GetContextParameters = {
fetchUserInfo?: boolean;
getAccessToken?: boolean;
resource?: string;
};
4 changes: 2 additions & 2 deletions packages/remix/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LogtoConfig } from '@logto/node';
import { GetContextParameters, LogtoConfig } from '@logto/node';
import { SessionStorage } from '@remix-run/node';

import { makeLogtoAdapter } from './infrastructure/logto';
Expand Down Expand Up @@ -28,7 +28,7 @@ export const makeLogtoRemix = (
sessionStorage,
}),

getContext: (dto: { includeAccessToken: boolean }) =>
getContext: (dto: GetContextParameters) =>
makeGetContext(dto, {
createLogtoAdapter,
sessionStorage,
Expand Down
10 changes: 3 additions & 7 deletions packages/remix/src/infrastructure/logto/get-context.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { LogtoContext } from '@logto/node';
import { GetContextParameters, LogtoContext } from '@logto/node';

import { CreateLogtoClient } from './create-client';
import { LogtoStorage } from './create-storage';

type GetContextRequest = {
readonly includeAccessToken: boolean;
};

type GetContextResponse = {
readonly context: LogtoContext;
};

export const makeGetContext =
(deps: { storage: LogtoStorage; createClient: CreateLogtoClient }) =>
async (request: GetContextRequest): Promise<GetContextResponse> => {
async (request: GetContextParameters): Promise<GetContextResponse> => {
const { storage, createClient } = deps;

const client = createClient();

const context = await client.getContext(request.includeAccessToken);
const context = await client.getContext(request);

return {
context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ describe('useCases:getContext:GetContextController', () => {

const controller = GetContextController.fromDto({
useCase,
includeAccessToken: false,
});

expect(controller.constructor.name).toBe('GetContextController');
Expand Down
18 changes: 5 additions & 13 deletions packages/remix/src/useCases/getContext/GetContextController.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,28 @@
import { LogtoContext } from '@logto/node';
import { GetContextParameters, LogtoContext } from '@logto/node';

import { getCookieHeaderFromRequest } from '../../framework/get-cookie-header-from-request';
import type { GetContextUseCase } from './GetContextUseCase';

type GetContextControllerDto = {
readonly includeAccessToken?: boolean;
type GetContextControllerDto = GetContextParameters & {
readonly useCase: GetContextUseCase;
};

export class GetContextController {
public static readonly fromDto = (dto: GetContextControllerDto) =>
new GetContextController({
useCase: dto.useCase,
includeAccessToken: dto.includeAccessToken ?? false,
});
public static readonly fromDto = (dto: GetContextControllerDto) => new GetContextController(dto);

private readonly useCase = this.properties.useCase;
private readonly includeAccessToken = this.properties.includeAccessToken;
private constructor(
private readonly properties: {
includeAccessToken: boolean;
private readonly properties: GetContextParameters & {
useCase: GetContextUseCase;
}
) {}

public readonly execute = async (request: Request): Promise<LogtoContext> => {
const cookieHeader = getCookieHeaderFromRequest(request);
const { includeAccessToken } = this;

const result = await this.useCase({
cookieHeader: cookieHeader ?? undefined,
includeAccessToken,
...this.properties,
});

return result.context;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ describe('useCases:getContext:GetContextUseCase', () => {

const response = await execute({
cookieHeader: 'abcd',
includeAccessToken: false,
});

expect(getContext).toBeCalledTimes(1);
Expand Down
8 changes: 3 additions & 5 deletions packages/remix/src/useCases/getContext/GetContextUseCase.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { GetContextParameters } from '@logto/node';
import { SessionStorage } from '@remix-run/node';

import { CreateLogtoAdapter, LogtoContext } from '../../infrastructure/logto';

type GetContextRequest = {
type GetContextRequest = GetContextParameters & {
readonly cookieHeader: string | undefined;
readonly includeAccessToken: boolean;
};

type GetContextResponse = {
Expand All @@ -20,9 +20,7 @@ export const makeGetContextUseCase =

const logto = createLogtoAdapter(session);

const response = await logto.getContext({
includeAccessToken: request.includeAccessToken,
});
const response = await logto.getContext(request);

return {
context: response.context,
Expand Down
9 changes: 3 additions & 6 deletions packages/remix/src/useCases/getContext/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { GetContextParameters } from '@logto/node';
import { SessionStorage } from '@remix-run/node';

import { CreateLogtoAdapter } from '../../infrastructure/logto';
import { GetContextController } from './GetContextController';
import { makeGetContextUseCase } from './GetContextUseCase';

type GetContextDto = {
readonly includeAccessToken?: boolean;
};

type HandleGetContextDeps = {
readonly createLogtoAdapter: CreateLogtoAdapter;
readonly sessionStorage: SessionStorage;
};

export const makeGetContext =
(dto: GetContextDto, deps: HandleGetContextDeps) => async (request: Request) => {
(dto: GetContextParameters, deps: HandleGetContextDeps) => async (request: Request) => {
const { createLogtoAdapter, sessionStorage } = deps;

const useCase = makeGetContextUseCase({
Expand All @@ -24,7 +21,7 @@ export const makeGetContext =

const controller = GetContextController.fromDto({
useCase,
includeAccessToken: dto.includeAccessToken,
...dto,
});

return controller.execute(request);
Expand Down

0 comments on commit 6fb22ea

Please sign in to comment.