Skip to content

Commit

Permalink
feat(js): add interactionMode props to signIn method
Browse files Browse the repository at this point in the history
add interactionMode props to signIn method
  • Loading branch information
simeng-li committed Mar 3, 2023
1 parent fff27b2 commit ea763a5
Show file tree
Hide file tree
Showing 19 changed files with 128 additions and 26 deletions.
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
LogtoConfig,
LogtoClientErrorCode,
UserInfoResponse,
InteractionMode,
} from '@logto/client';

export {
Expand Down
7 changes: 7 additions & 0 deletions packages/client/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
endSessionEndpoint,
failingRequester,
createAdapters,
mockedSignUpUri,
} from './mock';
import { buildAccessTokenKey } from './utils';

Expand Down Expand Up @@ -147,6 +148,12 @@ describe('LogtoClient', () => {
expect(navigate).toHaveBeenCalledWith(mockedSignInUri);
});

it('should redirect to signInUri with interactionMode params after calling signIn with signUp mode', async () => {
const logtoClient = createClient();
await logtoClient.signIn(redirectUri, 'signUp');
expect(navigate).toHaveBeenCalledWith(mockedSignUpUri);
});

it('should redirect to signInUri just after calling signIn with user specified prompt', async () => {
const logtoClient = createClient(Prompt.Login);
await logtoClient.signIn(redirectUri);
Expand Down
12 changes: 9 additions & 3 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { CodeTokenResponse, IdTokenClaims, UserInfoResponse } from '@logto/js';
import type {
CodeTokenResponse,
IdTokenClaims,
UserInfoResponse,
InteractionMode,
} from '@logto/js';
import {
decodeIdToken,
fetchOidcConfig,
Expand All @@ -23,7 +28,7 @@ import type { AccessToken, LogtoConfig, LogtoSignInSessionItem } from './types';
import { isLogtoAccessTokenMap, isLogtoSignInSessionItem } from './types';
import { buildAccessTokenKey, getDiscoveryEndpoint } from './utils';

export type { IdTokenClaims, LogtoErrorCode, UserInfoResponse } from '@logto/js';
export type { IdTokenClaims, LogtoErrorCode, UserInfoResponse, InteractionMode } from '@logto/js';
export {
LogtoError,
OidcError,
Expand Down Expand Up @@ -111,7 +116,7 @@ export default class LogtoClient {
return fetchUserInfo(userinfoEndpoint, accessToken, this.adapter.requester);
}

async signIn(redirectUri: string) {
async signIn(redirectUri: string, interactionMode?: InteractionMode) {
const { appId: clientId, prompt, resources, scopes } = this.logtoConfig;
const { authorizationEndpoint } = await this.getOidcConfig();
const codeVerifier = this.adapter.generateCodeVerifier();
Expand All @@ -127,6 +132,7 @@ export default class LogtoClient {
scopes,
resources,
prompt,
interactionMode,
});

await this.setSignInSession({ redirectUri, codeVerifier, state });
Expand Down
11 changes: 11 additions & 0 deletions packages/client/src/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ export const postSignOutRedirectUri = 'http://localhost:3000';
export const mockCodeChallenge = 'code_challenge_value';
export const mockedCodeVerifier = 'code_verifier_value';
export const mockedState = 'state_value';

export const mockedSignInUri = generateSignInUri({
authorizationEndpoint,
clientId: appId,
redirectUri,
codeChallenge: mockCodeChallenge,
state: mockedState,
});

export const mockedSignInUriWithLoginPrompt = generateSignInUri({
authorizationEndpoint,
clientId: appId,
Expand All @@ -67,6 +69,15 @@ export const mockedSignInUriWithLoginPrompt = generateSignInUri({
prompt: Prompt.Login,
});

export const mockedSignUpUri = generateSignInUri({
authorizationEndpoint,
clientId: appId,
redirectUri,
codeChallenge: mockCodeChallenge,
state: mockedState,
interactionMode: 'signUp',
});

export const accessToken = 'access_token_value';
export const refreshToken = 'new_refresh_token_value';
export const idToken = 'id_token_value';
Expand Down
12 changes: 10 additions & 2 deletions packages/express/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ type Adapter = {

jest.mock('@logto/node', () =>
jest.fn((_: unknown, { navigate }: Adapter) => ({
signIn: () => {
navigate(signInUrl);
signIn: (_redirectUri?: string, interactionMode?: string) => {
navigate(interactionMode ? `${signInUrl}?interactionMode=${interactionMode}` : signInUrl);
signIn();
},
handleSignInCallback,
Expand Down Expand Up @@ -65,6 +65,14 @@ describe('Express', () => {
});
});

describe('handleSignUn', () => {
it('should redirect to Logto sign in url with signUp interaction mode and save session', async () => {
const response = await testRouter(handleAuthRoutes(configs)).get('/logto/sign-up');
expect(response.header.location).toEqual(`${signInUrl}?interactionMode=signUp`);
expect(signIn).toHaveBeenCalled();
});
});

describe('handleSignInCallback', () => {
it('should call client.handleSignInCallback and redirect to home page', async () => {
const response = await testRouter(handleAuthRoutes(configs)).get('/logto/sign-in-callback');
Expand Down
8 changes: 7 additions & 1 deletion packages/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { LogtoExpressConfig } from './types';

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

export type { LogtoContext } from '@logto/node';
export type { LogtoContext, InteractionMode } from '@logto/node';
export type { LogtoExpressConfig } from './types';

export type Middleware = (
Expand Down Expand Up @@ -55,6 +55,12 @@ export const handleAuthRoutes = (config: LogtoExpressConfig): Router => {
break;
}

case 'sign-up': {
await nodeClient.signIn(`${config.baseUrl}/logto/sign-in-callback`, 'signUp');

break;
}

case 'sign-in-callback': {
if (request.url) {
await nodeClient.handleSignInCallback(`${config.baseUrl}${request.originalUrl}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/js/src/consts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export enum QueryKey {
Scope = 'scope',
State = 'state',
Token = 'token',
// Need to align with the OIDC extraParams settings in core
InteractionMode = 'interaction_mode',
}

export enum Prompt {
Expand Down
15 changes: 15 additions & 0 deletions packages/js/src/core/sign-in.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,19 @@ describe('generateSignInUri', () => {
'https://logto.dev/oidc/sign-in?client_id=clientId&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&code_challenge=codeChallenge&code_challenge_method=S256&state=state&response_type=code&prompt=login&scope=openid+offline_access+profile+email+phone&resource=resource1&resource=resource2'
);
});

test('with interactionMode', () => {
const signInUri = generateSignInUri({
authorizationEndpoint,
clientId,
redirectUri,
codeChallenge,
state,
interactionMode: 'signUp',
});

expect(signInUri).toEqual(
'https://logto.dev/oidc/sign-in?client_id=clientId&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&code_challenge=codeChallenge&code_challenge_method=S256&state=state&response_type=code&prompt=consent&scope=openid+offline_access+profile&interaction_mode=signUp'
);
});
});
8 changes: 8 additions & 0 deletions packages/js/src/core/sign-in.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Prompt, QueryKey } from '../consts';
import type { InteractionMode } from '../types';
import { withDefaultScopes } from '../utils';

const codeChallengeMethod = 'S256';
Expand All @@ -13,6 +14,7 @@ export type SignInUriParameters = {
scopes?: string[];
resources?: string[];
prompt?: Prompt;
interactionMode?: InteractionMode;
};

export const generateSignInUri = ({
Expand All @@ -24,6 +26,7 @@ export const generateSignInUri = ({
scopes,
resources,
prompt,
interactionMode,
}: SignInUriParameters) => {
const urlSearchParameters = new URLSearchParams({
[QueryKey.ClientId]: clientId,
Expand All @@ -40,5 +43,10 @@ export const generateSignInUri = ({
urlSearchParameters.append(QueryKey.Resource, resource);
}

// Set interactionMode to signUp for a create account user experience
if (interactionMode) {
urlSearchParameters.append(QueryKey.InteractionMode, interactionMode);
}

return `${authorizationEndpoint}?${urlSearchParameters.toString()}`;
};
3 changes: 3 additions & 0 deletions packages/js/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export type LogtoRequestErrorBody = {
};

export type Requester = <T>(...args: Parameters<typeof fetch>) => Promise<T>;

// Need to align with the OIDC extraParams settings in core
export type InteractionMode = 'signIn' | 'signUp';
19 changes: 17 additions & 2 deletions packages/next/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ type Adapter = {

jest.mock('@logto/node', () =>
jest.fn((_: unknown, { navigate }: Adapter) => ({
signIn: () => {
navigate(signInUrl);
signIn: (_redirectUri: string, interactionMode?: string) => {
navigate(interactionMode ? `${signInUrl}?interactionMode=${interactionMode}` : signInUrl);
signIn();
},
handleSignInCallback,
Expand Down Expand Up @@ -87,6 +87,21 @@ describe('Next', () => {
expect(save).toHaveBeenCalled();
expect(signIn).toHaveBeenCalled();
});

it('should redirect to Logto sign in url with interactionMode and save session', async () => {
const client = new LogtoClient(configs);
await testApiHandler({
handler: client.handleSignIn(undefined, 'signUp'),
url: '/api/logto/sign-in',
test: async ({ fetch }) => {
const response = await fetch({ method: 'GET', redirect: 'manual' });
const headers = response.headers as Map<string, string>;
expect(headers.get('location')).toEqual(`${signInUrl}?interactionMode=signUp`);
},
});
expect(save).toHaveBeenCalled();
expect(signIn).toHaveBeenCalled();
});
});

describe('handleSignInCallback', () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/next/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IncomingMessage } from 'http';

import type { GetContextParameters } from '@logto/node';
import type { GetContextParameters, InteractionMode } from '@logto/node';
import NodeClient from '@logto/node';
import { withIronSessionApiRoute, withIronSessionSsr } from 'iron-session/next';
import type { GetServerSidePropsContext, GetServerSidePropsResult, NextApiHandler } from 'next';
Expand All @@ -10,19 +10,20 @@ import type { LogtoNextConfig } from './types';

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

export type { LogtoContext } from '@logto/node';
export type { LogtoContext, InteractionMode } from '@logto/node';

export default class LogtoClient {
private navigateUrl?: string;
private storage?: NextStorage;
constructor(private readonly config: LogtoNextConfig) {}

handleSignIn = (
redirectUri = `${this.config.baseUrl}/api/logto/sign-in-callback`
redirectUri = `${this.config.baseUrl}/api/logto/sign-in-callback`,
interactionMode?: InteractionMode
): NextApiHandler =>
withIronSessionApiRoute(async (request, response) => {
const nodeClient = this.createNodeClient(request);
await nodeClient.signIn(redirectUri);
await nodeClient.signIn(redirectUri, interactionMode);
await this.storage?.save();

if (this.navigateUrl) {
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
LogtoClientErrorCode,
Storage,
StorageKey,
InteractionMode,
} from '@logto/client';

export {
Expand Down
4 changes: 4 additions & 0 deletions packages/react-sample/src/pages/Home/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
padding: 20px;
}

.button:not(:last-child) {
margin-right: 4px;
}

.table {
margin: 50px auto;
table-layout: fixed;
Expand Down
26 changes: 18 additions & 8 deletions packages/react-sample/src/pages/Home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,24 @@ const Home = () => {
<div className={styles.container}>
<h3>Logto React Sample</h3>
{!isAuthenticated && (
<button
type="button"
onClick={() => {
void signIn(redirectUrl);
}}
>
Sign In
</button>
<>
<button
type="button"
onClick={() => {
void signIn(redirectUrl);
}}
>
Sign In
</button>
<button
type="button"
onClick={() => {
void signIn(redirectUrl, 'signUp');
}}
>
Sign Up
</button>
</>
)}
{isAuthenticated && (
<button
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IdTokenClaims, UserInfoResponse } from '@logto/browser';
import { IdTokenClaims, InteractionMode, UserInfoResponse } from '@logto/browser';
import { useCallback, useContext, useEffect, useRef } from 'react';

import { LogtoContext, throwContextError } from '../context';
Expand All @@ -10,7 +10,7 @@ type Logto = {
fetchUserInfo: () => Promise<UserInfoResponse | undefined>;
getAccessToken: (resource?: string) => Promise<string | undefined>;
getIdTokenClaims: () => Promise<IdTokenClaims | undefined>;
signIn: (redirectUri: string) => Promise<void>;
signIn: (redirectUri: string, interactionMode?: InteractionMode) => Promise<void>;
signOut: (postLogoutRedirectUri?: string) => Promise<void>;
};

Expand Down Expand Up @@ -104,15 +104,15 @@ const useLogto = (): Logto => {
const isLoading = loadingCount > 0;

const signIn = useCallback(
async (redirectUri: string) => {
async (redirectUri: string, interactionMode?: InteractionMode) => {
if (!logtoClient) {
return throwContextError();
}

try {
setLoadingState(true);

await logtoClient.signIn(redirectUri);
await logtoClient.signIn(redirectUri, interactionMode);
} catch (error: unknown) {
handleError(error, 'Unexpected error occurred while signing in.');
}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
UserInfoResponse,
LogtoErrorCode,
LogtoClientErrorCode,
InteractionMode,
} from '@logto/browser';

export {
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
UserInfoResponse,
LogtoErrorCode,
LogtoClientErrorCode,
InteractionMode,
} from '@logto/browser';

export {
Expand Down
Loading

0 comments on commit ea763a5

Please sign in to comment.