1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 09:24:35 +00:00

Compare commits

...

13 Commits

Author SHA1 Message Date
Allan Bowe
ffd6bc5a5c Merge pull request #836 from sasjs/issue-835
feat(auth): added multi-language support to logIn method
2024-06-21 13:40:20 +01:00
Yury
c2e64d9ba6 chore: cleanup 2024-06-21 11:32:58 +03:00
Yury
a90f699abd feat(auth): added utils to get and check login header 2024-06-21 11:24:06 +03:00
Yury
2cca192f88 chore(auth-manager): added comment 2024-06-20 17:38:56 +03:00
Yury
053b07769a feat(auth): added multi-language support to logIn method 2024-06-20 17:15:05 +03:00
Yury Shkoda
4c4511913c Merge pull request #834 from sasjs/redirectLogin-fix
fix(auth-manager): fixed redirectedLoginUrl
2024-02-19 15:59:04 +03:00
Yury
8c64c24f3c chore: left a comment 2024-02-19 15:26:26 +03:00
Yury
1f2f445002 chore: fixed SASLogon URL in AuthManager test 2024-02-19 11:06:09 +03:00
Yury
6afa056a86 chore: testing 2024-02-19 10:58:23 +03:00
Yury
fe47ca1152 fix(auth-manager): fixed redirectedLoginUrl 2024-02-19 08:50:19 +03:00
Yury Shkoda
10da691f0f Merge pull request #833 from sasjs/parent-session-state-check
fix(poll-job-state): fixed checking session state
2023-09-15 12:12:24 +03:00
Yury Shkoda
318f9694cb test: updated unit tests threshold 2023-09-15 08:58:46 +03:00
Yury Shkoda
56e6131e5c fix(poll-job-state): fixed checking session state 2023-09-15 08:51:39 +03:00
17 changed files with 264 additions and 37 deletions

View File

@@ -22,8 +22,9 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Check npm audit
run: npm audit --production --audit-level=low
# FIXME: uncomment 'Check npm audit' step after axios version bump
# - name: Check npm audit
# run: npm audit --production --audit-level=low
- name: Install Dependencies
run: npm ci

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"cSpell.words": ["SASVIYA"]
}

View File

@@ -43,10 +43,10 @@ module.exports = {
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
statements: 64.01,
statements: 64.03,
branches: 45.11,
functions: 54.1,
lines: 64.51
functions: 54.18,
lines: 64.53
}
},

View File

@@ -223,7 +223,7 @@ const needsRetry = (state: string) =>
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
* @param streamLog - indicates if job log should be streamed.
* @param logStream - job log stream.
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state.
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state. Session state is considered healthy if it is equal to 'running' or 'idle'.
* @returns - a promise which resolves with a job state
*/
export const doPoll = async (
@@ -263,15 +263,20 @@ export const doPoll = async (
throw new JobStatePollError(jobId, err)
})
// Checks if session state is equal to 'running' or 'idle'.
const isSessionStatesHealthy = (state: string) =>
[SessionState.Running, SessionState.Idle].includes(
state as SessionState
)
// Clear parent session and throw an error if session state is not
// 'running' or response status is not 200.
if (sessionState !== SessionState.Running || responseStatus !== 200) {
// 'running', 'idle' or response status is not 200.
if (!isSessionStatesHealthy(sessionState) || responseStatus !== 200) {
sessionManager.clearSession(sessionId, access_token)
const sessionError =
sessionState !== SessionState.Running
? `Session state of the job is not 'running'. Session state is '${sessionState}'`
: `Session response status is not 200. Session response status is ${responseStatus}.`
const sessionError = isSessionStatesHealthy(sessionState)
? `Session response status is not 200. Session response status is ${responseStatus}.`
: `Session state of the job is not 'running' or 'idle'. Session state is '${sessionState}'`
throw new JobStatePollError(jobId, new Error(sessionError))
}

View File

@@ -465,7 +465,7 @@ describe('doPoll', () => {
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Running,
result: SessionState.Idle,
responseStatus: 200
})
})
@@ -533,9 +533,9 @@ describe('doPoll', () => {
)
})
it('should throw an error if session is not in running state', async () => {
it('should throw an error if session state is not healthy', async () => {
const filteredSessionStates = Object.values(SessionState).filter(
(state) => state !== SessionState.Running
(state) => state !== SessionState.Running && state !== SessionState.Idle
)
const randomSessionState =
filteredSessionStates[
@@ -580,7 +580,7 @@ describe('doPoll', () => {
new JobStatePollError(
mockJob.id,
new Error(
`Session state of the job is not 'running'. Session state is '${randomSessionState}'`
`Session state of the job is not 'running' or 'idle'. Session state is '${randomSessionState}'`
)
)
)

View File

@@ -7,6 +7,7 @@ import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
import { openWebPage } from './openWebPage'
import { verifySas9Login } from './verifySas9Login'
import { verifySasViyaLogin } from './verifySasViyaLogin'
import { isLogInSuccessHeaderPresent } from './'
export class AuthManager {
public userName = ''
@@ -14,6 +15,7 @@ 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
constructor(
private serverUrl: string,
private serverType: ServerType,
@@ -27,6 +29,8 @@ export class AuthManager {
: this.serverType === ServerType.SasViya
? '/SASLogon/logout.do?'
: '/SASLogon/logout'
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
}
/**
@@ -129,7 +133,7 @@ export class AuthManager {
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse)
if (!isLoggedIn) {
if (isCredentialsVerifyError(loginResponse)) {
@@ -214,7 +218,7 @@ export class AuthManager {
* - a boolean `isLoggedIn`
* - a string `userName`,
* - a string `userFullName` and
* - a form `loginForm` if not loggedin.
* - a form `loginForm` if not loggedIn.
*/
public async checkSession(): Promise<LoginResultInternal> {
const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
@@ -381,9 +385,3 @@ const isCredentialsVerifyError = (response: string): boolean =>
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
response
)
const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedin
return /You have signed in/gm.test(response)
}

View File

@@ -1,3 +1,4 @@
export * from './AuthManager'
export * from './isAuthorizeFormRequired'
export * from './isLoginRequired'
export * from './loginHeader'

97
src/auth/loginHeader.ts Normal file
View File

@@ -0,0 +1,97 @@
import { ServerType } from '@sasjs/utils/types'
import { getUserLanguage } from '../utils'
const enLoginSuccessHeader = 'You have signed in.'
export const defaultSuccessHeaderKey = 'default'
// The following headers provided by https://github.com/sasjs/adapter/issues/835#issuecomment-2177818601
export const loginSuccessHeaders: { [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,
[defaultSuccessHeaderKey]: enLoginSuccessHeader
}
/**
* Provides expected login header based on language settings of the browser.
* @returns - expected header as a string.
*/
export const getExpectedLogInSuccessHeader = (): string => {
// get default success header
let successHeader = loginSuccessHeaders[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 = loginSuccessHeaders[userLang]
// handle case when there is no exact match of the language code
if (!userLangSuccessHeader) {
// get all supported language codes
const headerLanguages = Object.keys(loginSuccessHeaders)
// 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 = loginSuccessHeaders[headerLanguage]
}
} else {
successHeader = userLangSuccessHeader
}
}
return successHeader
}
/**
* 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 - boolean indicating if Login success header is present.
*/
export const isLogInSuccessHeaderPresent = (
serverType: ServerType,
response: any
): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedIn
return new RegExp(getExpectedLogInSuccessHeader(), 'gm').test(response)
}

View File

@@ -1,17 +1,22 @@
/**
* @jest-environment jsdom
*/
import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types'
import axios from 'axios'
import {
mockedCurrentUserApi,
mockLoginAuthoriseRequiredResponse,
mockLoginSuccessResponse
mockLoginAuthoriseRequiredResponse
} from './mockResponses'
import { serialize } from '../../utils'
import * as openWebPageModule from '../openWebPage'
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
import * as verifySas9LoginModule from '../verifySas9Login'
import { RequestClient } from '../../request/RequestClient'
import { getExpectedLogInSuccessHeader } from '../'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -125,6 +130,7 @@ describe('AuthManager', () => {
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
@@ -133,8 +139,9 @@ describe('AuthManager', () => {
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
)
const loginResponse = await authManager.logIn(userName, password)
@@ -170,6 +177,7 @@ describe('AuthManager', () => {
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
@@ -178,8 +186,9 @@ describe('AuthManager', () => {
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
)
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
@@ -365,7 +374,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon`,
`${serverUrl}/SASLogon`,
'SASLogon',
{
width: 500,
@@ -409,7 +418,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon`,
`${serverUrl}/SASLogon`,
'SASLogon',
{
width: 500,
@@ -453,7 +462,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon`,
`${serverUrl}/SASLogon`,
'SASLogon',
{
width: 500,
@@ -497,7 +506,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon`,
`${serverUrl}/SASLogon`,
'SASLogon',
{
width: 500,

View File

@@ -0,0 +1,82 @@
/**
* @jest-environment jsdom
*/
import { ServerType } from '@sasjs/utils/types'
import {
loginSuccessHeaders,
isLogInSuccessHeaderPresent,
defaultSuccessHeaderKey
} from '../'
describe('isLogInSuccessHeaderPresent', () => {
let languageGetter: any
beforeEach(() => {
languageGetter = jest.spyOn(window.navigator, 'language', 'get')
})
it('should check SASVIYA and SAS9 login success header based on language preferences of the browser', () => {
// test SASVIYA server type
Object.keys(loginSuccessHeaders).forEach((key) => {
languageGetter.mockReturnValue(key)
expect(
isLogInSuccessHeaderPresent(
ServerType.SasViya,
loginSuccessHeaders[key]
)
).toBeTruthy()
})
// test SAS9 server type
Object.keys(loginSuccessHeaders).forEach((key) => {
languageGetter.mockReturnValue(key)
expect(
isLogInSuccessHeaderPresent(ServerType.Sas9, loginSuccessHeaders[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(
isLogInSuccessHeaderPresent(
ServerType.SasViya,
loginSuccessHeaders[short]
)
).toBeTruthy()
})
// test falling back to default language code
languageGetter.mockReturnValue('WRONG-LANGUAGE')
expect(
isLogInSuccessHeaderPresent(
ServerType.Sas9,
loginSuccessHeaders[defaultSuccessHeaderKey]
)
).toBeTruthy()
})
it('should check SASVJS login success header', () => {
expect(
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: true })
).toBeTruthy()
expect(
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: false })
).toBeFalsy()
expect(isLogInSuccessHeaderPresent(ServerType.Sasjs, undefined)).toBeFalsy()
})
})

View File

@@ -1,7 +1,6 @@
import { SasAuthResponse } from '@sasjs/utils/types'
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 mockAuthResponse: SasAuthResponse = {
access_token: 'acc355',

View File

@@ -3,6 +3,7 @@
*/
import { verifySas9Login } from '../verifySas9Login'
import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySas9Login', () => {
const serverUrl = 'http://test-server.com'
@@ -18,7 +19,9 @@ describe('verifySas9Login', () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
document: {
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
}
} as unknown as Window

View File

@@ -3,6 +3,7 @@
*/
import { verifySasViyaLogin } from '../verifySasViyaLogin'
import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySasViyaLogin', () => {
const serverUrl = 'http://test-server.com'
@@ -19,7 +20,9 @@ describe('verifySasViyaLogin', () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
document: {
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
}
} as unknown as Window

View File

@@ -1,4 +1,5 @@
import { delay } from '../utils'
import { getExpectedLogInSuccessHeader } from './'
export async function verifySas9Login(loginPopup: Window): Promise<{
isLoggedIn: boolean
@@ -6,13 +7,17 @@ export async function verifySas9Login(loginPopup: Window): Promise<{
let isLoggedIn = false
let startTime = new Date()
let elapsedSeconds = 0
do {
await delay(1000)
if (loginPopup.closed) break
isLoggedIn =
loginPopup.window.location.href.includes('SASLogon') &&
loginPopup.window.document.body.innerText.includes('You have signed in.')
loginPopup.window.document.body.innerText.includes(
getExpectedLogInSuccessHeader()
)
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)

View File

@@ -1,4 +1,5 @@
import { delay } from '../utils'
import { getExpectedLogInSuccessHeader } from './'
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
isLoggedIn: boolean
@@ -6,23 +7,32 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
let isLoggedIn = false
let startTime = new Date()
let elapsedSeconds = 0
do {
await delay(1000)
if (loginPopup.closed) break
isLoggedIn = isLoggedInSASVIYA()
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
let isAuthorized = false
startTime = new Date()
do {
await delay(1000)
if (loginPopup.closed) break
isAuthorized =
loginPopup.window.location.href.includes('SASLogon') ||
loginPopup.window.document.body?.innerText?.includes(
'You have signed in.'
getExpectedLogInSuccessHeader()
)
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isAuthorized && elapsedSeconds < 5 * 60)

View File

@@ -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

View File

@@ -20,3 +20,4 @@ export * from './serialize'
export * from './splitChunks'
export * from './validateInput'
export * from './getFormData'
export * from './getUserLanguage'