UNPKG

@baqhub/sdk-react

Version:

The official React SDK for the BAQ federated app platform.

324 lines (323 loc) 14.2 kB
import { AbortedError, AnyRecord, AppRecord, AppScopes, Authentication, EntityRecord, Http, HttpStatusCode, RequestError, unreachable, } from "@baqhub/sdk"; import isEqual from "lodash/isEqual.js"; import { useCallback, useEffect, useMemo, useReducer } from "react"; import { abortable } from "../helpers/hooks.js"; import { InvalidActionError } from "../helpers/stateErrors.js"; import { AuthenticationStorage, } from "../helpers/storage.js"; import { buildFetcher } from "../helpers/suspense.js"; import { StoreIdentity } from "./store/storeIdentity.js"; // // State. // export var AuthenticationStatus; (function (AuthenticationStatus) { AuthenticationStatus["UNAUTHENTICATED"] = "unauthenticated"; AuthenticationStatus["AUTHENTICATED"] = "authenticated"; })(AuthenticationStatus || (AuthenticationStatus = {})); export var ConnectStatus; (function (ConnectStatus) { ConnectStatus["IDLE"] = "idle"; ConnectStatus["CONNECTING"] = "connecting"; ConnectStatus["WAITING_ON_FLOW"] = "waiting_on_flow"; })(ConnectStatus || (ConnectStatus = {})); export var ConnectError; (function (ConnectError) { ConnectError["ENTITY_NOT_FOUND"] = "entity_not_found"; ConnectError["BAD_APP_RECORD"] = "bad_app_record"; ConnectError["OTHER"] = "other"; })(ConnectError || (ConnectError = {})); // // Actions. // var UseAuthenticationActionType; (function (UseAuthenticationActionType) { UseAuthenticationActionType["INITIALIZE_SUCCESS"] = "INITIALIZE_SUCCESS"; UseAuthenticationActionType["CONNECT_START"] = "CONNECT_START"; UseAuthenticationActionType["CONNECT_SUCCESS"] = "CONNECT_SUCCESS"; UseAuthenticationActionType["CONNECT_FAILURE"] = "CONNECT_FAILURE"; UseAuthenticationActionType["AUTHORIZATION_SUCCESS"] = "AUTHORIZATION_SUCCESS"; UseAuthenticationActionType["AUTHORIZATION_FAILURE"] = "AUTHORIZATION_FAILURE"; UseAuthenticationActionType["DISCONNECT"] = "DISCONNECT"; })(UseAuthenticationActionType || (UseAuthenticationActionType = {})); // // Reducer. // function reducer(state, action) { switch (action.type) { case UseAuthenticationActionType.INITIALIZE_SUCCESS: if (state.status !== AuthenticationStatus.AUTHENTICATED || !("localState" in state)) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.AUTHENTICATED, identity: action.identity || state.identity, }; case UseAuthenticationActionType.CONNECT_START: if (state.status !== AuthenticationStatus.UNAUTHENTICATED || state.connectStatus !== ConnectStatus.IDLE) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.CONNECTING, entity: action.entity, }; case UseAuthenticationActionType.CONNECT_SUCCESS: if (state.status !== AuthenticationStatus.UNAUTHENTICATED || state.connectStatus !== ConnectStatus.CONNECTING) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.WAITING_ON_FLOW, flowUrl: action.flowUrl, localState: action.localState, }; case UseAuthenticationActionType.CONNECT_FAILURE: if (state.status !== AuthenticationStatus.UNAUTHENTICATED || state.connectStatus !== ConnectStatus.CONNECTING) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.IDLE, error: action.error, }; case UseAuthenticationActionType.AUTHORIZATION_SUCCESS: if (state.status !== AuthenticationStatus.UNAUTHENTICATED || state.connectStatus !== ConnectStatus.WAITING_ON_FLOW) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.AUTHENTICATED, localState: state.localState, authorizationId: action.authorizationId, identity: action.identity, }; case UseAuthenticationActionType.AUTHORIZATION_FAILURE: if (state.status !== AuthenticationStatus.UNAUTHENTICATED || state.connectStatus !== ConnectStatus.WAITING_ON_FLOW) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.IDLE, }; case UseAuthenticationActionType.DISCONNECT: if (state.status !== AuthenticationStatus.AUTHENTICATED) { throw new InvalidActionError(state, action); } return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.IDLE, }; default: unreachable(action); } } export function buildAuthentication(options) { const { storage, secureStorage, app } = options; const authenticationStorage = new AuthenticationStorage(storage, secureStorage); const findLocalState = buildFetcher(async () => { return authenticationStorage.read(); }); function buildInitialState(newAuthorizationId) { const localState = findLocalState(); if (!localState) { return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.IDLE, }; } const authorizationId = newAuthorizationId || localState.authorizationId; if (!authorizationId) { return { status: AuthenticationStatus.UNAUTHENTICATED, connectStatus: ConnectStatus.IDLE, }; } const localStateWithAuthorization = Authentication.complete(localState, authorizationId); return { status: AuthenticationStatus.AUTHENTICATED, authorizationId, localState, identity: StoreIdentity.new(localStateWithAuthorization), }; } function useAuthentication(options = {}) { const { appIconUrl, authorizationId } = options; const [authenticationState, dispatch] = useReducer(reducer, authorizationId || undefined, buildInitialState); // // API. // const onConnectRequest = useCallback((entity) => { dispatch({ type: UseAuthenticationActionType.CONNECT_START, entity, }); }, []); const waitingOnFlowLocalState = authenticationState.status === AuthenticationStatus.UNAUTHENTICATED && authenticationState.connectStatus === ConnectStatus.WAITING_ON_FLOW && authenticationState; const onAuthorizationResult = useCallback((authorizationId) => { if (!waitingOnFlowLocalState) { throw new Error("Authentication not waiting on flow."); } if (!authorizationId) { dispatch({ type: UseAuthenticationActionType.AUTHORIZATION_FAILURE }); return; } const { localState } = waitingOnFlowLocalState; const localStateWithAuthorization = { ...localState, authorizationId: authorizationId || localState.authorizationId, }; dispatch({ type: UseAuthenticationActionType.AUTHORIZATION_SUCCESS, identity: StoreIdentity.new(localStateWithAuthorization), authorizationId, }); }, [waitingOnFlowLocalState]); const onDisconnectRequest = useCallback(() => { dispatch({ type: UseAuthenticationActionType.DISCONNECT }); }, []); // // Clear local state. // const unauthenticatedLocalState = authenticationState.status === AuthenticationStatus.UNAUTHENTICATED && authenticationState.connectStatus === ConnectStatus.IDLE && authenticationState; useEffect(() => { if (!unauthenticatedLocalState) { return; } authenticationStorage.write(undefined); }, [unauthenticatedLocalState]); // // Initialization. // const initializingLocalState = authenticationState.status === AuthenticationStatus.AUTHENTICATED && "localState" in authenticationState && authenticationState; useEffect(() => { if (!initializingLocalState) { return; } const { authorizationId, localState, identity } = initializingLocalState; const { findClient } = identity; return abortable(async (signal) => { try { const client = findClient(localState.entityRecord.author.entity); const [serverEntityRecord, serverAppRecord] = await Promise.all([ client.getOwnRecord(AnyRecord, EntityRecord, localState.entityRecord.id, { signal }), client.getOwnRecord(AnyRecord, AppRecord, localState.appRecord.id, { signal, }), ]); const updatedState = { ...localState, authorizationId, entityRecord: serverEntityRecord.record, appRecord: serverAppRecord.record, }; // Check compatibility of app record scopes with what we require. if (!AppScopes.hasScopes(updatedState.appRecord, app.scopeRequest)) { dispatch({ type: UseAuthenticationActionType.DISCONNECT }); return; } const authorizationIdChanged = updatedState.authorizationId !== localState.authorizationId; const recordsChanged = !isEqual(updatedState.entityRecord, serverEntityRecord.record) || !isEqual(updatedState.appRecord, serverAppRecord.record); if (authorizationIdChanged || recordsChanged) { await authenticationStorage.write(updatedState); } if (!recordsChanged) { dispatch({ type: UseAuthenticationActionType.INITIALIZE_SUCCESS, identity: undefined, }); return; } await authenticationStorage.write(updatedState); dispatch({ type: UseAuthenticationActionType.INITIALIZE_SUCCESS, identity: StoreIdentity.new(updatedState), }); } catch (error) { if (error instanceof AbortedError || signal.aborted) { return; } if (error instanceof RequestError && [ HttpStatusCode.NOT_FOUND, HttpStatusCode.FORBIDDEN, HttpStatusCode.UNAUTHORIZED, ].includes(error.status)) { dispatch({ type: UseAuthenticationActionType.DISCONNECT }); return; } throw error; } }); }, [initializingLocalState]); // // Connection. // const appIconPromise = useMemo(() => { if (!appIconUrl) { return undefined; } if (authenticationState.status !== AuthenticationStatus.UNAUTHENTICATED) { return undefined; } return Http.download(appIconUrl).then(([_, b]) => b); }, [appIconUrl, authenticationState.status]); const connectingState = authenticationState.status === AuthenticationStatus.UNAUTHENTICATED && authenticationState.connectStatus === ConnectStatus.CONNECTING && authenticationState; useEffect(() => { if (!connectingState) { return; } console.log("Starting auth."); const { entity } = connectingState; return abortable(async (signal) => { try { const icon = await appIconPromise; const auth = await Authentication.register(entity, app, { icon, signal, }); const { flowUrl, state } = auth; // Save the local state. await authenticationStorage.write(state); // Hand it off to the authentication flow. dispatch({ type: UseAuthenticationActionType.CONNECT_SUCCESS, flowUrl, localState: state, }); } catch (error) { if (error instanceof AbortedError || signal.aborted) { return; } console.log("Sign-in error:", error); dispatch({ type: UseAuthenticationActionType.CONNECT_FAILURE, error: ConnectError.OTHER, }); } }); }, [appIconPromise, connectingState]); return { state: authenticationState, onConnectRequest, onAuthorizationResult, onDisconnectRequest, }; } return { useAuthentication }; }