Skip to content
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

Support OAuth2 flow for "web app" #45

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/vartanbeno/go-reddit/v2
module github.com/bevzzz/go-reddit/v2

go 1.15

Expand Down
4 changes: 4 additions & 0 deletions reddit/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ var expectedPostAndComments = &PostAndComments{
AuthorID: "t2_testuser",

IsSelfPost: true,

Hidden: true,
RemovedByCategory: "moderator",
BannedBy: "",
},
Comments: []*Comment{
{
Expand Down
116 changes: 104 additions & 12 deletions reddit/reddit-oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,89 @@ Docs:
- Script (the simplest type of app). Select this if you are the only person who will
use the app. Only has access to your account.

Best option for a client like this is to use the script option.
This package currently supports clients for "script" and "web app" options.

2. After creating the app, you will get a client id and client secret.

3. Send a POST request (with the Content-Type header set to "application/x-www-form-urlencoded")
to https://www.reddit.com/api/v1/access_token with the following form values:
to https://www.reddit.com/api/v1/access_token to obtain the access (and refresh, read further) token.

To authorize a "script" app, include the following form values:
- grant_type=password
- username={your Reddit username}
- password={your Reddit password}

To authorize a "web" app, you will first need to obtain a "code" that can be exchanged for an access token.
It's a two-step process:

3.1 Redirect the user to https://www.reddit.com/api/v1/authorize (Reddit's official authorization URL).
To find out more about the required request parameters, see the "OAuth2" article from the Docs.

3.2. User will be redirected to your app's "Redirect URI". Extract "code" from the query parameters
and exchange it for the access_token, including the following form values:
- grant_type=authorization_code
- code={code}
- redirect_uri={you app's "Redirect URI"}

4. You should receive a response body like the following:
{
"access_token": "70743860-DRhHVNSEOMu1ldlI",
"token_type": "bearer",
"expires_in": 3600,
"scope": "*"
}

Note: web apps can obtain a refresh token by adding `&duration=permanent` parameter to the "authorization URL" (step 3.1).
*/

package reddit

import (
"context"
"fmt"
"net/http"
"time"

"golang.org/x/oauth2"
)

// webAppOAuthParams are used to retrieve access token using "code flow" or refresh_token,
// see https://github.com/reddit-archive/reddit/wiki/OAuth2#token-retrieval-code-flow.
type webAppOAuthParams struct {
// Code can be exchanged for access_token.
Code string

// RedirectURI is used to build an AuthCodeURL when requesting users to grant access,
// and later exchanging code for access_token. The URI must be valid, as it will receive
// a request containing the `code` after user grants access to the app. Part of the "code flow".
RedirectURI string

// RefreshToken should be set to retrieve a new access_token, ignoring the "code flow".
RefreshToken string
}

// TokenSource creates a reusable token source base on the provided configuration. If code is set,
// it is exchanged for an access_token. If, on the other hand RefreshToken is set, we assume that
// the initial authorization has already happened and create an oauth2.Token with immediate expiry.
func (p webAppOAuthParams) TokenSource(ctx context.Context, config *oauth2.Config) (oauth2.TokenSource, error) {
var tok *oauth2.Token
var err error

if p.RefreshToken != "" {
tok = &oauth2.Token{
RefreshToken: p.RefreshToken,
Expiry: time.Now(), // refresh before using
}
} else if p.Code != "" {
if tok, err = config.Exchange(ctx, p.Code); err != nil {
return nil, fmt.Errorf("exchange code: %w", err)
}
}
return config.TokenSource(ctx, tok), err
}

// oauthTokenSource retrieves access_token from resource owner's
// username and password. It implements oauth2.TokenSource.
type oauthTokenSource struct {
ctx context.Context
config *oauth2.Config
Expand All @@ -50,7 +105,8 @@ func (s *oauthTokenSource) Token() (*oauth2.Token, error) {
return s.config.PasswordCredentialsToken(s.ctx, s.username, s.password)
}

func oauthTransport(client *Client) http.RoundTripper {
// oauthTransport returns a Transport to handle authorization based the selected app type.
func oauthTransport(client *Client) (*oauth2.Transport, error) {
httpClient := &http.Client{Transport: client.client.Transport}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)

Expand All @@ -63,15 +119,51 @@ func oauthTransport(client *Client) http.RoundTripper {
},
}

tokenSource := oauth2.ReuseTokenSource(nil, &oauthTokenSource{
ctx: ctx,
config: config,
username: client.Username,
password: client.Password,
})
transport := &oauth2.Transport{Base: client.client.Transport}

return &oauth2.Transport{
Source: tokenSource,
Base: client.client.Transport,
switch client.appType {
case Script:
transport.Source = oauth2.ReuseTokenSource(nil, &oauthTokenSource{
ctx: ctx,
config: config,
username: client.Username,
password: client.Password,
})
case WebApp:
config.RedirectURL = client.webOauth.RedirectURI
ts, err := client.webOauth.TokenSource(ctx, config)
if err != nil {
return nil, err
}
transport.Source = ts
default:
// Should we panic here? There is not supposed to be any other app type.
}

return transport, nil
}

// AuthCodeURL is a util function for buiding a URL to request permission grant from a user.
//
// TODO: Currently only works with defaultAuthURL,
// but should be able to use a custom AuthURL. Need to find an elegant solution.
//
// By default, Reddit will only issue an access_token to a WebApp for 1h,
// after which the app would need to ask the user to grant access again.
// `permanent` should be set to true to additionally request a refresh_token.
func AuthCodeURL(clientID, redirectURI, state string, scopes []string, permanent bool) string {
config := &oauth2.Config{
ClientID: clientID,
Endpoint: oauth2.Endpoint{
AuthURL: defaultAuthURL,
},
RedirectURL: redirectURI,
Scopes: scopes,
}
var opts []oauth2.AuthCodeOption
if permanent {
opts = append(opts, oauth2.SetAuthURLParam("duration", "permanent"))
}

return config.AuthCodeURL(state, opts...)
}
152 changes: 152 additions & 0 deletions reddit/reddit-oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package reddit

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)

const (
testCode = "test_code"
testAccessToken = "test_access_token"
testRefreshToken = "test_refresh_token"
testRedirectURI = "http://localhost:5000/auth" // doens't need to be a valid URL

clientId = "test_client"
clientSecret = "test_secret"

subreddit = "golang"
)

func TestAuthCodeURL(t *testing.T) {
state := "test_state"
scopes := []string{"scope_a", "scope_b"}

for _, tt := range []struct {
name string
permanent bool
}{
{"not requesting refresh token", false},
{"request refresh token", true},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := url.Parse(AuthCodeURL(clientId, testRedirectURI, state, scopes, tt.permanent))
if err != nil {
t.Fatal(err)
}

checkQueryParameter(t, got, "client_id", clientId)
checkQueryParameter(t, got, "state", state)
checkQueryParameter(t, got, "redirect_uri", testRedirectURI)
checkQueryParameter(t, got, "scope", strings.Join(scopes, " "))

if tt.permanent {
checkQueryParameter(t, got, "duration", "permanent")
} else {
checkQueryParameter(t, got, "duration", "")
}
})
}
}

func TestWebAppOauth(t *testing.T) {
srv := testRedditServer(t)
t.Cleanup(srv.Close)

for _, tt := range []struct {
name string
opt Opt
}{
{"web app with code", WithWebAppCode(testCode, testRedirectURI)},
{"web app with refresh_token", WithWebAppRefresh(testRefreshToken)},
} {
t.Run(tt.name, func(t *testing.T) {
rc, err := NewClient(
Credentials{ID: clientId, Secret: clientSecret},
WithBaseURL(srv.URL),
WithTokenURL(srv.URL+"/access_token"),
tt.opt,
)
if err != nil {
t.Fatalf("create client: %v", err)
}

// Make a request: check that the client has received the correct access token
_, _, err = rc.Subreddit.TopPosts(context.Background(), subreddit, nil)
if err != nil {
t.Errorf("make authorized request: %v", err)
}
})
}
}

// testRedditServer mocks both reddit.com (for authorization) and oauth.reddit.com (for interactions).
// It only handles a number of endpoints necessary for tests.
func testRedditServer(tb testing.TB) *httptest.Server {
mux := http.NewServeMux()

// Exchange code for access_token
mux.HandleFunc("/access_token", func(w http.ResponseWriter, r *http.Request) {
enc := json.NewEncoder(w)
w.Header().Set("Content-type", "application/json")

// Validate grant type
var ok bool
switch r.FormValue("grant_type") {
case "authorization_code":
if code := r.FormValue("code"); code == testCode {
// Actual Reddit API returns a different error message
ok = true
}
case "refresh_token":
if rt := r.FormValue("refresh_token"); rt == testRefreshToken {
ok = true
}
default:
tb.Log("unexpected grant type:", r.FormValue("grant_type"))
}

if !ok {
// Actual Reddit API returns a different error message
enc.Encode(map[string]string{"error": "bad_request"})
return
}

enc.Encode(map[string]interface{}{
"access_token": testAccessToken,
"token_type": "bearer",
"expires_in": 10 * time.Second,
"scope": "scope1,scope2",
"refresh_token": testRefreshToken,
})
})

// Return the top post for the subreddit
mux.HandleFunc("/r/"+subreddit+"/top", func(w http.ResponseWriter, r *http.Request) {
if tok := strings.TrimLeft(r.Header.Get("Authorization"), "Bearer "); tok != testAccessToken {
http.Error(w, "", http.StatusUnauthorized)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"kind": kindPost,
"data": map[string]string{}, // data not needed for the test
})
})

srv := httptest.NewServer(mux)
return srv
}

// checkQueryParameter validates URL query parameters.
func checkQueryParameter(tb testing.TB, URL *url.URL, param, want string) {
if got := URL.Query().Get(param); got != want {
tb.Errorf("%s: got %q, want %q", param, got, want)
}
}
31 changes: 31 additions & 0 deletions reddit/reddit-options.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,37 @@ func WithTokenURL(u string) Opt {
}
}

// WithWebAppCode sets webOauth parameters for the client.
// Can be used to authorize a client immediately after receiving a callback
// to the web apps' redirect URI.
// Unlike BaseURL and TokenURL, redirectURI is a required parameter,
// because it is client-specific and no sensible default can be provided.
// Changes the client's appType to WebApp.
func WithWebAppCode(code, redirectURI string) Opt {
return func(c *Client) error {
c.appType = WebApp
c.webOauth = webAppOAuthParams{
Code: code,
RedirectURI: redirectURI,
}
return nil
}
}

// WithWebAppCode sets webOauth parameters for the client. It should be used in cases
// where the client wishes to "restore" its session with a cached refresh token,
// and is therefore mutually exclusive with WithWebAppCode option.
// Changes the client's appType to WebApp.
func WithWebAppRefresh(refreshToken string) Opt {
return func(c *Client) error {
c.appType = WebApp
c.webOauth = webAppOAuthParams{
RefreshToken: refreshToken,
}
return nil
}
}

// FromEnv configures the client with values from environment variables.
// Supported environment variables:
// GO_REDDIT_CLIENT_ID to set the client's id.
Expand Down
Loading