Skip to content

Commit

Permalink
Auto refresh the oauth2 token (#5)
Browse files Browse the repository at this point in the history
This change uses the whole oauth2 token for authentication which
includes type, access token, refresh token and expiration (previously
only the access token was used).

By using all the info of the oauth2 token and especially the refresh
token, the oauth2 client can now seamlessly refresh the oauth2 token
when it expires.

The documentation and examples have been updated accordingly.
  • Loading branch information
nstratos authored Sep 7, 2021
1 parent c7a6716 commit 64af698
Show file tree
Hide file tree
Showing 14 changed files with 289 additions and 190 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
- name: Run all tests including integration tests
run: |
cd mal
go test -v --access-token=${{ secrets.MAL_ACCESS_TOKEN }}
go test -v --client-id=${{ secrets.MAL_CLIENT_ID }} --oauth2-token='${{ secrets.MAL_OAUTH2_TOKEN }}'
5 changes: 2 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ name: tests

on:
push:
branches:
- master

pull_request:
branches:
- master
Expand Down Expand Up @@ -52,7 +51,7 @@ jobs:

- name: Run golint
run: |
go install golang.org/x/lint/golint
go install golang.org/x/lint/golint@latest
golint `go list ./... | grep -v /vendor/`
- name: Run go test
Expand Down
50 changes: 37 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,49 @@ the different MyAnimeList API methods.
When creating a new client, pass an `http.Client` that can handle authentication
for you. The recommended way is to use the `golang.org/x/oauth2` package
(https://github.com/golang/oauth2). After performing the OAuth2 flow, you will
get an access token which can be used like this:
get an oauth2 token containing an access token, a refresh token and an
expiration date. The oauth2 token can easily be stored in JSON format and used
like this:

```go
ctx := context.Background()
c := mal.NewClient(
oauth2.NewClient(ctx, oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: "<your access token>"},
)),
)
const storedToken = `
{
"token_type": "Bearer",
"access_token": "yourAccessToken",
"refresh_token": "yourRefreshToken",
"expiry": "2021-06-01T16:12:56.1319122Z"
}`

oauth2Token := new(oauth2.Token)
_ = json.Unmarshal([]byte(storedToken), oauth2Token)

// Create client ID and secret from https://myanimelist.net/apiconfig.
//
// Secret is currently optional if you choose App Type 'other'.
oauth2Conf := &oauth2.Config{
ClientID: "<Enter your registered MyAnimeList.net application client ID>",
ClientSecret: "<Enter your registered MyAnimeList.net application client secret>",
Endpoint: oauth2.Endpoint{
AuthURL: "https://myanimelist.net/v1/oauth2/authorize",
TokenURL: "https://myanimelist.net/v1/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
}

oauth2Client := oauth2Conf.Client(ctx, oauth2Token)

// The oauth2Client will refresh the token if it expires.
c := mal.NewClient(oauth2Client)
```

Note that all calls made by the client above will include the specified access
Note that all calls made by the client above will include the specified oauth2
token which is specific for an authenticated user. Therefore, authenticated
clients should almost never be shared between different users.

Performing the OAuth2 flow involves registering a MAL API application and then
asking for the user's consent to allow the application to access their data.

There is a detailed example of how to perform the Oauth2 flow and get an access
There is a detailed example of how to perform the Oauth2 flow and get an oauth2
token through the terminal under `example/malauth`. The only thing you need to run
the example is a client ID and a client secret which you can acquire after
registering your MAL API application. Here's how:
Expand All @@ -84,7 +108,7 @@ ID and client secret through flags:
go install github.com/nstratos/go-myanimelist/example/malauth
malauth --client-id=... --client-secret=...

After you perform a successful authentication once, the access token will be
After you perform a successful authentication once, the oauth2 token will be
cached in a file under the same directory which makes it easier to run the
example multiple times.

Expand Down Expand Up @@ -307,13 +331,13 @@ also a much higher chance of false positives in test failures due to network
issues etc.

These tests are meant to be run using a dedicated test account that contains
empty anime and manga lists. A valid access token needs to be provided every
empty anime and manga lists. A valid oauth2 token needs to be provided every
time. Check the authentication section to learn how to get one.

By default the integration tests are skipped when an access token is not
By default the integration tests are skipped when an oauth2 token is not
provided. To run all tests including the integration tests:

go test --access-token '<your access token>'
go test --client-id='<your app client ID>' --oauth2-token='<your oauth2 token>'

## License

Expand Down
57 changes: 36 additions & 21 deletions example/malauth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"context"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"net/http"
Expand Down Expand Up @@ -38,8 +39,8 @@ const (

func run() error {
var (
clientID = flag.String("client-id", defaultClientID, "your application client ID")
clientSecret = flag.String("client-secret", defaultClientSecret, "your application client secret")
clientID = flag.String("client-id", defaultClientID, "your registered MyAnimeList.net application client ID")
clientSecret = flag.String("client-secret", defaultClientSecret, "your registered MyAnimeList.net application client secret; optional if you chose App Type 'other'")
// state is a token to protect the user from CSRF attacks. In a web
// application, you should provide a non-empty string and validate that
// it matches the state query parameter on the redirect URL callback
Expand All @@ -63,14 +64,6 @@ func run() error {
}

func authenticate(ctx context.Context, clientID, clientSecret, state string) (*http.Client, error) {
accessToken := loadCachedToken()
if accessToken != "" {
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: accessToken},
)
return oauth2.NewClient(ctx, ts), nil
}

// Prepare the oauth2 configuration with your application ID, secret, the
// MyAnimeList authentication and token URLs as specified in:
//
Expand All @@ -85,6 +78,19 @@ func authenticate(ctx context.Context, clientID, clientSecret, state string) (*h
},
}

oauth2Token, err := loadCachedToken()
if err == nil {
refreshedToken, err := conf.TokenSource(ctx, oauth2Token).Token()
if err == nil && (oauth2Token != refreshedToken) {
fmt.Println("Caching refreshed oauth2 token...")
if err := cacheToken(*refreshedToken); err != nil {
return nil, fmt.Errorf("caching refreshed oauth2 token: %s", err)
}
return conf.Client(ctx, refreshedToken), nil
}
return conf.Client(ctx, oauth2Token), nil
}

// Generate a code verifier, a high-entropy cryptographic random string. It
// will be set as the code_challenge in the authentication URL. It should
// have a minimum length of 43 characters and a maximum length of 128
Expand Down Expand Up @@ -123,29 +129,38 @@ func authenticate(ctx context.Context, clientID, clientSecret, state string) (*h
if err != nil {
return nil, fmt.Errorf("exchanging code for token: %v", err)
}
fmt.Println("Authentication was successful. Caching access token...")
cacheToken(token.AccessToken)
fmt.Println("Authentication was successful. Caching oauth2 token...")
if err := cacheToken(*token); err != nil {
return nil, fmt.Errorf("caching oauth2 token: %s", err)
}

return conf.Client(ctx, token), nil
}

const cacheName = "auth-example-token-cache.txt"

func cacheToken(token string) {
content := []byte(token)
err := os.WriteFile(cacheName, content, 0644)
func cacheToken(token oauth2.Token) error {
b, err := json.MarshalIndent(token, "", " ")
if err != nil {
return fmt.Errorf("marshaling token %s: %v", token, err)
}
err = os.WriteFile(cacheName, b, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "caching access token: %v, token is: %s", err, token)
return
return fmt.Errorf("writing token %s to file %q: %v", token, cacheName, err)
}
return nil
}

func loadCachedToken() string {
token, err := os.ReadFile(cacheName)
func loadCachedToken() (*oauth2.Token, error) {
b, err := os.ReadFile(cacheName)
if err != nil {
return ""
return nil, fmt.Errorf("reading oauth2 token from cache file %q: %v", cacheName, err)
}
token := new(oauth2.Token)
if err := json.Unmarshal(b, token); err != nil {
return nil, fmt.Errorf("unmarshaling oauth2 token: %v", err)
}
return string(token)
return token, nil
}

func generateCodeVerifier(length int) (string, error) {
Expand Down
50 changes: 37 additions & 13 deletions mal/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,47 @@ Authentication
When creating a new client, pass an http.Client that can handle authentication
for you. The recommended way is to use the golang.org/x/oauth2 package
(https://github.com/golang/oauth2). After performing the OAuth2 flow, you will
get an access token which can be used like this:
get an oauth2 token containing an access token, a refresh token and an
expiration date. The oauth2 token can easily be stored in JSON format and used
like this:
const storedToken = `
{
"token_type": "Bearer",
"access_token": "yourAccessToken",
"refresh_token": "yourRefreshToken",
"expiry": "2021-06-01T16:12:56.1319122Z"
}`
oauth2Token := new(oauth2.Token)
_ = json.Unmarshal([]byte(storedToken), oauth2Token)
// Create client ID and secret from https://myanimelist.net/apiconfig.
//
// Secret is currently optional if you choose App Type 'other'.
oauth2Conf := &oauth2.Config{
ClientID: "<Enter your registered MyAnimeList.net application client ID>",
ClientSecret: "<Enter your registered MyAnimeList.net application client secret>",
Endpoint: oauth2.Endpoint{
AuthURL: "https://myanimelist.net/v1/oauth2/authorize",
TokenURL: "https://myanimelist.net/v1/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
}
ctx := context.Background()
c := mal.NewClient(
oauth2.NewClient(ctx, oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: "<your access token>"},
)),
)
oauth2Client := oauth2Conf.Client(ctx, oauth2Token)
// The oauth2Client will refresh the token if it expires.
c := mal.NewClient(oauth2Client)
Note that all calls made by the client above will include the specified access
Note that all calls made by the client above will include the specified oauth2
token which is specific for an authenticated user. Therefore, authenticated
clients should almost never be shared between different users.
Performing the OAuth2 flow involves registering a MAL API application and then
asking for the user's consent to allow the application to access their data.
There is a detailed example of how to perform the Oauth2 flow and get an access
There is a detailed example of how to perform the Oauth2 flow and get an oauth2
token through the terminal under example/malauth. The only thing you need to run
the example is a client ID and a client secret which you can acquire after
registering your MAL API application. Here's how:
Expand All @@ -63,7 +87,7 @@ ID and client secret through flags:
go install github.com/nstratos/go-myanimelist/example/malauth
malauth --client-id=... --client-secret=...
After you perform a successful authentication once, the access token will be
After you perform a successful authentication once, the oauth2 token will be
cached in a file under the same directory which makes it easier to run the
example multiple times.
Expand Down Expand Up @@ -272,13 +296,13 @@ also a much higher chance of false positives in test failures due to network
issues etc.
These tests are meant to be run using a dedicated test account that contains
empty anime and manga lists. A valid access token needs to be provided every
empty anime and manga lists. A valid oauth2 token needs to be provided every
time. Check the authentication section to learn how to get one.
By default the integration tests are skipped when an access token is not
By default the integration tests are skipped when an oauth2 token is not
provided. To run all tests including the integration tests:
go test --access-token '<your access token>'
go test --client-id='<your app client ID>' --oauth2-token='<your oauth2 token>'
License
Expand Down
Loading

0 comments on commit 64af698

Please sign in to comment.