Skip to content

Commit

Permalink
Add token refresher to supervise token expiration
Browse files Browse the repository at this point in the history
Fixes #8
  • Loading branch information
sleepyfran committed Dec 25, 2024
1 parent 611ee2e commit 000f3a1
Show file tree
Hide file tree
Showing 19 changed files with 356 additions and 61 deletions.
6 changes: 6 additions & 0 deletions packages/core/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ import type { AuthenticationInfo } from "@echo/core-types";
*/
export const isValidToken = (authInfo: AuthenticationInfo) =>
authInfo.expiresOn > new Date();

/**
* Checks whether the given authentication token is within 10 minutes of expiration.
*/
export const isTokenNearingExpiration = (authInfo: AuthenticationInfo) =>
authInfo.expiresOn < new Date(Date.now() + 1000 * 60 * 10);
16 changes: 16 additions & 0 deletions packages/core/types/src/model/broadcast-requests.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as S from "@effect/schema/Schema";
import {
ProviderId,
ProviderMetadata,
ProviderStartArgs,
ProviderStatus,
} from "./provider-metadata";
import { Serializable } from "@effect/schema";
import { AuthenticationInfo } from "./authentication";

/***
* Request to start a provider, normally handled by the media provider worker.
Expand Down Expand Up @@ -41,3 +43,17 @@ export class ProviderStatusChanged extends S.Class<ProviderStatusChanged>(
return ProviderStatusChanged;
}
}

/**
* Event emitted when the authentication info of a provider has been refreshed.
*/
export class ProviderAuthInfoChanged extends S.Class<ProviderAuthInfoChanged>(
"providerAuthInfoChanged",
)({
providerId: ProviderId,
authInfo: AuthenticationInfo,
}) {
get [Serializable.symbol]() {
return ProviderStatusChanged;
}
}
24 changes: 12 additions & 12 deletions packages/core/types/src/services/active-media-provider-cache.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { Effect, Option, Stream } from "effect";
import type { ProviderId, ProviderMetadata } from "../model";
import type {
AuthenticationInfo,
ProviderId,
ProviderMetadata,
} from "../model";
import type { MediaPlayer, MediaProvider } from "./media-provider";
import type { Authentication } from "./authentication";

export type ProviderWithMetadata = {
readonly lastAuthInfo: AuthenticationInfo;
readonly metadata: ProviderMetadata;
readonly authentication: Authentication;
readonly provider: MediaProvider;
readonly player: MediaPlayer;
};
Expand All @@ -24,21 +31,14 @@ export type IActiveMediaProviderCache = {
* listen to the provider's state changes and remove it from the cache once
* it becomes inactive.
*/
readonly add: (
metadata: ProviderMetadata,
provider: MediaProvider,
player: MediaPlayer,
) => Effect.Effect<void>;
readonly add: (args: ProviderWithMetadata) => Effect.Effect<void>;

/**
* Returns a media provider, if it is currently active, or none otherwise.
*/
readonly get: (providerId: ProviderId) => Effect.Effect<
Option.Option<{
provider: MediaProvider;
player: MediaPlayer;
}>
>;
readonly get: (
providerId: ProviderId,
) => Effect.Effect<Option.Option<ProviderWithMetadata>>;

/**
* Returns all currently active media providers.
Expand Down
46 changes: 43 additions & 3 deletions packages/core/types/src/services/authentication.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ProviderId } from "../model";
import type {
AuthenticationError,
AuthenticationInfo,
} from "../model/authentication";
import type { Effect } from "effect/Effect";
import { Effect, Option } from "effect";

/**
* Service that can connect to an authentication provider to authenticate the user.
Expand All @@ -11,7 +12,7 @@ export type Authentication = {
/**
* Implements the authentication flow for a specific provider.
*/
connect: Effect<AuthenticationInfo, AuthenticationError>;
connect: Effect.Effect<AuthenticationInfo, AuthenticationError>;

/**
* Attempts to silently authenticate the user with the cached credentials,
Expand All @@ -20,5 +21,44 @@ export type Authentication = {
*/
connectSilent: (
cachedCredentials: AuthenticationInfo,
) => Effect<AuthenticationInfo, AuthenticationError>;
) => Effect.Effect<AuthenticationInfo, AuthenticationError>;
};

/**
* Service that can hold a cache of the currently authenticated providers. This
* should be the source of truth for the authentication information.
*/
export type IAuthenticationCache = {
/**
* Retrieves the cached authentication info for a given provider.
*/
get: (
provider: ProviderId,
) => Effect.Effect<Option.Option<AuthenticationInfo>>;
};

/**
* Tag to identify the AuthenticationCache service.
*/
export class AuthenticationCache extends Effect.Tag(
"@echo/core-types/AuthenticationCache",
)<AuthenticationCache, IAuthenticationCache>() {}

/**
* Service that supervises the authentication info and triggers the refresh
* process when the auth of a certain provider is about to expire.
*/
export type IAuthenticationRefresher = {
/**
* Starts listening to provider start events and schedules the refresh of the
* authentication info when the token is about to expire.
*/
start: Effect.Effect<void>;
};

/**
* Tag to identify the AuthenticationRefresher service.
*/
export class AuthenticationRefresher extends Effect.Tag(
"@echo/core-types/AuthenticationRefresher",
)<AuthenticationRefresher, IAuthenticationRefresher>() {}
2 changes: 1 addition & 1 deletion packages/core/types/src/services/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Context, Scope, type Effect, type Stream } from "effect";
/**
* Defines all available channels.
*/
export type ChannelName = "mediaProvider";
export type ChannelName = "mediaProvider" | "authentication";

/**
* Defines a service that can act as the main message hub of the application,
Expand Down
18 changes: 15 additions & 3 deletions packages/infrastructure/onedrive-provider/src/onedrive-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { MediaProviderFactory, ProviderType } from "@echo/core-types";
import { Effect, Layer } from "effect";
import {
AuthenticationCache,
FileBasedProviderId,
MediaProviderFactory,
ProviderType,
} from "@echo/core-types";
import { Effect, Layer, Option } from "effect";
import { MsalAuthentication } from "./msal-authentication";
import { Client, type ClientOptions } from "@microsoft/microsoft-graph-client";
import { createListRoot } from "./apis/list-root.graph-api";
Expand All @@ -16,14 +21,21 @@ import { createFileUrlById } from "./apis/file-url-by-id.graph-api";
export const OneDriveProviderLive = Layer.effect(
MediaProviderFactory,
Effect.gen(function* () {
const authCache = yield* AuthenticationCache;
const msalAuth = yield* MsalAuthentication;

return MediaProviderFactory.of({
authenticationProvider: Effect.succeed(msalAuth),
createMediaProvider: (authInfo) => {
const options: ClientOptions = {
authProvider: {
getAccessToken: () => Promise.resolve(authInfo.accessToken),
getAccessToken: () =>
Effect.runPromise(
authCache.get(FileBasedProviderId.OneDrive).pipe(
Effect.map(Option.getOrElse(() => authInfo)),
Effect.map((authInfo) => authInfo.accessToken),
),
),
},
};

Expand Down
17 changes: 6 additions & 11 deletions packages/services/active-media-provider-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,15 @@ const makeActiveMediaProviderCache = Effect.gen(function* () {
);

return ActiveMediaProviderCache.of({
add: (metadata, provider, player) =>
add: (args) =>
Ref.update(providerByIdRef, (current) => {
const updatedMap = new Map(current);
updatedMap.set(metadata.id, {
metadata,
provider,
player,
});
updatedMap.set(args.metadata.id, args);
return updatedMap;
}).pipe(
Effect.andThen(Effect.log(`Added provider ${metadata.id} to cache`)),
Effect.andThen(
Effect.log(`Added provider ${args.metadata.id} to cache`),
),
),
get: (providerId) =>
Ref.get(providerByIdRef).pipe(
Expand All @@ -61,10 +59,7 @@ const makeActiveMediaProviderCache = Effect.gen(function* () {
return Option.none();
}

return Option.some({
provider: cachedProvider.provider,
player: cachedProvider.player,
});
return Option.some(cachedProvider);
}),
),
getAll: Effect.gen(function* () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,7 @@ export const addProviderWorkflow = Machine.makeWith<MachineState>()(
ProviderStartArgs,
request.startArgs,
);
yield* activeMediaProviderCache.add(
request.providerWithMetadata.metadata,
request.providerWithMetadata.provider,
request.providerWithMetadata.player,
);
yield* activeMediaProviderCache.add(request.providerWithMetadata);

return [{}, { _tag: "Done" as const }];
}),
Expand Down Expand Up @@ -179,7 +175,9 @@ export const addProviderWorkflow = Machine.makeWith<MachineState>()(
yield* state.loadedProvider.createMediaPlayer(authInfo);

const providerWithMetadata: ProviderWithMetadata = {
lastAuthInfo: authInfo,
metadata: state.loadedProvider.metadata,
authentication: state.loadedProvider.authentication,
provider: mediaProvider,
player: mediaPlayer,
};
Expand Down
22 changes: 15 additions & 7 deletions packages/services/app-init/src/app-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
StartProvider,
Player,
type IPlayer,
AuthenticationRefresher,
} from "@echo/core-types";
import {
LazyLoadedMediaPlayer,
Expand All @@ -25,6 +26,7 @@ import { initializeWorkers } from "@echo/services-bootstrap-workers";

const make = Effect.gen(function* () {
const activeMediaProviderCache = yield* ActiveMediaProviderCache;
const authRefresher = yield* AuthenticationRefresher;
const broadcaster = yield* Broadcaster;
const lazyLoadedProvider = yield* LazyLoadedProvider;
const lazyLoaderMediaPlayer = yield* LazyLoadedMediaPlayer;
Expand All @@ -45,9 +47,14 @@ const make = Effect.gen(function* () {

yield* mediaProviderArgsStorage.keepInSync.pipe(
Scope.extend(globalScope),
Effect.forkIn(globalScope),
);
yield* authRefresher.start.pipe(Effect.forkIn(globalScope));

yield* syncPageTitleWithPlayer(player).pipe(Scope.extend(globalScope));
yield* syncPageTitleWithPlayer(player).pipe(
Scope.extend(globalScope),
Effect.forkIn(globalScope),
);

const allProviderStates = yield* retrieveAllProviderArgs(localStorage);

Expand Down Expand Up @@ -128,11 +135,13 @@ const reinitializeProvider = (
},
}),
);
yield* activeMediaProviderCache.add(
startArgs.metadata,
mediaProvider,
mediaPlayer,
);
yield* activeMediaProviderCache.add({
lastAuthInfo: startArgs.authInfo,
metadata: startArgs.metadata,
authentication: providerFactory.authentication,
provider: mediaProvider,
player: mediaPlayer,
});

yield* Effect.log(
`Successfully reinitialized ${startArgs.metadata.id} provider`,
Expand All @@ -158,7 +167,6 @@ const syncPageTitleWithPlayer = (player: IPlayer) =>
Match.exhaustive,
),
),
Effect.forkScoped,
);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/services/bootstrap-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
"@echo/services-player": "^1.0.0",
"effect": "^3.8.3"
}
}
}
3 changes: 2 additions & 1 deletion packages/services/bootstrap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
"@echo/infrastructure-spotify-provider": "^1.0.0",
"@echo/services-active-media-provider-cache": "^1.0.0",
"@echo/services-media-provider-status": "^1.0.0",
"@echo/services-reauthentication": "^1.0.0",
"@echo/workers-media-provider": "^1.0.0",
"@effect/platform": "^0.66.2",
"effect": "^3.8.3"
}
}
}
17 changes: 12 additions & 5 deletions packages/services/bootstrap/src/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,24 @@ import {
} from "@echo/services-media-provider-status";
import { BrowserLocalStorageLive } from "@echo/infrastructure-browser-local-storage";
import { SpotifyArtistImageProvider } from "@echo/infrastructure-spotify-artist-image-provider";
import {
AuthenticationCacheLive,
AuthenticationRefresherLive,
} from "@echo/services-reauthentication";

/**
* Exports a layer that can provide all dependencies that are needed in the
* main thread (web-app).
*/
export const MainLive = ActiveMediaProviderCacheLive.pipe(
Layer.provideMerge(MediaProviderArgStorageLive),
export const MainLive = MediaProviderArgStorageLive.pipe(
Layer.provideMerge(MediaProviderStatusLive),
Layer.provideMerge(BroadcasterLive),
Layer.provideMerge(BroadcastListenerLive),
Layer.provideMerge(LazyLoadedProviderLive),
Layer.provideMerge(LazyLoadedMediaPlayerLive),
Layer.provideMerge(AuthenticationRefresherLive),
Layer.provideMerge(AuthenticationCacheLive),
Layer.provideMerge(ActiveMediaProviderCacheLive),
Layer.provideMerge(BroadcasterLive),
Layer.provideMerge(BroadcastListenerLive),
Layer.provideMerge(BrowserLocalStorageLive),
Layer.provideMerge(BrowserCryptoLive),
Layer.provideMerge(DexieDatabaseLive),
Expand All @@ -41,9 +47,10 @@ export const MainLive = ActiveMediaProviderCacheLive.pipe(
*/
export const WorkerLive = MediaProviderStatusLive.pipe(
Layer.provideMerge(BroadcasterLive),
Layer.provideMerge(BroadcastListenerLive),
Layer.provideMerge(BrowserCryptoLive),
Layer.provideMerge(LazyLoadedProviderLive),
Layer.provideMerge(AuthenticationCacheLive),
Layer.provideMerge(BroadcastListenerLive),
Layer.provideMerge(DexieDatabaseLive),
Layer.provideMerge(MmbMetadataProviderLive),
Layer.provideMerge(SpotifyArtistImageProvider),
Expand Down
Loading

0 comments on commit 000f3a1

Please sign in to comment.