Auth state working

This commit is contained in:
Raghunandan
2020-06-02 17:10:46 +02:00
parent c177c050c4
commit 578760956f
11 changed files with 334 additions and 27 deletions
+27 -1
View File
@@ -143,6 +143,30 @@ export type OpenIdConnectApi = {
logout(): Promise<void>;
};
export type ProfileInfoOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty profile will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
};
export type ProfileInfoApi = {
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
};
export type ProfileInfo = {
provider: string;
email: string;
name?: string;
picture?: string;
};
/**
* Provides authentication towards Google APIs and identities.
*
@@ -151,7 +175,9 @@ export type OpenIdConnectApi = {
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef = createApiRef<OAuthApi & OpenIdConnectApi>({
export const googleAuthApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi
>({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
});
@@ -22,6 +22,9 @@ import {
OpenIdConnectApi,
IdTokenOptions,
AccessTokenOptions,
ProfileInfoApi,
ProfileInfoOptions,
ProfileInfo,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
@@ -39,6 +42,7 @@ type CreateOptions = {
};
export type GoogleAuthResponse = {
profile: any;
accessToken: string;
idToken: string;
scope: string;
@@ -53,7 +57,7 @@ const DEFAULT_PROVIDER = {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth implements OAuthApi, OpenIdConnectApi {
class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
static create({
apiOrigin,
basePath,
@@ -69,6 +73,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GoogleAuthResponse): GoogleSession {
return {
profile: res.profile,
idToken: res.idToken,
accessToken: res.accessToken,
scopes: GoogleAuth.normalizeScopes(res.scope),
@@ -123,6 +128,14 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
await this.sessionManager.removeSession();
}
async getProfile(options: ProfileInfoOptions = {}) {
const session = await this.sessionManager.getSession(options);
if (!session) {
return undefined;
}
return session.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { ProfileInfo } from '../../../definitions';
export type GoogleSession = {
profile: ProfileInfo;
idToken: string;
accessToken: string;
scopes: Set<string>;
@@ -117,6 +117,10 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
async getCurrentSession() {
return this.currentSession;
}
private async collapsedSessionRefresh(): Promise<T> {
if (this.refreshPromise) {
return this.refreshPromise;
@@ -14,48 +14,254 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React, { FC, useState, useEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { sidebarConfig } from './config';
import { Avatar, Typography } from '@material-ui/core';
import {
Avatar,
ListItem,
ListItemAvatar,
ListItemText,
Popover,
List,
ListItemIcon,
ListItemSecondaryAction,
IconButton,
Tooltip,
} from '@material-ui/core';
import { blueGrey } from '@material-ui/core/colors';
import { useSetState } from 'react-use';
import { Skeleton } from '@material-ui/lab';
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core';
import LogoutIcon from '@material-ui/icons/PowerSettingsNew';
import ControlPointIcon from '@material-ui/icons/ControlPoint';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
const useStyles = makeStyles<Theme>(() => {
const useStyles = makeStyles<Theme>(theme => {
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
return {
root: {
width: drawerWidthOpen,
display: 'flex',
alignItems: 'center',
color: '#b5b5b5',
paddingLeft: 18,
paddingTop: 14,
paddingBottom: 14,
color: '#b5b5b5',
},
avatar: {
width: userBadgeDiameter,
height: userBadgeDiameter,
marginRight: 8,
},
purple: {
color: theme.palette.getContrastText(blueGrey[500]),
backgroundColor: blueGrey[500],
},
};
});
type Props = {
imageUrl: string;
name: string;
hideName?: boolean;
email: string;
imageUrl?: string;
name?: string;
collapsedMode?: boolean;
};
export const LoggedUserBadge: FC<Props> = ({
imageUrl,
name,
hideName = false,
email,
collapsedMode = false,
}) => {
const [state, setState] = useSetState({
open: false,
anchorEl: null,
});
const googleAuth = useApi(googleAuthApiRef);
const googleLogin = useGoogleLoginState(state.open);
const handleOpen = (event: {
preventDefault: () => void;
currentTarget: any;
}) => {
// This prevents ghost click.
event.preventDefault();
setState({
open: true,
anchorEl: event.currentTarget,
});
};
const handleClose = () => {
setState({
open: false,
});
};
const handleGoogleSignIn = () => {
googleAuth.getIdToken();
handleClose();
};
const handleGoogleSignOut = () => {
googleAuth.logout();
};
const classes = useStyles();
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
const displayName = name ?? displayEmail;
return (
<div className={classes.root}>
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
</div>
<>
<ListItem className={classes.root} onClick={handleOpen}>
<ListItemAvatar>
{imageUrl ? (
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
) : (
<Avatar
alt={name}
className={`${classes.avatar} ${classes.purple}`}
>
{avatarFallback[0]}
</Avatar>
)}
</ListItemAvatar>
{!collapsedMode && <ListItemText primary={displayName} />}
</ListItem>
<Popover
transitionDuration={0}
open={state.open}
anchorEl={state.anchorEl}
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
onClose={handleClose}
>
<List dense>
<SessionListItem
loading={googleLogin.loading}
title="Google"
icon={AccountCircleIcon}
user={
googleLogin.isLoggedIn && {
id: googleLogin.profile?.email,
avatarUrl: googleLogin.profile?.picture ?? '',
avatarAlt:
googleLogin.profile?.picture ?? googleLogin.profile?.email,
}
}
onSignIn={handleGoogleSignIn}
onSignOut={handleGoogleSignOut}
/>
</List>
</Popover>
</>
);
};
const SessionListItem: FC<{
loading: boolean;
title: string;
icon: any;
user: any;
onSignIn: Function;
onSignOut: Function;
}> = ({ loading, title, icon, user, onSignIn, onSignOut, ...props }) => {
if (loading) {
return (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>
<Skeleton variant="circle" width={40} height={40} />
</ListItemIcon>
<ListItemText
primary={<Skeleton component="span" width={120} />}
secondary={<Skeleton component="span" width={60} />}
/>
<ListItemSecondaryAction>
<IconButton>
<Skeleton variant="circle" width={24} height={24} />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
}
if (!user) {
return (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
<ListItemText primary="Sign In" secondary={title} />
<ListItemSecondaryAction>
<Tooltip
title={`Sign in with ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignIn()}>
<ControlPointIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
const { id, avatarUrl, avatarAlt } = user;
return (
<ListItem {...props}>
<ListItemAvatar>
<Avatar src={avatarUrl} alt={avatarAlt}>
{avatarAlt && avatarAlt[0].toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText primary={id} secondary={title} />
<ListItemSecondaryAction>
<Tooltip
title={`Sign out from ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignOut()}>
<LogoutIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
const useGoogleLoginState = (open: boolean) => {
const googleAuth = useApi(googleAuthApiRef);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<ProfileInfo>();
useEffect(() => {
if (!open) {
return;
}
let didCancel = false;
googleAuth.getProfile().then(profile => {
if (didCancel) {
return;
}
setProfile(profile);
setLoading(false);
});
return () => {
didCancel = true;
};
}, [open]);
if (loading) {
return { loading: true };
}
return { loading: false, isLoggedIn: !!profile, profile };
};
+22 -9
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC, useContext } from 'react';
import React, { FC, useContext, useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core';
import People from '@material-ui/icons/People';
import { SidebarContext } from './config';
@@ -23,6 +23,7 @@ import { LoggedUserBadge } from './LoggedUserBadge';
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow';
import { BackstageTheme } from '@backstage/theme';
import { SidebarPinStateContext } from './Page';
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core';
const ARROW_BUTTON_SIZE = 20;
const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
@@ -58,18 +59,30 @@ export const SidebarUserBadge: FC<{}> = () => {
SidebarPinStateContext,
);
const classes = useStyles({ isPinned });
const googleAuth = useApi(googleAuthApiRef);
const [profile, setProfile] = useState<ProfileInfo>();
useEffect(() => {
//TODO(soapraj): How to observe if the user is logged in
//TODO(soapraj): Enumerate all the providers supported by the app and let user log in from here
googleAuth.getProfile({ optional: true }).then(googleProfile => {
setProfile(googleProfile);
});
}, [googleAuth]);
const isUserLoggedIn = false;
return (
<div className={classes.root}>
{isUserLoggedIn ? (
<LoggedUserBadge
imageUrl="https://via.placeholder.com/200/200"
name="Victor Viale"
hideName={!isOpen}
/>
{profile ? (
<>
<LoggedUserBadge
email={profile.email}
imageUrl={profile.picture}
name={profile.name}
collapsedMode={!isOpen}
/>
</>
) : (
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
<SidebarItem icon={People} text="" disableSelected />
)}
{isOpen && (
<button
+3 -1
View File
@@ -34,7 +34,9 @@
"passport-google-oauth20": "^2.0.0",
"passport-saml": "^1.3.3",
"winston": "^3.2.1",
"yn": "^4.0.0"
"yn": "^4.0.0",
"jwt-decode": "2.2.0",
"@types/jwt-decode": "2.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
@@ -199,6 +199,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
async logout(req: express.Request, res: express.Response): Promise<any> {
//TODO(soapraj): Logout doesnt work yet
if (!ensuresXRequestedWith(req)) {
return res.status(401).send('Invalid X-Requested-With header');
}
@@ -233,6 +234,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
// get new access_token
const refreshInfo = await this.providerHandlers.refresh(
this.provider,
refreshToken,
scope,
);
@@ -15,6 +15,7 @@
*/
import express from 'express';
import jwtDecoder from 'jwt-decode';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import {
executeFrameHandlerStrategy,
@@ -27,6 +28,7 @@ import {
AuthInfoPrivate,
RedirectInfo,
AuthProviderConfig,
AuthInfoWithProfile,
} from '../types';
import { OAuthProvider } from '../OAuthProvider';
@@ -73,18 +75,37 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
return await executeFrameHandlerStrategy(req, this._strategy);
}
async refresh(refreshToken: string, scope: string): Promise<AuthInfoBase> {
async refresh(
provider: string,
refreshToken: string,
scope: string,
): Promise<AuthInfoWithProfile> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
// TODO(soapraj): check what happens when you return undefined
let profile;
try {
const { email, name, picture } = jwtDecoder(params.id_token);
profile = {
provider,
email,
name,
picture,
};
} catch (e) {
console.error('Failed to parse id token and get profile info');
}
return {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
profile,
};
}
}
+9 -2
View File
@@ -26,7 +26,7 @@ export type AuthProviderConfig = {
export interface OAuthProviderHandlers {
start(req: express.Request, options: any): Promise<any>;
handler(req: express.Request): Promise<any>;
refresh?(refreshToken: string, scope: string): Promise<any>;
refresh?(provider: string, refreshToken: string, scope: string): Promise<any>;
logout?(): Promise<any>;
}
@@ -49,7 +49,14 @@ export type AuthInfoBase = {
};
export type AuthInfoWithProfile = AuthInfoBase & {
profile: passport.Profile;
profile:
| {
provider: string;
email: string;
name?: string;
picture?: string;
}
| undefined;
};
export type AuthInfoPrivate = {
+10
View File
@@ -3705,6 +3705,11 @@
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
"@types/jwt-decode@2.2.1":
version "2.2.1"
resolved "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2"
integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==
"@types/lodash@^4.14.151":
version "4.14.155"
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
@@ -11900,6 +11905,11 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3:
array-includes "^3.0.3"
object.assign "^4.1.0"
jwt-decode@2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
keyv@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"