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(auth): added multi-language support to logIn method #836

Merged
merged 4 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
95 changes: 88 additions & 7 deletions src/auth/AuthManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ServerType } from '@sasjs/utils/types'
import { RequestClient } from '../request/RequestClient'
import { NotFoundError } from '../types/errors'
import { LoginOptions, LoginResult, LoginResultInternal } from '../types/Login'
import { serialize } from '../utils'
import { serialize, getUserLanguage } from '../utils'
import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
import { openWebPage } from './openWebPage'
import { verifySas9Login } from './verifySas9Login'
Expand All @@ -14,6 +14,44 @@ export class AuthManager {
private loginUrl: string
private logoutUrl: string
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
private defaultSuccessHeaderKey = 'default'
private successHeaders: { [key: string]: string } = {
es: `Ya se ha iniciado la sesi\u00f3n.`,
th: `\u0e04\u0e38\u0e13\u0e25\u0e07\u0e0a\u0e37\u0e48\u0e2d\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e41\u0e25\u0e49\u0e27`,
ja: `\u30b5\u30a4\u30f3\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002`,
nb: `Du har logget deg p\u00e5.`,
sl: `Prijavili ste se.`,
ar: `\u0644\u0642\u062f \u0642\u0645\u062a `,
sk: `Prihl\u00e1sili ste sa.`,
zh_HK: `\u60a8\u5df2\u767b\u5165\u3002`,
zh_CN: `\u60a8\u5df2\u767b\u5f55\u3002`,
it: `L'utente si \u00e8 connesso.`,
sv: `Du har loggat in.`,
he: `\u05e0\u05db\u05e0\u05e1\u05ea `,
nl: `U hebt zich aangemeld.`,
pl: `Zosta\u0142e\u015b zalogowany.`,
ko: `\ub85c\uadf8\uc778\ud588\uc2b5\ub2c8\ub2e4.`,
zh_TW: `\u60a8\u5df2\u767b\u5165\u3002`,
tr: `Oturum a\u00e7t\u0131n\u0131z.`,
iw: `\u05e0\u05db\u05e0\u05e1\u05ea `,
fr: `Vous \u00eates connect\u00e9.`,
uk: `\u0412\u0438 \u0432\u0432\u0456\u0439\u0448\u043b\u0438 \u0432 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441.`,
pt_BR: `Voc\u00ea se conectou.`,
no: `Du har logget deg p\u00e5.`,
cs: `Jste p\u0159ihl\u00e1\u0161eni.`,
fi: `Olet kirjautunut sis\u00e4\u00e4n.`,
ru: `\u0412\u044b \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u043b\u0438 \u0432\u0445\u043e\u0434 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.`,
el: `\u0388\u03c7\u03b5\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af.`,
hr: `Prijavili ste se.`,
da: `Du er logget p\u00e5.`,
de: `Sie sind jetzt angemeldet.`,
sh: `Prijavljeni ste.`,
pt: `Iniciou sess\u00e3o.`,
hu: `Bejelentkezett.`,
sr: `Prijavljeni ste.`,
en: enLoginSuccessHeader,
YuryShkoda marked this conversation as resolved.
Show resolved Hide resolved
[this.defaultSuccessHeaderKey]: enLoginSuccessHeader
}

constructor(
private serverUrl: string,
Expand Down Expand Up @@ -132,7 +170,10 @@ export class AuthManager {

let loginResponse = await this.sendLoginRequest(loginForm, loginParams)

let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
let isLoggedIn = this.isLogInSuccessHeaderPresent(
this.serverType,
loginResponse
)

if (!isLoggedIn) {
if (isCredentialsVerifyError(loginResponse)) {
Expand Down Expand Up @@ -166,6 +207,50 @@ export class AuthManager {
}
}

/**
* Checks if Login success header is present in the response based on language settings of the browser
* @param serverType - server type
* @param response - response object
* @returns - return boolean indicating if Login success header is present
*/
private isLogInSuccessHeaderPresent(
serverType: ServerType,
response: any
): boolean {
if (serverType === ServerType.Sasjs) return response?.loggedin

// get default success header
let successHeader = this.successHeaders[this.defaultSuccessHeaderKey]

// get user language based on language settings of the browser
const userLang = getUserLanguage()

if (userLang) {
// get success header on exact match of the language code
let userLangSuccessHeader = this.successHeaders[userLang]

// handle case when there is no exact match of the language code
if (!userLangSuccessHeader) {
// get all supported language codes
const headerLanguages = Object.keys(this.successHeaders)

// find language code on partial match
const headerLanguage = headerLanguages.find((language) =>
new RegExp(language, 'i').test(userLang)
)

// reassign success header if partial match was found
if (headerLanguage) {
successHeader = this.successHeaders[headerLanguage]
}
} else {
successHeader = userLangSuccessHeader
}
}

return new RegExp(successHeader, 'gm').test(response)
}

private async performCASSecurityCheck() {
const casAuthenticationUrl = `${this.serverUrl}/SASStoredProcess/j_spring_cas_security_check`

Expand Down Expand Up @@ -385,8 +470,4 @@ const isCredentialsVerifyError = (response: string): boolean =>
response
)

const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedin

return /You have signed in/gm.test(response)
}
export const enLoginSuccessHeader = 'You have signed in.'
60 changes: 60 additions & 0 deletions src/auth/spec/AuthManager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* @jest-environment jsdom
*/

import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types'
Expand Down Expand Up @@ -63,6 +67,12 @@ describe('AuthManager', () => {
})

describe('login - default mechanism', () => {
let languageGetter: any

beforeEach(() => {
languageGetter = jest.spyOn(window.navigator, 'language', 'get')
})

it('should call the auth callback and return when already logged in', async () => {
const authManager = new AuthManager(
serverUrl,
Expand Down Expand Up @@ -294,6 +304,56 @@ describe('AuthManager', () => {
mockLoginAuthoriseRequiredResponse
)
})

it('should check login success header based on language preferences of the browser', () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)

// test built in language codes
Object.keys(authManager['successHeaders']).forEach((key) => {
languageGetter.mockReturnValue(key)

expect(
authManager['isLogInSuccessHeaderPresent'](
serverType,
authManager['successHeaders'][key]
)
).toBeTruthy()
})

// test possible longer language codes
const possibleLanguageCodes = [
{ short: 'en', long: 'en-US' },
{ short: 'fr', long: 'fr-FR' },
{ short: 'es', long: 'es-ES' }
]

possibleLanguageCodes.forEach((key) => {
const { short, long } = key
languageGetter.mockReturnValue(long)

expect(
authManager['isLogInSuccessHeaderPresent'](
serverType,
authManager['successHeaders'][short]
)
).toBeTruthy()
})

// test falling back to default language code
languageGetter.mockReturnValue('WRONG-LANGUAGE')

expect(
authManager['isLogInSuccessHeaderPresent'](
serverType,
authManager['successHeaders'][authManager['defaultSuccessHeaderKey']]
)
).toBeTruthy()
})
})

describe('login - redirect mechanism', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/auth/spec/mockResponses.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { SasAuthResponse } from '@sasjs/utils/types'
import { enLoginSuccessHeader } from '../AuthManager'

export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
export const mockLoginSuccessResponse = `You have signed in`
export const mockLoginSuccessResponse = enLoginSuccessHeader

export const mockAuthResponse: SasAuthResponse = {
access_token: 'acc355',
Expand Down
3 changes: 2 additions & 1 deletion src/auth/spec/verifySas9Login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { verifySas9Login } from '../verifySas9Login'
import * as delayModule from '../../utils/delay'
import { enLoginSuccessHeader } from '../AuthManager'

describe('verifySas9Login', () => {
const serverUrl = 'http://test-server.com'
Expand All @@ -18,7 +19,7 @@ describe('verifySas9Login', () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
document: { body: { innerText: `<h3>${enLoginSuccessHeader}</h3>` } }
}
} as unknown as Window

Expand Down
3 changes: 2 additions & 1 deletion src/auth/spec/verifySasViyaLogin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { verifySasViyaLogin } from '../verifySasViyaLogin'
import * as delayModule from '../../utils/delay'
import { enLoginSuccessHeader } from '../AuthManager'

describe('verifySasViyaLogin', () => {
const serverUrl = 'http://test-server.com'
Expand All @@ -19,7 +20,7 @@ describe('verifySasViyaLogin', () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
document: { body: { innerText: `<h3>${enLoginSuccessHeader}</h3>` } }
}
} as unknown as Window

Expand Down
3 changes: 2 additions & 1 deletion src/auth/verifySas9Login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { delay } from '../utils'
import { enLoginSuccessHeader } from './AuthManager'

export async function verifySas9Login(loginPopup: Window): Promise<{
isLoggedIn: boolean
Expand All @@ -12,7 +13,7 @@ export async function verifySas9Login(loginPopup: Window): Promise<{

isLoggedIn =
loginPopup.window.location.href.includes('SASLogon') &&
loginPopup.window.document.body.innerText.includes('You have signed in.')
loginPopup.window.document.body.innerText.includes(enLoginSuccessHeader)
YuryShkoda marked this conversation as resolved.
Show resolved Hide resolved
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)

Expand Down
5 changes: 2 additions & 3 deletions src/auth/verifySasViyaLogin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { delay } from '../utils'
import { enLoginSuccessHeader } from './AuthManager'

export async function verifySasViyaLogin(loginPopup: Window): Promise<{
isLoggedIn: boolean
Expand All @@ -20,9 +21,7 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
if (loginPopup.closed) break
isAuthorized =
loginPopup.window.location.href.includes('SASLogon') ||
loginPopup.window.document.body?.innerText?.includes(
'You have signed in.'
)
loginPopup.window.document.body?.innerText?.includes(enLoginSuccessHeader)
YuryShkoda marked this conversation as resolved.
Show resolved Hide resolved
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isAuthorized && elapsedSeconds < 5 * 60)

Expand Down
10 changes: 10 additions & 0 deletions src/utils/getUserLanguage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
interface IEnavigator {
userLanguage?: string
}

/**
* Provides preferred language of the user.
* @returns A string representing the preferred language of the user, usually the language of the browser UI. Examples of valid language codes include "en", "en-US", "fr", "fr-FR", "es-ES". More info available https://datatracker.ietf.org/doc/html/rfc5646
*/
export const getUserLanguage = () =>
window.navigator.language || (window.navigator as IEnavigator).userLanguage
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './serialize'
export * from './splitChunks'
export * from './validateInput'
export * from './getFormData'
export * from './getUserLanguage'
Loading