diff --git a/packages/core/types/src/model/authentication.ts b/packages/core/types/src/model/authentication.ts index 1de1667..d3d0aaf 100644 --- a/packages/core/types/src/model/authentication.ts +++ b/packages/core/types/src/model/authentication.ts @@ -1,5 +1,24 @@ import * as S from "@effect/schema/Schema"; +/** + * Defines all the provider-specific information that is needed to authenticate + * with a specific provider. + */ +export const ProviderSpecificAuthenticationInfo = S.Union( + S.TaggedStruct("MSAL", { + account: S.Struct({ + homeAccountId: S.String.pipe(S.nonEmptyString()), + environment: S.String.pipe(S.nonEmptyString()), + tenantId: S.String.pipe(S.nonEmptyString()), + username: S.String.pipe(S.nonEmptyString()), + localAccountId: S.String.pipe(S.nonEmptyString()), + }), + }), +); +export type ProviderSpecificAuthenticationInfo = S.Schema.Type< + typeof ProviderSpecificAuthenticationInfo +>; + /** * Defines the result of a successfully authenticated user, with the information * that is needed to use a service that requires authentication. @@ -10,6 +29,12 @@ export const AuthenticationInfoSchema = S.Struct({ */ accessToken: S.String.pipe(S.nonEmptyString()), + /** + * Provider-specific information that is needed to authenticate with the + * provider. + */ + providerSpecific: ProviderSpecificAuthenticationInfo, + /** * Date in which the token expires. */ @@ -21,5 +46,7 @@ export type AuthenticationInfo = S.Schema.Type; * Defines the error that can occur when authenticating a user. */ export enum AuthenticationError { + InteractionRequired = "InteractionRequired", Unknown = "Unknown", + WrongCredentials = "WrongCredentials", } diff --git a/packages/core/types/src/services/authentication.ts b/packages/core/types/src/services/authentication.ts index 98b09a2..4ba7aa8 100644 --- a/packages/core/types/src/services/authentication.ts +++ b/packages/core/types/src/services/authentication.ts @@ -12,4 +12,13 @@ export type Authentication = { * Implements the authentication flow for a specific provider. */ connect: Effect; + + /** + * Attempts to silently authenticate the user with the cached credentials, + * if available. If the credentials are not available, the service will run + * the connect flow. + */ + connectSilent: ( + cachedCredentials: AuthenticationInfo, + ) => Effect; }; diff --git a/packages/infrastructure/onedrive-provider/src/msal-authentication.ts b/packages/infrastructure/onedrive-provider/src/msal-authentication.ts index 1b75356..5e985bc 100644 --- a/packages/infrastructure/onedrive-provider/src/msal-authentication.ts +++ b/packages/infrastructure/onedrive-provider/src/msal-authentication.ts @@ -1,9 +1,15 @@ -import { PublicClientApplication } from "@azure/msal-browser"; +import { + InteractionRequiredAuthError, + PublicClientApplication, + type AuthenticationResult as MsalAuthenticationResult, +} from "@azure/msal-browser"; import { addHours } from "@echo/core-dates"; import { AppConfig, type Authentication, AuthenticationError, + type AuthenticationInfo, + ProviderSpecificAuthenticationInfo, } from "@echo/core-types"; import { Context, Effect, Layer, Ref } from "effect"; @@ -33,29 +39,78 @@ export const MsalAuthenticationLive = Layer.effect( }), ); - return MsalAuthentication.of({ - connect: Effect.gen(function* () { - const app = yield* msalAppRef.get; - - yield* Effect.tryPromise({ - try: () => app.initialize(), - catch: () => AuthenticationError.Unknown, - }); - - const authResult = yield* Effect.tryPromise({ - try: () => app.loginPopup({ scopes: [...appConfig.graph.scopes] }), - catch: () => AuthenticationError.Unknown, - }); + const authRequest = { scopes: [...appConfig.graph.scopes] }; + const handleResponse = ( + authResult: MsalAuthenticationResult | null, + ): Effect.Effect => + Effect.gen(function* () { if (authResult) { return { accessToken: authResult.accessToken, expiresOn: authResult.expiresOn ?? addHours(new Date(), 2), + providerSpecific: ProviderSpecificAuthenticationInfo.make({ + account: authResult.account, + }), }; } return yield* Effect.fail(AuthenticationError.Unknown); - }), + }); + + const connect = Effect.gen(function* () { + const app = yield* msalAppRef.get; + + yield* Effect.tryPromise({ + try: () => app.initialize(), + catch: () => AuthenticationError.Unknown, + }); + + const authResult = yield* Effect.tryPromise({ + try: () => app.loginPopup(authRequest), + catch: () => AuthenticationError.Unknown, + }); + + return yield* handleResponse(authResult); + }); + + const connectSilent = (cachedCredentials: AuthenticationInfo) => + Effect.gen(function* () { + const app = yield* msalAppRef.get; + + if (cachedCredentials.providerSpecific._tag !== "MSAL") { + return yield* Effect.fail(AuthenticationError.WrongCredentials); + } + + yield* Effect.tryPromise({ + try: () => app.initialize(), + catch: () => AuthenticationError.Unknown, + }); + + return yield* Effect.tryPromise({ + try: () => + app.acquireTokenSilent({ + ...authRequest, + account: cachedCredentials.providerSpecific.account, + }), + catch: (e) => { + console.error(e); + return e instanceof InteractionRequiredAuthError + ? AuthenticationError.InteractionRequired + : AuthenticationError.Unknown; + }, + }).pipe( + Effect.tapError((e) => + Effect.logError(`Error while connecting silently: ${e}`), + ), + Effect.flatMap(handleResponse), + Effect.orElse(() => connect), + ); + }); + + return MsalAuthentication.of({ + connect, + connectSilent, }); }), ); diff --git a/packages/services/app-init/src/app-init.ts b/packages/services/app-init/src/app-init.ts index 82aa435..efb6c44 100644 --- a/packages/services/app-init/src/app-init.ts +++ b/packages/services/app-init/src/app-init.ts @@ -1,10 +1,8 @@ -import { isValidToken } from "@echo/core-auth"; import { AppInit, AvailableProviders, LocalStorage, MediaPlayerFactory, - MediaProviderFactory, MediaProviderMainThreadBroadcastChannel, type ProviderStartArgs, type BroadcastChannel, @@ -18,6 +16,7 @@ import { LazyLoadedMediaPlayer, LazyLoadedProvider, } from "@echo/services-bootstrap"; +import type { ILoadedProvider } from "@echo/services-bootstrap/src/loaders/provider"; import { Effect, Layer, Option } from "effect"; const make = Effect.gen(function* () { @@ -46,11 +45,11 @@ const make = Effect.gen(function* () { return yield* reinitializeProvider( providerStartArgs.value, - providerFactory.createMediaProvider, + providerFactory, mediaPlayerFactory.createMediaPlayer, broadcastChannel, activeMediaProviderCache, - ); + ).pipe(Effect.orElseSucceed(() => {})); }), ), ); @@ -76,7 +75,7 @@ const retrieveProviderArgs = ( const reinitializeProvider = ( startArgs: ProviderStartArgs, - createMediaProvider: MediaProviderFactory["createMediaProvider"], + providerFactory: ILoadedProvider, createMediaPlayer: MediaPlayerFactory["createMediaPlayer"], broadcastChannel: BroadcastChannel< MediaProviderBroadcastSchema["mainThread"] @@ -84,16 +83,18 @@ const reinitializeProvider = ( activeMediaProviderCache: IActiveMediaProviderCache, ) => Effect.gen(function* () { - // TODO: This should attempt to refresh the token if it's expired. - // TODO: Instead of ignoring the initialization with a warning, we should notify the user. - if (!isValidToken(startArgs.authInfo)) { - yield* Effect.logWarning( - `The retrieved token for ${startArgs.metadata.id} is invalid, ignoring initialization.`, + const authResult = yield* providerFactory.authentication + .connectSilent(startArgs.authInfo) + .pipe( + Effect.tapError((error) => + Effect.logWarning( + `Failed to silently authenticate with ${startArgs.metadata.id}, ignoring cached credentials. Error: ${error}`, + ), + ), ); - } - const mediaProvider = createMediaProvider(startArgs.authInfo); - const mediaPlayer = yield* createMediaPlayer(startArgs.authInfo); + const mediaProvider = providerFactory.createMediaProvider(authResult); + const mediaPlayer = yield* createMediaPlayer(authResult); yield* broadcastChannel.send("start", startArgs); yield* activeMediaProviderCache.add( @@ -101,6 +102,10 @@ const reinitializeProvider = ( mediaProvider, mediaPlayer, ); + + yield* Effect.log( + `Successfully reinitialized ${startArgs.metadata.id} provider`, + ); }); export const AppInitLive = Layer.effect(AppInit, make); diff --git a/packages/services/bootstrap/src/loaders/provider.ts b/packages/services/bootstrap/src/loaders/provider.ts index ae76a7b..eb2f056 100644 --- a/packages/services/bootstrap/src/loaders/provider.ts +++ b/packages/services/bootstrap/src/loaders/provider.ts @@ -7,15 +7,20 @@ import { } from "@echo/core-types"; import { AppConfigLive } from "../app-config"; +/** + * Represents the available data for a loaded provider. + */ +export type ILoadedProvider = { + metadata: ProviderMetadata; + authentication: Authentication; + createMediaProvider: MediaProviderFactory["createMediaProvider"]; +}; + /** * Service that can lazily load a media provider. */ export type ILazyLoadedProvider = { - readonly load: (metadata: ProviderMetadata) => Effect.Effect<{ - metadata: ProviderMetadata; - authentication: Authentication; - createMediaProvider: MediaProviderFactory["createMediaProvider"]; - }>; + readonly load: (metadata: ProviderMetadata) => Effect.Effect; }; /**