Skip to content

Commit

Permalink
Validate persisted session info on both save and load
Browse files Browse the repository at this point in the history
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
  • Loading branch information
freben committed Dec 20, 2021
1 parent c3a0c97 commit 518ddc0
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/wise-melons-hope.md
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---

Schema-validate local storage cached session info on load
3 changes: 2 additions & 1 deletion packages/core-app-api/package.json
Expand Up @@ -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",
Expand Down
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
});

Expand Down
Expand Up @@ -14,5 +14,5 @@
* limitations under the License.
*/

export * from './types';
export type { GithubSession } from './types';
export { default as GithubAuth } from './GithubAuth';
Expand Up @@ -15,6 +15,7 @@
*/

import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
import { z } from 'zod';

/**
* Session information for GitHub auth.
Expand All @@ -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()),
}),
}),
});
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Expand Up @@ -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.
Expand All @@ -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()),
}),
}),
});
Expand Up @@ -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(' ')),
};

Expand Down Expand Up @@ -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',
),
],
});
});
});
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { ZodSchema } from 'zod';
import {
MutableSessionManager,
SessionScopesFunc,
Expand All @@ -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 */
Expand All @@ -42,19 +45,22 @@ 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>;

constructor(options: Options<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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}),
);
}
}

0 comments on commit 518ddc0

Please sign in to comment.