Merge pull request #1422 from spotify/mob/auth-std-profile
auth: refactor to include backstage identity and use more explicit types
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createApiRef } from '../ApiRef';
|
||||
import { ProfileInfo } from './auth';
|
||||
|
||||
/**
|
||||
* The Identity API used to identify and get information about the signed in user.
|
||||
@@ -29,6 +30,11 @@ export type IdentityApi = {
|
||||
*/
|
||||
getUserId(): string;
|
||||
|
||||
/**
|
||||
* The profile of the signed in user.
|
||||
*/
|
||||
getProfile(): ProfileInfo;
|
||||
|
||||
/**
|
||||
* An OpenID Connect ID Token which proves the identity of the signed in user.
|
||||
*
|
||||
|
||||
@@ -37,13 +37,13 @@ import { Observable } from '../..';
|
||||
*/
|
||||
export type OAuthScope = string | string[];
|
||||
|
||||
export type AccessTokenOptions = {
|
||||
export type AuthRequestOptions = {
|
||||
/**
|
||||
* If this is set to true, the user will not be prompted to log in,
|
||||
* and an empty access token will be returned if there is no existing session.
|
||||
* and an empty response will be returned if there is no existing session.
|
||||
*
|
||||
* This can be used to perform a check whether the user is logged in with a set of scopes,
|
||||
* or if you don't want to force a user to be logged in, but provide functionality if they already are.
|
||||
* 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
|
||||
*/
|
||||
@@ -88,7 +88,7 @@ export type OAuthApi = {
|
||||
*/
|
||||
getAccessToken(
|
||||
scope?: OAuthScope,
|
||||
options?: AccessTokenOptions,
|
||||
options?: AuthRequestOptions,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
@@ -97,29 +97,6 @@ export type OAuthApi = {
|
||||
logout(): Promise<void>;
|
||||
};
|
||||
|
||||
export type IdTokenOptions = {
|
||||
/**
|
||||
* If this is set to true, the user will not be prompted to log in,
|
||||
* and an empty id token 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;
|
||||
|
||||
/**
|
||||
* If this is set to true, the request will bypass the regular oauth login modal
|
||||
* and open the login popup directly.
|
||||
*
|
||||
* The method must be called synchronously from a user action for this to work in all browsers.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
instantPopup?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This API provides access to OpenID Connect credentials. It lets you request ID tokens,
|
||||
* which can be passed to backend services to prove the user's identity.
|
||||
@@ -136,7 +113,7 @@ export type OpenIdConnectApi = {
|
||||
* to log in. The returned promise will not resolve until the user has successfully logged in.
|
||||
* The returned promise can be rejected, but only if the user rejects the login request.
|
||||
*/
|
||||
getIdToken(options?: IdTokenOptions): Promise<string>;
|
||||
getIdToken(options?: AuthRequestOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* Log out the user's session. This will reload the page.
|
||||
@@ -144,38 +121,65 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* This API provides access to profile information of the user from an auth provider.
|
||||
*/
|
||||
export type ProfileInfoApi = {
|
||||
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
|
||||
/**
|
||||
* Get profile information for the user as supplied by this auth provider.
|
||||
*
|
||||
* If the optional flag is not set, a session is guaranteed to be returned, while if
|
||||
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
|
||||
*/
|
||||
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profile information of the user from an auth provider.
|
||||
* This API provides access to the user's identity within Backstage.
|
||||
*
|
||||
* An auth provider that implements this interface can be used to sign-in to backstage. It is
|
||||
* not intended to be used directly from a plugin, but instead serves as a connection between
|
||||
* this authentication method and the app's @IdentityApi
|
||||
*/
|
||||
export type BackstageIdentityApi = {
|
||||
/**
|
||||
* Get the user's identity within Backstage. This should normally not be called directly,
|
||||
* use the @IdentityApi instead.
|
||||
*
|
||||
* If the optional flag is not set, a session is guaranteed to be returned, while if
|
||||
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
|
||||
*/
|
||||
getBackstageIdentity(
|
||||
options?: AuthRequestOptions,
|
||||
): Promise<BackstageIdentity | undefined>;
|
||||
};
|
||||
|
||||
export type BackstageIdentity = {
|
||||
/**
|
||||
* The backstage user ID.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* An ID token that can be used to authenticate the user within Backstage.
|
||||
*/
|
||||
idToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profile information of the user.
|
||||
*/
|
||||
export type ProfileInfo = {
|
||||
/**
|
||||
* Email ID.
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* Display name that can be presented to the user.
|
||||
*/
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* URL to an avatar image of the user.
|
||||
*/
|
||||
@@ -207,7 +211,11 @@ export type SessionStateApi = {
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*/
|
||||
export const googleAuthApiRef = createApiRef<
|
||||
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionStateApi
|
||||
>({
|
||||
id: 'core.auth.google',
|
||||
description: 'Provides authentication towards Google APIs and identities',
|
||||
@@ -219,7 +227,9 @@ export const googleAuthApiRef = createApiRef<
|
||||
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
|
||||
* for a full list of supported scopes.
|
||||
*/
|
||||
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
|
||||
export const githubAuthApiRef = createApiRef<
|
||||
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi
|
||||
>({
|
||||
id: 'core.auth.github',
|
||||
description: 'Provides authentication towards Github APIs',
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('GithubAuth', () => {
|
||||
it('should get access token', async () => {
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: 'access-token' });
|
||||
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
|
||||
const githubAuth = new GithubAuth({ getSession } as any);
|
||||
|
||||
expect(await githubAuth.getAccessToken()).toBe('access-token');
|
||||
|
||||
@@ -19,9 +19,11 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { GithubSession } from './types';
|
||||
import {
|
||||
OAuthApi,
|
||||
AccessTokenOptions,
|
||||
SessionStateApi,
|
||||
SessionState,
|
||||
ProfileInfo,
|
||||
BackstageIdentity,
|
||||
AuthRequestOptions,
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
@@ -41,10 +43,13 @@ type CreateOptions = {
|
||||
};
|
||||
|
||||
export type GithubAuthResponse = {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
@@ -69,9 +74,14 @@ class GithubAuth implements OAuthApi, SessionStateApi {
|
||||
oauthRequestApi: oauthRequestApi,
|
||||
sessionTransform(res: GithubAuthResponse): GithubSession {
|
||||
return {
|
||||
accessToken: res.accessToken,
|
||||
scopes: GithubAuth.normalizeScope(res.scope),
|
||||
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
|
||||
...res,
|
||||
providerInfo: {
|
||||
accessToken: res.providerInfo.accessToken,
|
||||
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
|
||||
expiresAt: new Date(
|
||||
Date.now() + res.providerInfo.expiresInSeconds * 1000,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -79,7 +89,7 @@ class GithubAuth implements OAuthApi, SessionStateApi {
|
||||
const sessionManager = new StaticAuthSessionManager({
|
||||
connector,
|
||||
defaultScopes: new Set(['user']),
|
||||
sessionScopes: session => session.scopes,
|
||||
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
|
||||
});
|
||||
|
||||
return new GithubAuth(sessionManager);
|
||||
@@ -93,7 +103,7 @@ class GithubAuth implements OAuthApi, SessionStateApi {
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
|
||||
|
||||
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
|
||||
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
|
||||
const normalizedScopes = GithubAuth.normalizeScope(scope);
|
||||
const session = await this.sessionManager.getSession({
|
||||
...options,
|
||||
@@ -101,11 +111,25 @@ class GithubAuth implements OAuthApi, SessionStateApi {
|
||||
});
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (session) {
|
||||
return session.accessToken;
|
||||
return session.providerInfo.accessToken;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async getBackstageIdentity(
|
||||
options: AuthRequestOptions = {},
|
||||
): Promise<BackstageIdentity | undefined> {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
return session?.backstageIdentity;
|
||||
}
|
||||
|
||||
async getProfile(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
return session?.profile;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.sessionManager.removeSession();
|
||||
this.sessionStateTracker.setIsSignedId(false);
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ProfileInfo } from '../../..';
|
||||
import { BackstageIdentity } from '../../../definitions';
|
||||
|
||||
export type GithubSession = {
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
@@ -23,9 +23,9 @@ const PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
describe('GoogleAuth', () => {
|
||||
it('should get refreshed access token', async () => {
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture });
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ getSession } as any);
|
||||
|
||||
expect(await googleAuth.getAccessToken()).toBe('access-token');
|
||||
@@ -33,9 +33,9 @@ describe('GoogleAuth', () => {
|
||||
});
|
||||
|
||||
it('should get refreshed id token', async () => {
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ getSession } as any);
|
||||
|
||||
expect(await googleAuth.getIdToken()).toBe('id-token');
|
||||
@@ -43,9 +43,9 @@ describe('GoogleAuth', () => {
|
||||
});
|
||||
|
||||
it('should get optional id token', async () => {
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ getSession } as any);
|
||||
|
||||
expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token');
|
||||
@@ -58,9 +58,11 @@ describe('GoogleAuth', () => {
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
accessToken: 'access-token',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set([`${PREFIX}not-enough`]),
|
||||
providerInfo: {
|
||||
accessToken: 'access-token',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set([`${PREFIX}not-enough`]),
|
||||
},
|
||||
})
|
||||
.mockRejectedValue(error);
|
||||
const googleAuth = new GoogleAuth({ getSession } as any);
|
||||
@@ -77,17 +79,21 @@ describe('GoogleAuth', () => {
|
||||
|
||||
it('should wait for all session refreshes', async () => {
|
||||
const initialSession = {
|
||||
idToken: 'token1',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set(),
|
||||
providerInfo: {
|
||||
idToken: 'token1',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set(),
|
||||
},
|
||||
};
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(initialSession)
|
||||
.mockResolvedValue({
|
||||
idToken: 'token2',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set(),
|
||||
providerInfo: {
|
||||
idToken: 'token2',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set(),
|
||||
},
|
||||
});
|
||||
const googleAuth = new GoogleAuth({ getSession } as any);
|
||||
|
||||
@@ -95,7 +101,7 @@ describe('GoogleAuth', () => {
|
||||
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
|
||||
initialSession.expiresAt = thePast;
|
||||
initialSession.providerInfo.expiresAt = thePast;
|
||||
|
||||
const promise1 = googleAuth.getIdToken();
|
||||
const promise2 = googleAuth.getIdToken();
|
||||
|
||||
@@ -20,13 +20,13 @@ import { GoogleSession } from './types';
|
||||
import {
|
||||
OAuthApi,
|
||||
OpenIdConnectApi,
|
||||
IdTokenOptions,
|
||||
AccessTokenOptions,
|
||||
ProfileInfoApi,
|
||||
ProfileInfoOptions,
|
||||
ProfileInfo,
|
||||
SessionStateApi,
|
||||
SessionState,
|
||||
BackstageIdentityApi,
|
||||
AuthRequestOptions,
|
||||
BackstageIdentity,
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
@@ -46,11 +46,14 @@ type CreateOptions = {
|
||||
};
|
||||
|
||||
export type GoogleAuthResponse = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
@@ -62,7 +65,12 @@ const DEFAULT_PROVIDER = {
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
class GoogleAuth
|
||||
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
|
||||
implements
|
||||
OAuthApi,
|
||||
OpenIdConnectApi,
|
||||
ProfileInfoApi,
|
||||
BackstageIdentityApi,
|
||||
SessionStateApi {
|
||||
static create({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
@@ -78,11 +86,15 @@ class GoogleAuth
|
||||
oauthRequestApi: oauthRequestApi,
|
||||
sessionTransform(res: GoogleAuthResponse): GoogleSession {
|
||||
return {
|
||||
profile: res.profile,
|
||||
idToken: res.idToken,
|
||||
accessToken: res.accessToken,
|
||||
scopes: GoogleAuth.normalizeScopes(res.scope),
|
||||
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
|
||||
...res,
|
||||
providerInfo: {
|
||||
idToken: res.providerInfo.idToken,
|
||||
accessToken: res.providerInfo.accessToken,
|
||||
scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope),
|
||||
expiresAt: new Date(
|
||||
Date.now() + res.providerInfo.expiresInSeconds * 1000,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -94,9 +106,10 @@ class GoogleAuth
|
||||
`${SCOPE_PREFIX}userinfo.email`,
|
||||
`${SCOPE_PREFIX}userinfo.profile`,
|
||||
]),
|
||||
sessionScopes: session => session.scopes,
|
||||
sessionShouldRefresh: session => {
|
||||
const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000;
|
||||
sessionScopes: (session: GoogleSession) => session.providerInfo.scopes,
|
||||
sessionShouldRefresh: (session: GoogleSession) => {
|
||||
const expiresInSec =
|
||||
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
|
||||
return expiresInSec < 60 * 5;
|
||||
},
|
||||
});
|
||||
@@ -114,7 +127,7 @@ class GoogleAuth
|
||||
|
||||
async getAccessToken(
|
||||
scope?: string | string[],
|
||||
options?: AccessTokenOptions,
|
||||
options?: AuthRequestOptions,
|
||||
) {
|
||||
const normalizedScopes = GoogleAuth.normalizeScopes(scope);
|
||||
const session = await this.sessionManager.getSession({
|
||||
@@ -123,16 +136,16 @@ class GoogleAuth
|
||||
});
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (session) {
|
||||
return session.accessToken;
|
||||
return session.providerInfo.accessToken;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async getIdToken(options: IdTokenOptions = {}) {
|
||||
async getIdToken(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (session) {
|
||||
return session.idToken;
|
||||
return session.providerInfo.idToken;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -142,13 +155,18 @@ class GoogleAuth
|
||||
this.sessionStateTracker.setIsSignedId(false);
|
||||
}
|
||||
|
||||
async getProfile(options: ProfileInfoOptions = {}) {
|
||||
async getBackstageIdentity(
|
||||
options: AuthRequestOptions = {},
|
||||
): Promise<BackstageIdentity | undefined> {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
return session.profile;
|
||||
return session?.backstageIdentity;
|
||||
}
|
||||
|
||||
async getProfile(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
return session?.profile;
|
||||
}
|
||||
|
||||
static normalizeScopes(scopes?: string | string[]): Set<string> {
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ProfileInfo } from '../../../definitions';
|
||||
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
|
||||
|
||||
export type GoogleSession = {
|
||||
providerInfo: {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import React, {
|
||||
ComponentType,
|
||||
FC,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
@@ -258,24 +257,14 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
component: ComponentType<SignInPageProps>;
|
||||
children: ReactElement;
|
||||
}> = ({ component: Component, children }) => {
|
||||
const [done, setDone] = useState(false);
|
||||
const [result, setResult] = useState<SignInResult>();
|
||||
|
||||
const onResult = useCallback(
|
||||
(result: SignInResult) => {
|
||||
if (done) {
|
||||
throw new Error('Identity result callback was called twice');
|
||||
}
|
||||
this.identityApi.setSignInResult(result);
|
||||
setDone(true);
|
||||
},
|
||||
[done],
|
||||
);
|
||||
|
||||
if (done) {
|
||||
if (result) {
|
||||
this.identityApi.setSignInResult(result);
|
||||
return children;
|
||||
}
|
||||
|
||||
return <Component onResult={onResult} />;
|
||||
return <Component onResult={setResult} />;
|
||||
};
|
||||
|
||||
const AppRouter: FC<{}> = ({ children }) => {
|
||||
@@ -293,6 +282,10 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
if (!SignInPageComponent) {
|
||||
this.identityApi.setSignInResult({
|
||||
userId: 'guest',
|
||||
profile: {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IdentityApi } from '../apis';
|
||||
import { IdentityApi, ProfileInfo } from '../apis';
|
||||
import { SignInResult } from './types';
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,7 @@ import { SignInResult } from './types';
|
||||
export class AppIdentity implements IdentityApi {
|
||||
private hasIdentity = false;
|
||||
private userId?: string;
|
||||
private profile?: ProfileInfo;
|
||||
private idTokenFunc?: () => Promise<string>;
|
||||
private logoutFunc?: () => Promise<void>;
|
||||
|
||||
@@ -36,6 +37,15 @@ export class AppIdentity implements IdentityApi {
|
||||
return this.userId!;
|
||||
}
|
||||
|
||||
getProfile(): ProfileInfo {
|
||||
if (!this.hasIdentity) {
|
||||
throw new Error(
|
||||
'Tried to access IdentityApi profile before app was loaded',
|
||||
);
|
||||
}
|
||||
return this.profile!;
|
||||
}
|
||||
|
||||
async getIdToken(): Promise<string | undefined> {
|
||||
if (!this.hasIdentity) {
|
||||
throw new Error(
|
||||
@@ -55,6 +65,7 @@ export class AppIdentity implements IdentityApi {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// This is indirectly called by the sign-in page to continue into the app.
|
||||
setSignInResult(result: SignInResult) {
|
||||
if (this.hasIdentity) {
|
||||
return;
|
||||
@@ -62,8 +73,12 @@ export class AppIdentity implements IdentityApi {
|
||||
if (!result.userId) {
|
||||
throw new Error('Invalid sign-in result, userId not set');
|
||||
}
|
||||
if (!result.profile) {
|
||||
throw new Error('Invalid sign-in result, profile not set');
|
||||
}
|
||||
this.hasIdentity = true;
|
||||
this.userId = result.userId;
|
||||
this.profile = result.profile;
|
||||
this.idTokenFunc = result.getIdToken;
|
||||
this.logoutFunc = result.logout;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ComponentType } from 'react';
|
||||
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { ApiHolder } from '../apis';
|
||||
import { AppTheme, ConfigApi } from '../apis/definitions';
|
||||
import { AppTheme, ConfigApi, ProfileInfo } from '../apis/definitions';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
@@ -31,6 +31,9 @@ export type SignInResult = {
|
||||
* User ID that will be returned by the IdentityApi
|
||||
*/
|
||||
userId: string;
|
||||
|
||||
profile: ProfileInfo;
|
||||
|
||||
/**
|
||||
* Function used to retrieve an ID token for the signed in user.
|
||||
*/
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('showLoginPopup', () => {
|
||||
// None of these should be accepted
|
||||
listener({ source: popupMock } as MessageEvent);
|
||||
listener({ origin: 'my-origin' } as MessageEvent);
|
||||
listener({ data: { type: 'auth-result' } } as MessageEvent);
|
||||
listener({ data: { type: 'authorization_response' } } as MessageEvent);
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
@@ -68,26 +68,26 @@ describe('showLoginPopup', () => {
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: { type: 'not-auth-result', payload: {} },
|
||||
data: { type: 'not-auth-result', response: {} },
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
|
||||
'waiting',
|
||||
);
|
||||
|
||||
const myPayload = {};
|
||||
const myResponse = {};
|
||||
|
||||
// This should be accepted as a valid sessions response
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {
|
||||
type: 'auth-result',
|
||||
payload: myPayload,
|
||||
type: 'authorization_response',
|
||||
response: myResponse,
|
||||
},
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(payloadPromise).resolves.toBe(myPayload);
|
||||
await expect(payloadPromise).resolves.toBe(myResponse);
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
@@ -118,7 +118,7 @@ describe('showLoginPopup', () => {
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {
|
||||
type: 'auth-result',
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
message: 'NOPE',
|
||||
name: 'NopeError',
|
||||
|
||||
@@ -46,11 +46,11 @@ export type LoginPopupOptions = {
|
||||
|
||||
type AuthResult =
|
||||
| {
|
||||
type: 'auth-result';
|
||||
payload: any;
|
||||
type: 'authorization_response';
|
||||
response: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'auth-result';
|
||||
type: 'authorization_response';
|
||||
error: {
|
||||
name: string;
|
||||
message: string;
|
||||
@@ -58,12 +58,13 @@ type AuthResult =
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a popup pointing to a URL that starts an auth flow.
|
||||
* Show a popup pointing to a URL that starts an auth flow. Implementing the receiving
|
||||
* end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00
|
||||
*
|
||||
* The redirect handler of the flow should use postMessage to communicate back
|
||||
* to the app window. The message posted to the app must match the AuthResult type.
|
||||
*
|
||||
* The returned promise resolves to the contents of the message that was posted from the auth popup.
|
||||
* The returned promise resolves to the response of the message that was posted from the auth popup.
|
||||
*/
|
||||
export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -91,7 +92,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
|
||||
return;
|
||||
}
|
||||
const { data } = event;
|
||||
if (data.type !== 'auth-result') {
|
||||
if (data.type !== 'authorization_response') {
|
||||
return;
|
||||
}
|
||||
const authResult = data as AuthResult;
|
||||
@@ -103,7 +104,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
|
||||
// error.extra = authResult.error.extra;
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(authResult.payload);
|
||||
resolve(authResult.response);
|
||||
}
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -41,22 +41,29 @@ export const OAuthProviderSettings: FC<OAuthProviderSidebarProps> = ({
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let didCancel = false;
|
||||
|
||||
const checkSession = async () => {
|
||||
const session = await api.getAccessToken('', { optional: true });
|
||||
setSignedIn(!!session);
|
||||
if (!didCancel) {
|
||||
setSignedIn(!!session);
|
||||
}
|
||||
};
|
||||
let subscription: Subscription;
|
||||
const observeSession = () => {
|
||||
subscription = api
|
||||
.sessionState$()
|
||||
.subscribe((sessionState: SessionState) => {
|
||||
setSignedIn(sessionState === SessionState.SignedIn);
|
||||
if (!didCancel) {
|
||||
setSignedIn(sessionState === SessionState.SignedIn);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkSession();
|
||||
observeSession();
|
||||
return () => {
|
||||
didCancel = true;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -41,9 +41,13 @@ export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let didCancel = false;
|
||||
|
||||
const checkSession = async () => {
|
||||
const session = await api.getIdToken({ optional: true });
|
||||
setSignedIn(!!session);
|
||||
if (!didCancel) {
|
||||
setSignedIn(!!session);
|
||||
}
|
||||
};
|
||||
|
||||
let subscription: Subscription;
|
||||
@@ -51,13 +55,16 @@ export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
|
||||
subscription = api
|
||||
.sessionState$()
|
||||
.subscribe((sessionState: SessionState) => {
|
||||
setSignedIn(sessionState === SessionState.SignedIn);
|
||||
if (!didCancel) {
|
||||
setSignedIn(sessionState === SessionState.SignedIn);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkSession();
|
||||
observeSession();
|
||||
return () => {
|
||||
didCancel = true;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -14,19 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState, useRef, useEffect } from 'react';
|
||||
import React, { FC, useRef } from 'react';
|
||||
import { makeStyles, Avatar, Divider } from '@material-ui/core';
|
||||
import {
|
||||
ProfileInfo,
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
Subscription,
|
||||
SessionState,
|
||||
} from '@backstage/core-api';
|
||||
import { useApi, identityApiRef } from '@backstage/core-api';
|
||||
import { SidebarItem } from '../Items';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
avatar: {
|
||||
@@ -39,71 +32,26 @@ export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({
|
||||
open,
|
||||
setOpen,
|
||||
}) => {
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
const ref = useRef<Element>(); // for scrolling down when collapse item opens
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const classes = useStyles();
|
||||
const profile = useApi(identityApiRef).getProfile();
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!open);
|
||||
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
await googleAuth
|
||||
.getProfile({ optional: true })
|
||||
.then((userProfile?: ProfileInfo) => {
|
||||
setProfile(userProfile);
|
||||
});
|
||||
};
|
||||
|
||||
let subscription: Subscription;
|
||||
const observeSession = () => {
|
||||
subscription = googleAuth
|
||||
.sessionState$()
|
||||
.subscribe(async (sessionState: SessionState) => {
|
||||
if (sessionState === SessionState.SignedIn) {
|
||||
await fetchProfile();
|
||||
} else {
|
||||
setProfile(undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
observeSession();
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [googleAuth]);
|
||||
|
||||
// Handle main auth info that is shown on the collapsible SidebarItem
|
||||
let avatar;
|
||||
let displayName = 'Guest';
|
||||
if (profile) {
|
||||
const email = profile.email;
|
||||
const name = profile.name;
|
||||
const imageUrl = profile.picture;
|
||||
const emailTrimmed = email.split('@')[0];
|
||||
const displayEmail =
|
||||
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
|
||||
displayName = name ?? displayEmail;
|
||||
avatar = imageUrl
|
||||
? () => (
|
||||
<Avatar alt={displayName} src={imageUrl} className={classes.avatar} />
|
||||
)
|
||||
: () => <Avatar alt={displayName} className={classes.avatar} />;
|
||||
}
|
||||
const displayName = profile.displayName ?? profile.email;
|
||||
const SignInAvatar = () => (
|
||||
<Avatar src={profile.picture} className={classes.avatar}>
|
||||
{displayName[0]}
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider innerRef={ref} />
|
||||
<SidebarItem
|
||||
text={displayName}
|
||||
onClick={handleClick}
|
||||
icon={avatar || AccountCircleIcon}
|
||||
>
|
||||
<SidebarItem text={displayName} onClick={handleClick} icon={SignInAvatar}>
|
||||
{open ? <ExpandMore /> : <ExpandLess />}
|
||||
</SidebarItem>
|
||||
</>
|
||||
|
||||
@@ -56,6 +56,9 @@ const Component: ProviderComponent = ({ onResult }) => {
|
||||
const handleResult = ({ userId, idToken }: Data) => {
|
||||
onResult({
|
||||
userId,
|
||||
profile: {
|
||||
email: `${userId}@example.com`,
|
||||
},
|
||||
getIdToken: idToken ? async () => idToken : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,16 +18,7 @@ import React from 'react';
|
||||
import { Grid, Typography, Button } from '@material-ui/core';
|
||||
import { InfoCard } from '../InfoCard/InfoCard';
|
||||
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
|
||||
import {
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
errorApiRef,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
function parseUserId(profile: ProfileInfo) {
|
||||
return profile!.email.replace(/@.*/, '');
|
||||
}
|
||||
import { useApi, googleAuthApiRef, errorApiRef } from '@backstage/core-api';
|
||||
|
||||
const Component: ProviderComponent = ({ onResult }) => {
|
||||
const googleAuthApi = useApi(googleAuthApiRef);
|
||||
@@ -35,12 +26,17 @@ const Component: ProviderComponent = ({ onResult }) => {
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await googleAuthApi.getIdToken({ instantPopup: true });
|
||||
const identity = await googleAuthApi.getBackstageIdentity({
|
||||
instantPopup: true,
|
||||
});
|
||||
|
||||
const profile = await googleAuthApi.getProfile();
|
||||
|
||||
onResult({
|
||||
userId: parseUserId(profile!),
|
||||
getIdToken: () => googleAuthApi.getIdToken(),
|
||||
userId: identity!.id,
|
||||
profile: profile!,
|
||||
getIdToken: () =>
|
||||
googleAuthApi.getBackstageIdentity().then(i => i!.idToken),
|
||||
logout: async () => {
|
||||
await googleAuthApi.logout();
|
||||
},
|
||||
@@ -69,11 +65,21 @@ const Component: ProviderComponent = ({ onResult }) => {
|
||||
const loader: ProviderLoader = async apis => {
|
||||
const googleAuthApi = apis.get(googleAuthApiRef)!;
|
||||
|
||||
const profile = await googleAuthApi.getProfile({ optional: true });
|
||||
const identity = await googleAuthApi.getBackstageIdentity({
|
||||
optional: true,
|
||||
});
|
||||
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const profile = await googleAuthApi.getProfile();
|
||||
|
||||
return {
|
||||
userId: parseUserId(profile!),
|
||||
getIdToken: () => googleAuthApi.getIdToken(),
|
||||
userId: identity.id,
|
||||
profile: profile!,
|
||||
getIdToken: () =>
|
||||
googleAuthApi.getBackstageIdentity().then(i => i!.idToken),
|
||||
logout: async () => {
|
||||
await googleAuthApi.logout();
|
||||
},
|
||||
|
||||
@@ -19,6 +19,14 @@ import { Grid, Typography, Button } from '@material-ui/core';
|
||||
import { InfoCard } from '../InfoCard/InfoCard';
|
||||
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
|
||||
|
||||
const result = {
|
||||
userId: 'guest',
|
||||
profile: {
|
||||
email: 'guest@example.com',
|
||||
displayName: 'Guest',
|
||||
},
|
||||
};
|
||||
|
||||
const Component: ProviderComponent = ({ onResult }) => (
|
||||
<Grid item>
|
||||
<InfoCard
|
||||
@@ -27,7 +35,7 @@ const Component: ProviderComponent = ({ onResult }) => (
|
||||
<Button
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={() => onResult({ userId: 'guest' })}
|
||||
onClick={() => onResult(result)}
|
||||
>
|
||||
Enter
|
||||
</Button>
|
||||
@@ -45,7 +53,7 @@ const Component: ProviderComponent = ({ onResult }) => (
|
||||
);
|
||||
|
||||
const loader: ProviderLoader = async () => {
|
||||
return { userId: 'guest' };
|
||||
return result;
|
||||
};
|
||||
|
||||
export const guestProvider: SignInProvider = { Component, loader };
|
||||
|
||||
@@ -93,8 +93,9 @@ export const useSignInProviders = (
|
||||
}
|
||||
if (result) {
|
||||
handleWrappedResult(result);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
if (didCancel) {
|
||||
|
||||
@@ -22,6 +22,7 @@ builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
|
||||
|
||||
builder.add(identityApiRef, {
|
||||
getUserId: () => 'guest',
|
||||
getProfile: () => ({ email: 'guest@example.com' }),
|
||||
getIdToken: () => undefined,
|
||||
logout: async () => {},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user