Skip to content

Commit

Permalink
feat: Allow initialization with access token or token exchange code (#…
Browse files Browse the repository at this point in the history
…194)

* fix: use latest refresh token when refreshing

* feat: Accept AccessToken object in the Strava constructor

* feat: Perform token exchange to generate a Strava object

* chore: Document new constructors

* fix: Correct license link in readme
  • Loading branch information
c-harding committed Mar 15, 2024
1 parent 503ae9f commit bc48f8d
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 97 deletions.
114 changes: 93 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ yarn add strava

## Usage

The way the library is implemented the user must have gone through the [Strava OAuth flow](https://developers.strava.com/docs/authentication/) beforehand and got a refresh token. This way we can ensure that whenever needed we get a new access token.

This may not be the best way to work with the API and I'm open to suggestions to make it better.
The library can be initialized with a refresh token and optionally an access token.
If these are not available, see below (**Token exchange**).

```javascript
import { Strava } from 'strava'
Expand All @@ -36,30 +35,103 @@ const strava = new Strava({
refresh_token: 'def',
})

;(async () => {
try {
const activities = await strava.activities.getLoggedInAthleteActivities()
console.log(activities)
} catch (error) {
console.log(error)
}
})()
try {
const activities = await strava.activities.getLoggedInAthleteActivities()
console.log(activities)
} catch (error) {
console.log(error)
}
```

### Refreshing the access token

This library will automatically refresh the access token when needed. You may need to store the refresh token somewhere, so you can use it `on_token_refresh` callback.
This library will automatically refresh the access token when needed.
In order to store the token, you can use the `on_token_refresh` callback.
This received an `AccessToken` object (consisting of `access_token`, `expires_at`, and `refresh_token`).
Note that the refresh token as returned by this call can sometimes change,
at which point the old token becomes invalid.

An `AccessToken` object can also be passed as a second argument to the Strava constructor.
This can save an initial token refresh.
As `AccessToken` contains a refresh token,
the first argument does not need to contain a refresh token.

```javascript
import { Strava } from 'strava'

const strava = new Strava({
client_id: '123',
client_secret: 'abc',
refresh_token: 'def',
on_token_refresh: (response: RefreshTokenResponse) => {
db.set('refresh_token', response.refresh_token)
}
})
const strava = new Strava(
{
client_id: '123',
client_secret: 'abc',

on_token_refresh: response => {
cache.accessToken = response
},
},
cache.accessToken,
)
```

### Token exchange

When a user logs in for the first time, you will need to perform authorization with OAuth.
This involves sending the user to <https://www.strava.com/oauth/authorize>,
and receiving the auth code as a query parameter.

This can be used as follows:

```javascript
import { Strava } from 'strava'

try {
const strava = await Strava.createFromTokenExchange(
{
client_id: '123',
client_secret: 'abc',
},
token,
)

const activities = await strava.activities.getLoggedInAthleteActivities()
console.log(activities)
} catch (error) {
console.log(error)
}
```

### Getting athlete info

When a user logs in for the first time, the Strava API returns information about the newly logged-in user.
This can be read using the on_token_refresh callback.
Note that this will only ever be provided on the initial token exchange,
before the promise returned from `Strava.createFromTokenExchange` returns.
When the `on_token_refresh` callback is called again after the token expires,
`response.athlete` will always be undefined.

```javascript
import { Strava } from 'strava'

try {
const strava = await Strava.createFromTokenExchange(
{
client_id: '123',
client_secret: 'abc',
on_token_refresh: response => {
if (response.athlete) {
console.log(response.athlete)
}

db.set('refresh_token', response.refresh_token)
},
},
token,
)

const activities = await strava.activities.getLoggedInAthleteActivities()
console.log(activities)
} catch (error) {
console.log(error)
}
```

## Contributing
Expand All @@ -68,4 +140,4 @@ Issues and pull requests are welcome.

## License

[MIT](https://github.com/rfoell/strava/blob/master/LICENSE)
[MIT](https://github.com/rfoel/strava/blob/master/LICENSE)
40 changes: 24 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,36 @@ import {
Uploads,
} from './resources'
import { Oauth } from './resources/oauth'
import { RefreshTokenRequest } from './types'
import { AccessToken, AppConfig, RefreshTokenRequest } from './types'

export * from './types'
export * from './enums'
export * from './models'

export class Strava {
private readonly request: Request
activities: Activities
athletes: Athletes
clubs: Clubs
gears: Gears
oauth: Oauth
routes: Routes
runningRaces: RunningRaces
segmentEfforts: SegmentEfforts
segments: Segments
streams: Streams
subscriptions: Subscriptions
uploads: Uploads
readonly activities: Activities
readonly athletes: Athletes
readonly clubs: Clubs
readonly gears: Gears
readonly oauth: Oauth
readonly routes: Routes
readonly runningRaces: RunningRaces
readonly segmentEfforts: SegmentEfforts
readonly segments: Segments
readonly streams: Streams
readonly subscriptions: Subscriptions
readonly uploads: Uploads

constructor(config: RefreshTokenRequest) {
this.request = new Request(config)
constructor(config: RefreshTokenRequest, access_token?: AccessToken)
constructor(config: AppConfig, access_token: AccessToken)
constructor(config: RefreshTokenRequest, access_token?: AccessToken) {
this.request = new Request(config, access_token)
this.activities = new Activities(this.request)
this.athletes = new Athletes(this.request)
this.clubs = new Clubs(this.request)
this.gears = new Gears(this.request)
this.oauth = new Oauth()
this.oauth = this.request.oauth
this.routes = new Routes(this.request)
this.runningRaces = new RunningRaces(this.request)
this.segmentEfforts = new SegmentEfforts(this.request)
Expand All @@ -49,4 +51,10 @@ export class Strava {
this.subscriptions = new Subscriptions(this.request)
this.uploads = new Uploads(this.request)
}

static async createFromTokenExchange(config: AppConfig, code: string) {
const tokenExchangeResponse = await Oauth.tokenExchange(config, code)
config.on_token_refresh?.(tokenExchangeResponse)
return new Strava(config, tokenExchangeResponse)
}
}
60 changes: 19 additions & 41 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fetch, { BodyInit } from 'node-fetch'

import { RefreshTokenRequest, RefreshTokenResponse } from './types'
import { AccessToken, RefreshTokenRequest } from './types'
import { Oauth } from './resources/oauth'

type RequestParams = {
query?: Record<string, any>
Expand All @@ -10,61 +11,38 @@ type RequestParams = {
}

export class Request {
config: RefreshTokenRequest
response: RefreshTokenResponse
readonly oauth = new Oauth()

constructor(config: RefreshTokenRequest) {
this.config = config
}
constructor(
readonly config: RefreshTokenRequest,
private token?: AccessToken,
) {}

private async getAccessToken(): Promise<RefreshTokenResponse> {
if (
!this.response ||
this.response?.expires_at < new Date().getTime() / 1000
) {
const query: string = new URLSearchParams({
private async getAccessToken(): Promise<string> {
if (!this.token || this.token.expires_at < new Date().getTime() / 1000) {
const token = await this.oauth.refreshTokens({
client_id: this.config.client_id,
client_secret: this.config.client_secret,
refresh_token: this.config.refresh_token,
grant_type: 'refresh_token',
}).toString()

const response = await fetch(
`https://www.strava.com/oauth/token?${query}`,
{
method: 'post',
},
)

if (!response.ok) {
throw response
}

this.response = (await response.json()) as RefreshTokenResponse
this.config.on_token_refresh?.(this.response)
refresh_token: this.token?.refresh_token || this.config.refresh_token,
})
this.token = token
this.config.on_token_refresh?.(token)
}
return this.response
return this.token.access_token
}

public async makeApiRequest<Response>(
method: string,
uri: string,
params?: RequestParams,
): Promise<Response> {
if (!params?.access_token) await this.getAccessToken()
const token = params?.access_token || (await this.getAccessToken())
const query: string =
params?.query && Object.keys(params?.query).length
? `?${new URLSearchParams(
Object.entries(params?.query).reduce(
(acc, [key, value]) => ({ ...acc, [key]: String(value) }),
{},
),
).toString()}`
params?.query && Object.keys(params.query).length
? `?${new URLSearchParams(params?.query)}`
: ''
const headers = {
Authorization: `Bearer ${
params?.access_token ? params?.access_token : this.response.access_token
}`,
Authorization: `Bearer ${token}`,
'content-type': 'application/json',
...(params?.headers ? params.headers : {}),
}
Expand Down
42 changes: 28 additions & 14 deletions src/resources/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
import fetch from 'node-fetch'

import { RefreshTokenRequest, RefreshTokenResponse } from '../types'
import { AppConfig, RefreshTokenRequest, RefreshTokenResponse } from '../types'

export class Oauth {
constructor() {}

async refreshTokens(
token: RefreshTokenRequest,
): Promise<RefreshTokenResponse> {
if (!token) {
throw new Error('No token provided')
}
const query: string = new URLSearchParams({
client_id: token.client_id,
client_secret: token.client_secret,
refresh_token: token.refresh_token,
grant_type: 'refresh_token',
}).toString()
return await Oauth.oauthRequest(
new URLSearchParams({
client_id: token.client_id,
client_secret: token.client_secret,
refresh_token: token.refresh_token,
grant_type: 'refresh_token',
}),
)
}

const response = await fetch(
`https://www.strava.com/oauth/token?${query}`,
{
method: 'post',
},
static async tokenExchange(config: AppConfig, code: string) {
if (!code) {
throw new Error('No code provided')
}
return await Oauth.oauthRequest(
new URLSearchParams({
client_id: config.client_id,
client_secret: config.client_secret,
code,
grant_type: 'authorization_code',
}),
)
}

private static async oauthRequest(body: URLSearchParams) {
const response = await fetch(`https://www.strava.com/oauth/token`, {
body,
method: 'post',
})
if (!response.ok) {
throw response
}
Expand Down
19 changes: 15 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
export interface RefreshTokenRequest {
import { SummaryAthlete } from './models'

export interface AppConfig {
client_id: string
client_secret: string
refresh_token: string
on_token_refresh?: (token: RefreshTokenResponse) => void
}
export interface RefreshTokenResponse {

export interface RefreshTokenRequest extends AppConfig {
refresh_token: string
}

export interface AccessToken {
access_token: string
expires_at: number
refresh_token?: string
}

export interface RefreshTokenResponse extends AccessToken {
expires_in: number
refresh_token: string
/** The athlete is only provided on the initial request */
athlete?: SummaryAthlete
}

/**
Expand Down
3 changes: 2 additions & 1 deletion test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import nock from 'nock'
export const auth = () => {
nock('https://www.strava.com')
.post(
'/oauth/token?client_id=client_id&client_secret=client_secret&refresh_token=refresh_token&grant_type=refresh_token',
'/oauth/token',
'client_id=client_id&client_secret=client_secret&refresh_token=refresh_token&grant_type=refresh_token',
)
.reply(200, {})
return new Strava({
Expand Down

0 comments on commit bc48f8d

Please sign in to comment.