Validate persisted session info on both save and load

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-12-20 12:11:14 +01:00
parent c3a0c97461
commit 518ddc00bc
9 changed files with 156 additions and 31 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Schema-validate local storage cached session info on load
+2 -1
View File
@@ -42,7 +42,8 @@
"prop-types": "^15.7.2",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
"zen-observable": "^0.8.15",
"zod": "^3.11.6"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -14,25 +14,25 @@
* limitations under the License.
*/
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
ProfileInfo,
SessionApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import {
AuthSessionStore,
RefreshingAuthSessionManager,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { OAuthApiCreateOptions } from '../types';
import { OptionalRefreshSessionManagerMux } from '../../../../lib/AuthSessionManager/OptionalRefreshSessionManagerMux';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { OAuthApiCreateOptions } from '../types';
import { GithubSession, githubSessionSchema } from './types';
export type GithubAuthResponse = {
providerInfo: {
@@ -105,6 +105,7 @@ export default class GithubAuth implements OAuthApi, SessionApi {
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
}),
storageKey: `${provider.id}Session`,
schema: githubSessionSchema,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export * from './types';
export type { GithubSession } from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -15,6 +15,7 @@
*/
import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
import { z } from 'zod';
/**
* Session information for GitHub auth.
@@ -30,3 +31,25 @@ export type GithubSession = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
export const githubSessionSchema: z.ZodSchema<GithubSession> = z.object({
providerInfo: z.object({
accessToken: z.string(),
scopes: z.set(z.string()),
expiresAt: z.date().optional(),
}),
profile: z.object({
email: z.string().optional(),
displayName: z.string().optional(),
picture: z.string().optional(),
}),
backstageIdentity: z.object({
id: z.string(),
token: z.string(),
identity: z.object({
type: z.literal('user'),
userEntityRef: z.string(),
ownershipEntityRefs: z.array(z.string()),
}),
}),
});
@@ -14,24 +14,24 @@
* limitations under the License.
*/
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
ProfileInfo,
BackstageIdentity,
SessionState,
AuthRequestOptions,
ProfileInfoApi,
BackstageIdentity,
BackstageIdentityApi,
ProfileInfo,
ProfileInfoApi,
SessionApi,
SessionState,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { SamlSession } from './types';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { AuthApiCreateOptions } from '../types';
import { SamlSession, samlSessionSchema } from './types';
export type SamlAuthResponse = {
profile: ProfileInfo;
@@ -72,6 +72,7 @@ export default class SamlAuth
const authSessionStore = new AuthSessionStore<SamlSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
schema: samlSessionSchema,
});
return new SamlAuth(authSessionStore);
@@ -13,7 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
import { BackstageIdentity, ProfileInfo } from '@backstage/core-plugin-api';
import { z } from 'zod';
/**
* Session information for SAML auth.
@@ -25,3 +27,21 @@ export type SamlSession = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
export const samlSessionSchema: z.ZodSchema<SamlSession> = z.object({
userId: z.string(),
profile: z.object({
email: z.string().optional(),
displayName: z.string().optional(),
picture: z.string().optional(),
}),
backstageIdentity: z.object({
id: z.string(),
token: z.string(),
identity: z.object({
type: z.literal('user'),
userEntityRef: z.string(),
ownershipEntityRefs: z.array(z.string()),
}),
}),
});
@@ -13,11 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { withLogCollector } from '@backstage/test-utils';
import { z } from 'zod';
import { AuthSessionStore } from './AuthSessionStore';
import { SessionManager } from './types';
const defaultOptions = {
storageKey: 'my-key',
schema: z.any(),
sessionScopes: (session: string) => new Set(session.split(' ')),
};
@@ -147,4 +151,48 @@ describe('GheAuth AuthSessionStore', () => {
store.sessionState$();
expect(manager.sessionState$).toHaveBeenCalled();
});
it('should schema-validate stored data', async () => {
const manager = new MockManager();
const firstStore = new AuthSessionStore<boolean>({
manager,
storageKey: 'a',
schema: z.boolean(),
sessionScopes: () => new Set(),
});
const secondStore = new AuthSessionStore<number>({
manager,
storageKey: 'a',
schema: z.number(),
sessionScopes: () => new Set(),
});
firstStore.setSession(true);
await expect(firstStore.getSession({})).resolves.toBe(true);
await expect(
withLogCollector(async () => {
await expect(secondStore.getSession({})).resolves.toBeUndefined();
}),
).resolves.toMatchObject({
log: [
expect.stringContaining(
'Failed to load session from local storage because it did not conform to the expected schema',
),
],
});
await expect(
withLogCollector(async () => {
await secondStore.setSession('no' as any);
}),
).resolves.toMatchObject({
warn: [
expect.stringContaining(
'Failed to save session to local storage because it did not conform to the expected schema',
),
],
});
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ZodSchema } from 'zod';
import {
MutableSessionManager,
SessionScopesFunc,
@@ -27,6 +28,8 @@ type Options<T> = {
manager: MutableSessionManager<T>;
/** Storage key to use to store sessions */
storageKey: string;
/** The schema used to validate the stored data */
schema: ZodSchema<T>;
/** Used to get the scope of the session */
sessionScopes?: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed, defaults to never refresh */
@@ -42,6 +45,7 @@ type Options<T> = {
export class AuthSessionStore<T> implements MutableSessionManager<T> {
private readonly manager: MutableSessionManager<T>;
private readonly storageKey: string;
private readonly schema: ZodSchema<T>;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private readonly helper: SessionScopeHelper<T>;
@@ -49,12 +53,14 @@ export class AuthSessionStore<T> implements MutableSessionManager<T> {
const {
manager,
storageKey,
schema,
sessionScopes,
sessionShouldRefresh = () => false,
} = options;
this.manager = manager;
this.storageKey = storageKey;
this.schema = schema;
this.sessionShouldRefreshFunc = sessionShouldRefresh;
this.helper = new SessionScopeHelper({
sessionScopes,
@@ -104,7 +110,16 @@ export class AuthSessionStore<T> implements MutableSessionManager<T> {
}
return value;
});
return session;
try {
return this.schema.parse(session);
} catch (e) {
// eslint-disable-next-line no-console
console.log(
`Failed to load session from local storage because it did not conform to the expected schema, ${e}`,
);
throw e;
}
}
return undefined;
@@ -117,19 +132,30 @@ export class AuthSessionStore<T> implements MutableSessionManager<T> {
private saveSession(session: T | undefined) {
if (session === undefined) {
localStorage.removeItem(this.storageKey);
} else {
localStorage.setItem(
this.storageKey,
JSON.stringify(session, (_key, value) => {
if (value instanceof Set) {
return {
__type: 'Set',
__value: Array.from(value),
};
}
return value;
}),
);
return;
}
try {
this.schema.parse(session);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
`Failed to save session to local storage because it did not conform to the expected schema, ${e}`,
);
return;
}
localStorage.setItem(
this.storageKey,
JSON.stringify(session, (_key, value) => {
if (value instanceof Set) {
return {
__type: 'Set',
__value: Array.from(value),
};
}
return value;
}),
);
}
}