Skip to content

Commit

Permalink
Automatically use the refresh token if 401
Browse files Browse the repository at this point in the history
Only if there is one and that we're past expiration date
If the refresh token is successful

...this is assuming the refresh token expires *after* the access token
  • Loading branch information
TTTaevas committed Nov 23, 2023
1 parent 82decee commit 2169c19
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 17 deletions.
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[**osu-api-v2-js**](https://github.com/TTTaevas/osu-api-v2-js) is a JavaScript & TypeScript package that helps you interact with [osu!api (v2)](https://docs.ppy.sh).

It is currently unstable as it's under development, but you can find documentation on [osu-v2.taevas.xyz](https://osu-v2.taevas.xyz/) if needed!
It is currently a bit unstable as it's under development, but you can find documentation on [osu-v2.taevas.xyz](https://osu-v2.taevas.xyz) if needed!

## How to install and get started

Expand All @@ -15,6 +15,7 @@ pnpm add osu-api-v2-js # if using pnpm
bun a osu-api-v2-js # if using bun
```

You will want to create your own OAuth application: https://osu.ppy.sh/home/account/edit#oauth
To use (import) the package in your project and start interacting with the API, you may do something like that:

```typescript
Expand All @@ -25,7 +26,7 @@ async function logUserTopPlayBeatmap(username: string) {
// Because of how the API server works, it's more convenient to use `osu.API.createAsync()` instead of `new osu.API()`!
// In a proper application, you'd use this function as soon as the app starts so you can use that object everywhere
// (or if it acts as a user, you'd use this function at the end of the authorization flow)
const api = await osu.API.createAsync({id: "<client_id>", "<client_secret>"})
const api = await osu.API.createAsync({id: "<client_id>", secret: "<client_secret>"})

const user = await api.getUser({username}) // We need to get the id of the user in order to request what we want
const score = (await api.getUserScores(user, "best", 1, osu.Rulesets.osu))[0] // Specifying the Ruleset is optional
Expand All @@ -40,6 +41,39 @@ async function logUserTopPlayBeatmap(username: string) {
logUserTopPlayBeatmap("Doomsday fanboy")
```

### Authorization flow
A simple guide on how to do extra fancy stuff

#### The part where the user says they're okay with using your application

If your application is meant to act on behalf of a user after they've clicked on a button to say they consent to your application identifying them and reading public data on their behalf and some other stuff maybe, then things will work differently

Let's take it step by step! First, this package comes with `generateAuthorizationURL()`, which will generate for you a link so users can click on it and allow your application to do stuff on their behalf
This function requires you to specify scopes... well, just know that **`identify` is always implicitly specified**, that **`public` is almost always implicitly required**, and that **functions that require other scopes are explicit about it!**

Please note: It is the user who ultimately decides which scopes they allow, so you can't assume they allowed all the scopes you specified...
Thankfully though, you can check at any time the allowed scopes with the `scopes` property of your `api` object!

#### The part where your application hears the user when the user says okay

The user clicked your link and authorized your application! ...Now what?

When a user authorizes your application, they get redirected to your `Application Callback URL` with a *huge* code as a GET parameter (the name of the parameter is `code`), and it is this very code that will allow you to proceed with the authorization flow! So make sure that somehow, you retrieve this code!

With this code, you're able to create your `api` object:
```typescript
const api = await osu.API.createAsync({id: "<client_id>", secret: "<client_secret>"}, {code: "<code>", redirect_uri: "<application_callback_url>"})
```

#### The part where you make it so your application works without the user saying okay every 2 minutes

Congrats on making your `api` object! Now you should do something in order to not lose it, or not need a new one in order to request more data!

Do note that your `api` object has lots of practical properties: `user` allows you to know which user it acts on behalf of, `expires` allows you to know when your requests with your current `access_token` will fail, and `refresh_token` is your key to getting a new `access_token` without asking the user again!
Although, you should not need to access them often, because your `api` object has a function to use that refresh token which you can call at any given time, and it **will** call it itself if, upon requesting something, it notices the date the `access_token` `expires` is in the past!

Your `refresh_token` can actually also expire at a (purposefully) unknown time, so depending of how your application works, you could use it at some point around the date of expiration, or you could throw away your `api` object while waiting for a user to start the authorization flow again

## Implemented endpoints

### Beatmap Packs
Expand Down
57 changes: 44 additions & 13 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ export class APIError {
message: string
server: string
endpoint: string
parameters: string
parameters?: object
/**
* @param message The reason why things didn't go as expected
* @param server The server to which the request was sent
* @param endpoint The type of resource that was requested from the server
* @param parameters The filters that were used to specify what resource was wanted
*/
constructor(message: string, server: string, endpoint: string, parameters: string) {
constructor(message: string, server: string, endpoint: string, parameters?: object) {
this.message = message
this.server = server
this.endpoint = endpoint
Expand Down Expand Up @@ -149,7 +149,7 @@ export class API {
*/
constructor(client?: {id: number, secret: string}, token_type?: string, expires?: Date,
access_token?: string, scopes?: Scope[], refresh_token?: string, user?: number,
verbose: "none" | "errors" | "all" = "none", server: string = "https://osu.ppy.sh") {
verbose: "none" | "errors" | "all" = "all", server: string = "https://osu.ppy.sh") {
this.client = client ?? {id: 0, secret: ""}
this.token_type = token_type ?? ""
this.expires = expires ?? new Date()
Expand All @@ -172,7 +172,13 @@ export class API {
}
}

private async obtainToken(body: any, api: API): Promise<API> {
/**
* Set most of an `api`'s properties, like tokens, token_type, scopes, expiration_date
* @param body An Object with the client id & secret, grant_type, and stuff that depends of the grant_type
* @param api The `api` which will see its properties change
* @returns `api`, just in case, because in theory it should modify the original object
*/
private async obtainToken(body: object, api: API): Promise<API> {
const response = await fetch(`${this.server}/oauth/token`, {
method: "post",
headers: {
Expand Down Expand Up @@ -239,26 +245,41 @@ export class API {
return api
}

public async refreshToken() {
if (!this.refresh_token) {return false}
/**
* @returns Whether or not the token has been refreshed
*/
public async refreshToken(): Promise<boolean> {
if (!this.refresh_token) {
this.log(true, "Attempted to get a new access token despite not having a refresh token!")
return false
}

const old_token = this.access_token
const body = {
client_id: this.client.id,
client_secret: this.client.secret,
grant_type: "refresh_token",
refresh_token: this.refresh_token
}

const response = await this.obtainToken(body, this)
return response ? true : false
try {
await this.obtainToken(body, this)
if (old_token !== this.access_token) this.log(false, "The token has been refreshed!")
return old_token !== this.access_token
} catch {
this.log(true, "Failed to refresh the token :(")
return false
}
}

/**
* @param method The type of request, each endpoint uses a specific one (if it uses multiple, the intent and parameters become different)
* @param endpoint What comes in the URL after `api/`
* @param parameters The things to specify in the request, such as the beatmap_id when looking for a beatmap
* @param number_try How many attempts there's been to get the data
* @returns A Promise with either the API's response or `false` upon failing
* @param number_try Attempt number for doing this specific request
* @returns A Promise with the API's response
*/
private async request(method: "get" | "post", endpoint: string,
private async request(method: "get" | "post" | "put" | "delete", endpoint: string,
parameters?: {[k: string]: any}, number_try: number = 1): Promise<any> {
const max_tries = 5
let err = "none"
Expand Down Expand Up @@ -302,8 +323,18 @@ export class API {
if (!response || !response.ok) {
if (response) {
err = response.statusText

if (response.status === 401) {
this.log(true, "Server responded with status code 401, maybe you need to do this action as an user?")
if (this.refresh_token && new Date() > this.expires) {
this.log(true, "Server responded with status code 401, your token might have expired, I will attempt to refresh your token...")
let refreshed = await this.refreshToken()

if (refreshed) {
to_retry = true
}
} else {
this.log(true, "Server responded with status code 401, maybe you need to do this action as a user?")
}
} else if (response.status === 403) {
this.log(true, "Server responded with status code 403, you may lack the necessary scope for this action!")
} else if (response.status === 422) {
Expand All @@ -328,7 +359,7 @@ export class API {
return correctType(await this.request(method, endpoint, parameters, number_try + 1))
}

throw new APIError(err, `${this.server}/api/v2`, endpoint, JSON.stringify(parameters))
throw new APIError(err, `${this.server}/api/v2`, endpoint, parameters)
}

this.log(false, response.statusText, response.status, {endpoint, parameters})
Expand Down
6 changes: 4 additions & 2 deletions lib/tests/test_authorized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ async function test(id: string | undefined, secret: string | undefined, redirect
let code = prompt(`What code do you get from: ${url}\n\n`)

let api = await osu.API.createAsync({id: Number(id), secret}, {code, redirect_uri}, "all")
let d2 = await api.getRoom({id: 464285})
let a = await api.getPlaylistItemScores({id: d2.playlist[0].id, room_id: d2.id})
api.access_token = "a"
api.expires = new Date(1980)
let r = await api.getResourceOwner()
console.log(r.username)
}

test(process.env.ID, process.env.SECRET, process.env.REDIRECT_URI)

0 comments on commit 2169c19

Please sign in to comment.