Skip to content

Refactor cache handling and code callbacks #129

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ npm install prismarine-auth
### Authflow
**Parameters**
- username? {String} - Username for authentication
- cacheDirectory? {String | Function} - Where we will store your tokens (optional) or a factory function that returns a cache.
- cacherOrDir? {String | CacheFactory} - Where we will store your tokens (optional, defaults to node_modules) or a factory object that returns a Cache (see [API.md](docs/API.md))
- options {Object?}
- [flow] {enum} Required if options is specified - see [API.md](docs/API.md) for options
- [forceRefresh] {boolean} - Clear all cached tokens for the specified `username` to get new ones on subsequent token requests
- [password] {string} - If passed we will do password based authentication.
- [authTitle] {string} - See the [API.md](docs/API.md)
- [deviceType] {string} - See the [API.md](docs/API.md)
- [scopes] {string[]} - Extra scopes to add to the auth request. By default, this includes Xbox and offline_access scopes; setting this will replace those scopes (but keep `offline_access` on `msal` flow which is required for caching). *Note that the flows will differ depending on specified `flow`.*
- [abortSignal] {AbortSignal} - (Optional) An AbortSignal to cancel the request.
- onMsaCode {Function} - (For device code auth) What we should do when we get the code. Useful for passing the code to another function.

### Examples
Expand Down
104 changes: 72 additions & 32 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This is the main exposed class you interact with. Every instance holds its own t
* `username` (optional, default='')
* When using device code auth - a unique id
* When using password auth - your microsoft account email
* `cache` (optional, default='node_modules') - Where to store cached tokens or a cache factory function. node_modules if not specified.
* `cacherOrDir` (optional, default='node_modules') - Where to store cached tokens or a cache factory function. node_modules if not specified.
* `options` (optional)
* `flow` (required) The auth flow to use. One of `live`, `msal`, `sisu`. If no `options` argument is specified, `msal` will be used.
* `live` - Generate an XSTS token using the live.com domain which allows for user, device and title authentication. This flow will only work with Windows Live client IDs (such as official Microsoft apps, not custom Azure apps).
Expand All @@ -20,7 +20,9 @@ This is the main exposed class you interact with. Every instance holds its own t
* `password` (optional) If you specify this option, we use password based auth. Note this may be unreliable.
* `authTitle` - The client ID for the service you are logging into. When using the `msal` flow, this is your custom Azure client token. When using `live`, this is the Windows Live SSO client ID - used when authenticating as a Windows app (such as a vanilla Minecraft client). For a list of titles, see `require('prismarine-auth').Titles` and FAQ section below for more info. (Required if using `sisu` or `live` flow, on `msal` flow we fallback to a default client ID.)
* `deviceType` (optional) if specifying an authTitle, the device type to auth as. For example, `Win32`, `iOS`, `Android`, `Nintendo`
* `scopes` {string[]} - Extra scopes to add to the auth request. By default, this includes Xbox and offline_access scopes; setting this will replace those scopes (but keep `offline_access` on `msal` flow which is required for caching). *Note that the flows will differ depending on specified `flow`.*
* `forceRefresh` (optional) boolean - Clear all cached tokens for the specified `username` to get new ones on subsequent token requests
* `abortSignal` (optional) An AbortSignal to cancel the request
* `codeCallback` (optional) The callback to call when doing device code auth. Otherwise, the code will be logged to the console.

#### getMsaToken () : Promise<string>
Expand Down Expand Up @@ -77,57 +79,95 @@ const flow = new Authflow('', './', { authTitle: Titles.MinecraftNintendoSwitch,
flow.getMinecraftJavaToken().then(console.log)
```

### Cache
### Caching

prismarine-auth uses caching to ensure users don't have to constantly sign in when authenticating to Microsoft/Xbox services. By default, if you pass a String value to Authflow's `cacheDir` function call argument, we'll use the local file system to store and retrieve data to build a cache. However, in some circumstances, you may not have access to the local file system, or have a more advanced use-case that requires database retreival, for example. In these scenarios, you can implement cache storage and retreval yourself to match your needs.
prismarine-auth uses caching so users don't have to repeatedly sign in when authenticating
to Microsoft/Xbox services.

If you pass a function to Authflow's `cacheDir` function call argument, you are expected to return a *factory method*, which means your function should instantiate and return a class or an object that implements the interface [defined here](https://github.com/PrismarineJS/prismarine-auth/blob/cf0957495458dc7cb0f2579d97b13d682be27d8f/index.d.ts#L125) and copied below:
By default, if you pass a String value to Authflow's `cacherOrDir` function call argument, we use the local file system
to store and retrieve data to build a cache. However, you may for example not have access to the local file system,
or have a more advanced use-case that requires database retrieval. In these scenarios, you can implement cache storage
and retrieval yourself to match your needs.

If you pass an object to Authflow's `cacherOrDir` function call argument, you are expected to return a
*factory object* that implements the following interface:

```typescript
// Return the stored value, this can be called multiple times
getCached(): Promise<any>
// Replace the stored value
setCached(value: any): Promise<void>
// Replace an part of the stored value. Implement this using the spread operator
setCachedPartial(value: any): Promise<void>
```ts
interface CacheFactory {
createCache(options: { username: string, cacheName: string }): Promise<Cache>
hasCache(cacheName: string, identifier: string): Promise<boolean>
deleteCache(cacheName: string, identifier: string): Promise<void>
deleteCaches(cacheName: string): Promise<void>
cleanup(): Promise<void>
}
```

Your cache function itself will be passed an object with the following properties:

```js
{
username: string, // Name of the user we're trying to get a token for
cacheName: string, // Depends on the cache usage, each cache has a unique name
When .createCache() is called on the factory object, your function should instantiate and return
a class or an object that implements the `Cache` interface [defined here](../index.d.ts) and copied below:

```ts
interface Cache {
// Erases all keys in the cache
reset(): Promise<void>
// Stores a key-value pair in the cache
set(key: string, value: any, options: { expiresOn?: number, obtainedOn?: number }): Promise<void>
// Retrieves a value from the cache
get(key: string): Promise<{ valid: boolean, value?: any, expiresOn?: number }>
// Removes all expired keys
cleanupExpired(): Promise<void>
// Returns true if the cache is empty
isEmpty(): Promise<boolean>
}
```

As an example of usage, you could create a minimal in memory cache like this (note that the returned class instance implements all the functions in the interface linked above):
As an example of usage, you could create a minimal in memory cache like this:

```js
class InMemoryCache {
private cache = {}
```ts
class InMemoryCache implements Cache {
cache = {}
async reset () {
// (should clear the data in the cache like a first run)
}
async getCached () {
return this.cache
async set (key, value, { expiresOn = Date.now() + 9000, obtainedOn = Date.now() } = {}) {
this.cache[key] = { value, expiresOn, obtainedOn }
}
async setCached (value) {
this.cache = value
async get (key) {
const data = this.cache[key]
if (!data) return
return { valid: data.expiresOn > Date.now(), value: data.value }
}
async setCachedPartial (value) {
this.cache = {
...this.cache,
...value
cleanupExpired () {
for (const key in this.cache) {
if (this.cache[key].expiresOn < Date.now()) {
delete this.cache[key]
}
}
}
isEmpty () {
return Promise.resolve(Object.keys(this.cache).length === 0)
}
}

function cacheFactory ({ username, cacheName }) {
return new InMemoryCache()
const cacheFactory = {
async createCache ({ username, cacheName }) {
return new InMemoryCache()
},
async hasCache (cacheName, identifier) {
// (should return true if the cache exists for the given identifier)
},
async deleteCache (cacheName, identifier) {
// (should delete the cache for the given identifier)
},
async deleteCaches (cacheName) {
// (should delete all caches for the given cacheName)
},
async cleanup () {
// (should clean up any resources used by the cache)
}
}
// Passed like `new Authflow('bob', cacheFactory, ...)`

// Passed like so:
const authflow = new Authflow('Notch', cacheFactory)
```

## FAQ
Expand Down
130 changes: 75 additions & 55 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { KeyObject } from 'crypto'

declare module 'prismarine-auth' {
export class Authflow {

// List of cache names (identifiers) that are used by the Authflow class
static CACHE_NAMES: string[]
username: string

options: MicrosoftAuthFlowOptions

/**
Expand All @@ -15,7 +15,7 @@ declare module 'prismarine-auth' {
* @param options Options
* @param codeCallback Optional callback to recieve token information using device code auth
*/
constructor(username?: string, cache?: string | CacheFactory, options?: MicrosoftAuthFlowOptions, codeCallback?: (res: ServerDeviceCodeResponse) => void)
constructor(username?: string, cacherOrDir?: string | CacheFactory, options?: MicrosoftAuthFlowOptions, codeCallback?: (res: ServerDeviceCodeResponse) => void)

// Returns a Microsoft Oauth access token -- https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
getMsaToken(): Promise<string>
Expand Down Expand Up @@ -72,7 +72,7 @@ declare module 'prismarine-auth' {
id: string,
state: string,
url: string,
variant: 'CLASSIC'|'SLIM'
variant: 'CLASSIC' | 'SLIM'
}

export interface MinecraftJavaProfileCape {
Expand Down Expand Up @@ -117,7 +117,11 @@ declare module 'prismarine-auth' {
password?: string
flow: 'live' | 'msal' | 'sisu'
// Reset the cache and obtain fresh tokens for everything
forceRefresh?: boolean
forceRefresh?: boolean,
signal?: AbortSignal,
// Extra scopes to add to the auth request. By default, this includes Xbox and offline_access scopes;
// setting this will replace those scopes (but keep offline_access on MSAL flow which is required for caching).
scopes: string[]
}

export enum Titles {
Expand All @@ -138,22 +142,36 @@ declare module 'prismarine-auth' {
}

type ServerDeviceCodeResponse = {
user_code: string
device_code: string
verification_uri: string
expires_in: number
interval: number
message: string
userURL: string,
userCode: string,
deviceId: string,
expiresInSeconds?: number,
// The Unix timestamp in milliseconds when the device code expires
expiresOn: number,
checkingInterval?: number,
message: string
}

export interface Cache {
// Erases all keys in the cache
reset(): Promise<void>
getCached(): Promise<any>
setCached(value: any): Promise<void>
setCachedPartial(value: any): Promise<void>
// Stores a key-value pair in the cache
set(key: string, value: any, options: { expiresOn?: number, obtainedOn?: number }): Promise<void>
// Retrieves a value from the cache
get(key: string): Promise<{ valid: boolean, value?: any, expiresOn?: number }>
// Removes all expired keys
cleanupExpired(): Promise<void>
// Returns true if the cache is empty
isEmpty(): Promise<boolean>
}

export type CacheFactory = (options: { username: string, cacheName: string }) => Cache
export interface CacheFactory {
createCache(options: { username: string, cacheName: string }): Promise<Cache>
hasCache(cacheName: string, identifier: string): Promise<boolean>
deleteCache(cacheName: string, identifier: string): Promise<void>
deleteCaches(cacheName: string): Promise<void>
cleanup(): Promise<void>
}

export type GetMinecraftBedrockServicesResponse = {
mcToken: string
Expand All @@ -168,55 +186,57 @@ declare module 'prismarine-auth' {
PlayFabId: string;
NewlyCreated: boolean;
SettingsForUser: {
NeedsAttribution: boolean;
GatherDeviceInfo: boolean;
GatherFocusInfo: boolean;
NeedsAttribution: boolean;
GatherDeviceInfo: boolean;
GatherFocusInfo: boolean;
};
LastLoginTime: string;
InfoResultPayload: {
AccountInfo: {
PlayFabId: string;
Created: string;
TitleInfo: {
Origination: string;
Created: string;
LastLogin: string;
FirstLogin: string;
isBanned: boolean;
TitlePlayerAccount: {
Id: string;
Type: string;
TypeString: string;
};
};
PrivateInfo: Record<string, unknown>;
XboxInfo: {
XboxUserId: string;
XboxUserSandbox: string;
};
};
UserInventory: any[];
UserDataVersion: number;
UserReadOnlyDataVersion: number;
CharacterInventories: any[];
PlayerProfile: {
PublisherId: string;
TitleId: string;
PlayerId: string;
};
};
EntityToken: {
EntityToken: string;
TokenExpiration: string;
Entity: {
AccountInfo: {
PlayFabId: string;
Created: string;
TitleInfo: {
Origination: string;
Created: string;
LastLogin: string;
FirstLogin: string;
isBanned: boolean;
TitlePlayerAccount: {
Id: string;
Type: string;
TypeString: string;
};
};
PrivateInfo: Record<string, unknown>;
XboxInfo: {
XboxUserId: string;
XboxUserSandbox: string;
};
};
UserInventory: any[];
UserDataVersion: number;
UserReadOnlyDataVersion: number;
CharacterInventories: any[];
PlayerProfile: {
PublisherId: string;
TitleId: string;
PlayerId: string;
};
};
EntityToken: {
EntityToken: string;
TokenExpiration: string;
Entity: {
Id: string;
Type: string;
TypeString: string;
};
};
TreatmentAssignment: {
Variants: any[];
Variables: any[];
Variants: any[];
Variables: any[];
};
}

export function createFileSystemCache(cacheDir: string, cacheIds: string[]): Promise<CacheFactory>
}
9 changes: 6 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
if (typeof process !== 'undefined' && parseInt(process.versions.node.split('.')[0]) < 14) {
if (typeof process !== 'undefined' && parseInt(process.versions.node.split('.')[0]) < 18) {
console.error('Your node version is currently', process.versions.node)
console.error('Please update it to a version >= 14.x.x from https://nodejs.org/')
console.error('Please update it to a version >= 18.x.x from https://nodejs.org/')
process.exit(1)
}

const { createFileSystemCache } = require('./src/common/cache/FileCache')

module.exports = {
Authflow: require('./src/MicrosoftAuthFlow'),
Titles: require('./src/common/Titles')
Titles: require('./src/common/Titles'),
createFileSystemCache
}
Loading