Skip to content

Commit

Permalink
feat(browser): handle sign-in callback (#181)
Browse files Browse the repository at this point in the history
* fix(browser): getAccessToken

* feat(browser): handle sign-in callback

* test(browser): handle sign-in callback
  • Loading branch information
IceHe authored Feb 23, 2022
1 parent 12b86e9 commit 58a4792
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 31 deletions.
1 change: 1 addition & 0 deletions packages/browser/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import get from 'lodash.get';
const logtoClientErrorCodes = Object.freeze({
sign_in_session: {
invalid: 'Invalid sign-in session.',
not_found: 'Sign-in session not found.',
},
not_authenticated: 'Not authenticated.',
get_access_token_by_refresh_token_failed: 'Failed to get access token by refresh token.',
Expand Down
93 changes: 78 additions & 15 deletions packages/browser/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,74 @@
import { Optional } from '@silverhand/essentials';
import { generateSignInUri } from '@logto/js';
import { Nullable } from '@silverhand/essentials';

import LogtoClient, { LogtoClientError, LogtoSignInSessionItem } from '.';

const clientId = 'client_id_value';
const endpoint = 'https://logto.dev';

const authorizationEndpoint = `${endpoint}/oidc/auth`;
const tokenEndpoint = `${endpoint}/oidc/token`;
const endSessionEndpoint = `${endpoint}/oidc/session/end`;
const revocationEndpoint = `${endpoint}/oidc/token/revocation`;
const mockedCodeVerifier = 'code_verifier_value';
const mockedState = 'state_value';
const mockedSignInUri = `${authorizationEndpoint}?foo=bar`;
const jwksUri = `${endpoint}/oidc/jwks`;
const issuer = 'http://localhost:443/oidc';

const redirectUri = 'http://localhost:3000/callback';
const postSignOutRedirectUri = 'http://localhost:3000';
const idTokenStorageKey = `logto:${clientId}:idToken`;

const mockCodeChallenge = 'code_challenge_value';
const mockedCodeVerifier = 'code_verifier_value';
const mockedState = 'state_value';
const mockedSignInUri = generateSignInUri({
authorizationEndpoint,
clientId,
redirectUri,
codeChallenge: mockCodeChallenge,
state: mockedState,
});

const refreshTokenStorageKey = `logto:${clientId}:refreshToken`;
const idTokenStorageKey = `logto:${clientId}:idToken`;

const accessToken = 'access_token_value';
const refreshToken = 'new_refresh_token_value';
const idToken = 'id_token_value';

const requester = jest.fn();
const failingRequester = jest.fn().mockRejectedValue(new Error('Failed!'));

jest.mock('@logto/js', () => ({
...jest.requireActual('@logto/js'),
fetchOidcConfig: async () => ({ authorizationEndpoint, endSessionEndpoint, revocationEndpoint }),
generateCodeChallenge: jest.fn().mockResolvedValue('code_challenge_value'),
fetchOidcConfig: jest.fn(async () => ({
authorizationEndpoint,
tokenEndpoint,
endSessionEndpoint,
revocationEndpoint,
jwksUri,
issuer,
})),
fetchTokenByAuthorizationCode: jest.fn(async () => ({
accessToken,
refreshToken,
idToken,
scope: 'read register manage',
expiresIn: 3600,
})),
generateCodeChallenge: jest.fn(async () => mockCodeChallenge),
generateCodeVerifier: jest.fn(() => mockedCodeVerifier),
generateSignInUri: jest.fn(() => mockedSignInUri),
generateState: jest.fn(() => mockedState),
verifyIdToken: jest.fn(),
}));

/**
* Make LogtoClient.signInSession accessible for test
*/
class LogtoClientSignInSessionAccessor extends LogtoClient {
public getSignInSessionItem(): Optional<LogtoSignInSessionItem> {
public getSignInSessionItem(): Nullable<LogtoSignInSessionItem> {
return this.signInSession;
}

public setSignInSessionItem(item: Optional<LogtoSignInSessionItem>) {
public setSignInSessionItem(item: Nullable<LogtoSignInSessionItem>) {
this.signInSession = item;
}
}
Expand All @@ -45,6 +79,10 @@ describe('LogtoClient', () => {
});

describe('signInSession', () => {
beforeEach(() => {
sessionStorage.clear();
});

test('getter should throw LogtoClientError when signInSession does not contain the required property', () => {
const signInSessionAccessor = new LogtoClientSignInSessionAccessor(
{ endpoint, clientId },
Expand All @@ -71,9 +109,8 @@ describe('LogtoClient', () => {
requester
);

// @ts-expect-error expected to set undefined
signInSessionAccessor.setSignInSessionItem();
expect(signInSessionAccessor.getSignInSessionItem()).toBeUndefined();
signInSessionAccessor.setSignInSessionItem(null);
expect(signInSessionAccessor.getSignInSessionItem()).toBeNull();
});

test('should be able to set and get the correct item', () => {
Expand All @@ -93,12 +130,38 @@ describe('LogtoClient', () => {
});
});

describe('signIn', () => {
test('window.location should be correct signInUri', async () => {
describe('signIn and handleSignInCallback', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});

test('should redirect to signInUri just after calling signIn', async () => {
const logtoClient = new LogtoClient({ endpoint, clientId }, requester);
await logtoClient.signIn(redirectUri);
expect(window.location.toString()).toEqual(mockedSignInUri);
});

test('handleSignInCallback should throw LogtoClientError when the sign-in session does not exist', async () => {
const logtoClient = new LogtoClient({ endpoint, clientId }, requester);
await expect(logtoClient.handleSignInCallback(redirectUri)).rejects.toMatchError(
new LogtoClientError('sign_in_session.not_found')
);
});

test('tokens should be set after calling signIn and handleSignInCallback successfully', async () => {
const logtoClient = new LogtoClient({ endpoint, clientId }, requester);
await logtoClient.signIn(redirectUri);

const code = `code_value`;
const callbackUri = `${redirectUri}?code=${code}&state=${mockedState}&codeVerifier=${mockedCodeVerifier}`;

await expect(logtoClient.handleSignInCallback(callbackUri)).resolves.not.toThrow();

await expect(logtoClient.getAccessToken()).resolves.toEqual(accessToken);
expect(localStorage.getItem(refreshTokenStorageKey)).toEqual(refreshToken);
expect(localStorage.getItem(idTokenStorageKey)).toEqual(idToken);
});
});

describe('signOut', () => {
Expand Down
112 changes: 96 additions & 16 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
CodeTokenResponse,
createRequester,
decodeIdToken,
fetchOidcConfig,
fetchTokenByAuthorizationCode,
fetchTokenByRefreshToken,
fetchUserInfo,
generateCodeChallenge,
Expand All @@ -14,10 +16,11 @@ import {
Requester,
revoke,
UserInfoResponse,
verifyAndParseCodeFromCallbackUri,
verifyIdToken,
withReservedScopes,
} from '@logto/js';
import { Nullable, Optional } from '@silverhand/essentials';
import { Nullable } from '@silverhand/essentials';
import { createRemoteJWKSet } from 'jose';
import { assert, Infer, string, type } from 'superstruct';

Expand Down Expand Up @@ -55,31 +58,33 @@ export const LogtoSignInSessionItemSchema = type({
export type LogtoSignInSessionItem = Infer<typeof LogtoSignInSessionItemSchema>;

export default class LogtoClient {
protected accessTokenMap = new Map<string, AccessToken>();
protected refreshToken: Nullable<string>;
protected idToken: Nullable<string>;
protected logtoConfig: LogtoConfig;
protected oidcConfig?: OidcConfigResponse;

protected logtoStorageKey: string;
protected requester: Requester;

protected accessTokenMap = new Map<string, AccessToken>();
private _refreshToken: Nullable<string>;
private _idToken: Nullable<string>;

constructor(logtoConfig: LogtoConfig, requester = createRequester()) {
this.logtoConfig = logtoConfig;
this.logtoStorageKey = buildLogtoKey(logtoConfig.clientId);
this.requester = requester;
this.refreshToken = localStorage.getItem(buildRefreshTokenKey(this.logtoStorageKey));
this.idToken = localStorage.getItem(buildIdTokenKey(this.logtoStorageKey));
this._refreshToken = localStorage.getItem(buildRefreshTokenKey(this.logtoStorageKey));
this._idToken = localStorage.getItem(buildIdTokenKey(this.logtoStorageKey));
}

public get isAuthenticated() {
return Boolean(this.idToken);
}

protected get signInSession(): Optional<LogtoSignInSessionItem> {
protected get signInSession(): Nullable<LogtoSignInSessionItem> {
const jsonItem = sessionStorage.getItem(this.logtoStorageKey);

if (!jsonItem) {
return undefined;
return null;
}

try {
Expand All @@ -92,7 +97,7 @@ export default class LogtoClient {
}
}

protected set signInSession(logtoSignInSessionItem: Optional<LogtoSignInSessionItem>) {
protected set signInSession(logtoSignInSessionItem: Nullable<LogtoSignInSessionItem>) {
if (!logtoSignInSessionItem) {
sessionStorage.removeItem(this.logtoStorageKey);

Expand All @@ -103,15 +108,51 @@ export default class LogtoClient {
sessionStorage.setItem(this.logtoStorageKey, jsonItem);
}

public async getAccessToken(resource?: string): Promise<Optional<string>> {
private get refreshToken() {
return this._refreshToken;
}

private set refreshToken(refreshToken: Nullable<string>) {
this._refreshToken = refreshToken;

const refreshTokenKey = buildRefreshTokenKey(this.logtoStorageKey);

if (!refreshToken) {
localStorage.removeItem(refreshTokenKey);

return;
}

localStorage.setItem(refreshTokenKey, refreshToken);
}

private get idToken() {
return this._idToken;
}

private set idToken(idToken: Nullable<string>) {
this._idToken = idToken;

const idTokenKey = buildIdTokenKey(this.logtoStorageKey);

if (!idToken) {
localStorage.removeItem(idTokenKey);

return;
}

localStorage.setItem(idTokenKey, idToken);
}

public async getAccessToken(resource?: string): Promise<Nullable<string>> {
if (!this.idToken) {
throw new LogtoClientError('not_authenticated');
}

const accessTokenKey = buildAccessTokenKey(resource);
const accessToken = this.accessTokenMap.get(accessTokenKey);

if (accessToken && accessToken.expiresAt > Date.now()) {
if (accessToken && accessToken.expiresAt > Date.now() / 1000) {
return accessToken.token;
}

Expand Down Expand Up @@ -140,12 +181,10 @@ export default class LogtoClient {
expiresAt: Math.round(Date.now() / 1000) + expiresIn,
});

localStorage.setItem(buildRefreshTokenKey(this.logtoStorageKey), refreshToken);
this.refreshToken = refreshToken;

if (idToken) {
await this.verifyIdToken(idToken);
localStorage.setItem(buildIdTokenKey(this.logtoStorageKey), idToken);
this.idToken = idToken;
}

Expand Down Expand Up @@ -196,6 +235,34 @@ export default class LogtoClient {
window.location.assign(signInUri);
}

public async handleSignInCallback(callbackUri: string) {
const { signInSession, logtoConfig, requester } = this;

if (!signInSession) {
throw new LogtoClientError('sign_in_session.not_found');
}

const { redirectUri, state, codeVerifier } = signInSession;
const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);

const { clientId } = logtoConfig;
const { tokenEndpoint } = await this.getOidcConfig();
const codeTokenResponse = await fetchTokenByAuthorizationCode(
{
clientId,
tokenEndpoint,
redirectUri,
codeVerifier,
code,
},
requester
);

await this.verifyIdToken(codeTokenResponse.idToken);

this.saveCodeToken(codeTokenResponse);
}

public async signOut(postLogoutRedirectUri?: string) {
if (!this.idToken) {
throw new LogtoClientError('not_authenticated');
Expand All @@ -218,9 +285,6 @@ export default class LogtoClient {
idToken: this.idToken,
});

localStorage.removeItem(buildRefreshTokenKey(this.logtoStorageKey));
localStorage.removeItem(buildIdTokenKey(this.logtoStorageKey));

this.accessTokenMap.clear();
this.refreshToken = null;
this.idToken = null;
Expand Down Expand Up @@ -248,4 +312,20 @@ export default class LogtoClient {
throw new LogtoClientError('invalid_id_token', error);
}
}

private saveCodeToken({
refreshToken,
idToken,
scope,
accessToken,
expiresIn,
}: CodeTokenResponse) {
this.refreshToken = refreshToken;
this.idToken = idToken;

// NOTE: Will add scope to accessTokenKey when needed. (Linear issue LOG-1589)
const accessTokenKey = buildAccessTokenKey();
const expiresAt = Date.now() / 1000 + expiresIn;
this.accessTokenMap.set(accessTokenKey, { token: accessToken, scope, expiresAt });
}
}

0 comments on commit 58a4792

Please sign in to comment.