From a0e47e2a747e3fc63446edea930a2911ac688203 Mon Sep 17 00:00:00 2001 From: Moeed ul Hassan Date: Thu, 26 Feb 2026 11:18:40 +0500 Subject: [PATCH 1/2] fix(twitterv2): upgrade provider to OAuth 2.0 with PKCE Resolves #635 by replacing mrjones/oauth with golang.org/x/oauth2, implementing PKCE required by X's Free tier API, and updating Session/Token structures accordingly. --- .git-blame-ignore-revs | 1 - .github/workflows/ci.yml | 37 - .github/workflows/codeql.yml | 44 -- .gitignore | 31 - LICENSE.txt | 22 - README.md | 158 ---- doc.go | 10 - examples/main.go | 291 ------- go.sum | 122 --- gothic/gothic.go | 316 -------- gothic/gothic_test.go | 292 ------- gothic/provider.go | 68 -- gothic/provider_test.go | 44 -- provider.go | 87 --- provider_test.go | 35 - providers/amazon/amazon.go | 166 ---- providers/amazon/amazon_test.go | 53 -- providers/amazon/session.go | 63 -- providers/amazon/session_test.go | 48 -- providers/apple/apple.go | 187 ----- providers/apple/apple_test.go | 119 --- providers/apple/session.go | 162 ---- providers/apple/session_test.go | 98 --- providers/auth0/auth0.go | 183 ----- providers/auth0/auth0_test.go | 101 --- providers/auth0/session.go | 64 -- providers/auth0/session_test.go | 48 -- providers/azuread/azuread.go | 187 ----- providers/azuread/azuread_test.go | 55 -- providers/azuread/session.go | 63 -- providers/azuread/session_test.go | 48 -- providers/azureadv2/azureadv2.go | 232 ------ providers/azureadv2/azureadv2_test.go | 62 -- providers/azureadv2/scopes.go | 717 ------------------ providers/azureadv2/session.go | 63 -- providers/azureadv2/session_test.go | 48 -- providers/battlenet/battlenet.go | 153 ---- providers/battlenet/battlenet_test.go | 53 -- providers/battlenet/session.go | 63 -- providers/battlenet/session_test.go | 48 -- providers/bitbucket/bitbucket.go | 241 ------ providers/bitbucket/bitbucket_test.go | 59 -- providers/bitbucket/session.go | 61 -- providers/bitbucket/session_test.go | 48 -- providers/bitly/bitly.go | 170 ----- providers/bitly/bitly_test.go | 52 -- providers/bitly/session.go | 59 -- providers/bitly/session_test.go | 33 - providers/box/box.go | 158 ---- providers/box/box_test.go | 53 -- providers/box/session.go | 63 -- providers/box/session_test.go | 48 -- providers/classlink/provider.go | 156 ---- providers/classlink/provider_test.go | 59 -- providers/classlink/session.go | 49 -- providers/classlink/session_test.go | 48 -- providers/cloudfoundry/cf.go | 176 ----- providers/cloudfoundry/cf_test.go | 53 -- providers/cloudfoundry/session.go | 66 -- providers/cloudfoundry/session_test.go | 48 -- providers/cognito/cognito.go | 238 ------ providers/cognito/cognito_test.go | 67 -- providers/cognito/session.go | 64 -- providers/cognito/session_test.go | 47 -- providers/dailymotion/dailymotion.go | 188 ----- providers/dailymotion/dailymotion_test.go | 53 -- providers/dailymotion/session.go | 62 -- providers/dailymotion/session_test.go | 48 -- providers/deezer/deezer.go | 179 ----- providers/deezer/deezer_test.go | 53 -- providers/deezer/session.go | 66 -- providers/deezer/session_test.go | 48 -- providers/digitalocean/digitalocean.go | 177 ----- providers/digitalocean/digitalocean_test.go | 51 -- providers/digitalocean/session.go | 63 -- providers/digitalocean/session_test.go | 39 - providers/dingtalk/dingtalk.go | 400 ---------- providers/dingtalk/dingtalk_test.go | 53 -- providers/dingtalk/session.go | 139 ---- providers/dingtalk/session_test.go | 57 -- providers/discord/discord.go | 236 ------ providers/discord/discord_test.go | 54 -- providers/discord/session.go | 66 -- providers/discord/session_test.go | 38 - providers/dropbox/dropbox.go | 211 ------ providers/dropbox/dropbox_test.go | 166 ---- providers/eveonline/eveonline.go | 162 ---- providers/eveonline/eveonline_test.go | 53 -- providers/eveonline/session.go | 63 -- providers/eveonline/session_test.go | 48 -- providers/facebook/facebook.go | 215 ------ providers/facebook/facebook_test.go | 72 -- providers/facebook/session.go | 59 -- providers/facebook/session_test.go | 48 -- providers/faux/README.md | 3 - providers/faux/faux.go | 110 --- providers/fitbit/fitbit.go | 195 ----- providers/fitbit/fitbit_test.go | 55 -- providers/fitbit/session.go | 61 -- providers/fitbit/session_test.go | 38 - providers/gitea/gitea.go | 186 ----- providers/gitea/gitea_test.go | 67 -- providers/gitea/session.go | 63 -- providers/gitea/session_test.go | 48 -- providers/github/github.go | 244 ------ providers/github/github_test.go | 73 -- providers/github/session.go | 56 -- providers/github/session_test.go | 48 -- providers/gitlab/gitlab.go | 187 ----- providers/gitlab/gitlab_test.go | 67 -- providers/gitlab/session.go | 63 -- providers/gitlab/session_test.go | 48 -- providers/google/endpoint.go | 11 - providers/google/endpoint_legacy.go | 14 - providers/google/google.go | 223 ------ providers/google/google_test.go | 152 ---- providers/google/session.go | 65 -- providers/google/session_test.go | 48 -- providers/heroku/heroku.go | 157 ---- providers/heroku/heroku_test.go | 53 -- providers/heroku/session.go | 63 -- providers/heroku/session_test.go | 48 -- providers/hubspot/hubspot.go | 174 ----- providers/hubspot/hubspot_test.go | 53 -- providers/hubspot/session.go | 60 -- providers/hubspot/session_test.go | 48 -- providers/influxcloud/influxcloud.go | 180 ----- providers/influxcloud/influxcloud_test.go | 89 --- providers/influxcloud/session.go | 58 -- providers/influxcloud/session_test.go | 48 -- providers/instagram/instagram.go | 173 ----- providers/instagram/instagram_test.go | 56 -- providers/instagram/session.go | 56 -- providers/instagram/session_test.go | 48 -- providers/intercom/intercom.go | 181 ----- providers/intercom/intercom_test.go | 143 ---- providers/intercom/session.go | 60 -- providers/intercom/session_test.go | 48 -- providers/kakao/kakao.go | 162 ---- providers/kakao/kakao_test.go | 53 -- providers/kakao/session.go | 65 -- providers/kakao/session_test.go | 48 -- providers/lark/lark.go | 307 -------- providers/lark/lark_test.go | 185 ----- providers/lark/session.go | 71 -- providers/lark/session_test.go | 112 --- providers/lastfm/lastfm.go | 230 ------ providers/lastfm/lastfm_test.go | 59 -- providers/lastfm/session.go | 54 -- providers/lastfm/session_test.go | 47 -- providers/line/line.go | 196 ----- providers/line/line_test.go | 65 -- providers/line/session.go | 69 -- providers/line/session_test.go | 48 -- providers/linkedin/linkedin.go | 278 ------- providers/linkedin/linkedin_test.go | 59 -- providers/linkedin/session.go | 58 -- providers/linkedin/session_test.go | 48 -- providers/mailru/mailru.go | 138 ---- providers/mailru/mailru_test.go | 66 -- providers/mailru/session.go | 59 -- providers/mailru/session_test.go | 40 - providers/mastodon/mastodon.go | 184 ----- providers/mastodon/mastodon_test.go | 67 -- providers/mastodon/session.go | 63 -- providers/mastodon/session_test.go | 48 -- providers/meetup/meetup.go | 196 ----- providers/meetup/meetup_test.go | 53 -- providers/meetup/session.go | 63 -- providers/meetup/session_test.go | 48 -- providers/microsoftonline/microsoftonline.go | 190 ----- .../microsoftonline/microsoftonline_test.go | 54 -- providers/microsoftonline/session.go | 62 -- providers/microsoftonline/session_test.go | 54 -- providers/naver/naver.go | 172 ----- providers/naver/naver_test.go | 56 -- providers/naver/session.go | 61 -- providers/naver/session_test.go | 53 -- providers/nextcloud/README.md | 85 --- providers/nextcloud/nextcloud.go | 205 ----- providers/nextcloud/nextcloud_setup.png | Bin 85944 -> 0 bytes providers/nextcloud/nextcloud_test.go | 72 -- providers/nextcloud/session.go | 63 -- providers/nextcloud/session_test.go | 48 -- providers/okta/okta.go | 197 ----- providers/okta/okta_test.go | 67 -- providers/okta/session.go | 64 -- providers/okta/session_test.go | 48 -- providers/onedrive/onedrive.go | 163 ---- providers/onedrive/onedrive_test.go | 53 -- providers/onedrive/session.go | 63 -- providers/onedrive/session_test.go | 48 -- providers/openidConnect/openidConnect.go | 529 ------------- providers/openidConnect/openidConnect_test.go | 123 --- providers/openidConnect/session.go | 81 -- providers/openidConnect/session_test.go | 47 -- providers/oura/errors.go | 16 - providers/oura/oura.go | 191 ----- providers/oura/oura_test.go | 55 -- providers/oura/session.go | 64 -- providers/oura/session_test.go | 38 - providers/patreon/patreon.go | 219 ------ providers/patreon/patreon_test.go | 53 -- providers/patreon/session.go | 63 -- providers/patreon/session_test.go | 37 - providers/paypal/paypal.go | 199 ----- providers/paypal/paypal_test.go | 67 -- providers/paypal/session.go | 63 -- providers/paypal/session_test.go | 48 -- providers/reddit/reddit.go | 137 ---- providers/reddit/reddit_test.go | 88 --- providers/reddit/session.go | 46 -- providers/reddit/session_test.go | 122 --- providers/salesforce/salesforce.go | 191 ----- providers/salesforce/salesforce_test.go | 53 -- providers/salesforce/session.go | 72 -- providers/salesforce/session_test.go | 48 -- providers/seatalk/seatalk.go | 161 ---- providers/seatalk/seatalk_test.go | 53 -- providers/seatalk/session.go | 54 -- providers/seatalk/session_test.go | 48 -- providers/shopify/scopes.go | 49 -- providers/shopify/session.go | 103 --- providers/shopify/session_test.go | 48 -- providers/shopify/shopify.go | 192 ----- providers/shopify/shopify_test.go | 55 -- providers/slack/session.go | 63 -- providers/slack/session_test.go | 48 -- providers/slack/slack.go | 236 ------ providers/slack/slack_test.go | 236 ------ providers/soundcloud/session.go | 63 -- providers/soundcloud/session_test.go | 48 -- providers/soundcloud/soundcloud.go | 169 ----- providers/soundcloud/soundcloud_test.go | 53 -- providers/spotify/session.go | 63 -- providers/spotify/session_test.go | 38 - providers/spotify/spotify.go | 224 ------ providers/spotify/spotify_test.go | 54 -- providers/steam/session.go | 100 --- providers/steam/session_test.go | 48 -- providers/steam/steam.go | 199 ----- providers/steam/steam_test.go | 55 -- providers/strava/session.go | 61 -- providers/strava/session_test.go | 48 -- providers/strava/strava.go | 182 ----- providers/strava/strava_test.go | 59 -- providers/stripe/session.go | 65 -- providers/stripe/session_test.go | 48 -- providers/stripe/stripe.go | 164 ---- providers/stripe/stripe_test.go | 53 -- providers/tiktok/session.go | 104 --- providers/tiktok/session_test.go | 48 -- providers/tiktok/tiktok.go | 278 ------- providers/tiktok/tiktok_test.go | 59 -- providers/tumblr/session.go | 54 -- providers/tumblr/tumblr.go | 152 ---- providers/twitch/session.go | 65 -- providers/twitch/session_test.go | 38 - providers/twitch/twitch.go | 369 --------- providers/twitch/twitch_test.go | 54 -- providers/twitter/session.go | 54 -- providers/twitter/session_test.go | 48 -- providers/twitter/twitter.go | 167 ---- providers/twitter/twitter_test.go | 120 --- providers/twitterv2/session.go | 27 +- providers/twitterv2/session_test.go | 2 +- providers/twitterv2/twitterv2.go | 126 +-- providers/twitterv2/twitterv2_test.go | 35 +- providers/typetalk/session.go | 63 -- providers/typetalk/session_test.go | 48 -- providers/typetalk/typetalk.go | 205 ----- providers/typetalk/typetalk_test.go | 53 -- providers/uber/session.go | 63 -- providers/uber/session_test.go | 48 -- providers/uber/uber.go | 161 ---- providers/uber/uber_test.go | 53 -- providers/vk/session.go | 62 -- providers/vk/session_test.go | 40 - providers/vk/vk.go | 183 ----- providers/vk/vk_test.go | 76 -- providers/wechat/session.go | 67 -- providers/wechat/session_test.go | 48 -- providers/wechat/wechat.go | 237 ------ providers/wechat/wechat_test.go | 53 -- providers/wecom/session.go | 55 -- providers/wecom/session_test.go | 40 - providers/wecom/wecom.go | 217 ------ providers/wecom/wecom_test.go | 61 -- providers/wepay/session.go | 65 -- providers/wepay/session_test.go | 48 -- providers/wepay/wepay.go | 155 ---- providers/wepay/wepay_test.go | 53 -- providers/xero/session.go | 61 -- providers/xero/session_test.go | 48 -- providers/xero/xero.go | 260 ------- providers/xero/xero_test.go | 124 --- providers/yahoo/session.go | 63 -- providers/yahoo/session_test.go | 48 -- providers/yahoo/yahoo.go | 166 ---- providers/yahoo/yahoo_test.go | 53 -- providers/yammer/session.go | 109 --- providers/yammer/session_test.go | 48 -- providers/yammer/yammer.go | 160 ---- providers/yammer/yammer_test.go | 53 -- providers/yandex/session.go | 64 -- providers/yandex/session_test.go | 48 -- providers/yandex/yandex.go | 182 ----- providers/yandex/yandex_test.go | 61 -- providers/zoom/session.go | 79 -- providers/zoom/session_test.go | 48 -- providers/zoom/zoom.go | 178 ----- providers/zoom/zoom_test.go | 55 -- session.go | 21 - user.go | 31 - user_test.go | 1 - 315 files changed, 116 insertions(+), 30552 deletions(-) delete mode 100644 .git-blame-ignore-revs delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/codeql.yml delete mode 100644 .gitignore delete mode 100644 LICENSE.txt delete mode 100644 README.md delete mode 100644 doc.go delete mode 100644 examples/main.go delete mode 100644 go.sum delete mode 100644 gothic/gothic.go delete mode 100644 gothic/gothic_test.go delete mode 100644 gothic/provider.go delete mode 100644 gothic/provider_test.go delete mode 100644 provider.go delete mode 100644 provider_test.go delete mode 100644 providers/amazon/amazon.go delete mode 100644 providers/amazon/amazon_test.go delete mode 100644 providers/amazon/session.go delete mode 100644 providers/amazon/session_test.go delete mode 100644 providers/apple/apple.go delete mode 100644 providers/apple/apple_test.go delete mode 100644 providers/apple/session.go delete mode 100644 providers/apple/session_test.go delete mode 100644 providers/auth0/auth0.go delete mode 100644 providers/auth0/auth0_test.go delete mode 100644 providers/auth0/session.go delete mode 100644 providers/auth0/session_test.go delete mode 100644 providers/azuread/azuread.go delete mode 100644 providers/azuread/azuread_test.go delete mode 100644 providers/azuread/session.go delete mode 100644 providers/azuread/session_test.go delete mode 100644 providers/azureadv2/azureadv2.go delete mode 100644 providers/azureadv2/azureadv2_test.go delete mode 100644 providers/azureadv2/scopes.go delete mode 100644 providers/azureadv2/session.go delete mode 100644 providers/azureadv2/session_test.go delete mode 100644 providers/battlenet/battlenet.go delete mode 100644 providers/battlenet/battlenet_test.go delete mode 100644 providers/battlenet/session.go delete mode 100644 providers/battlenet/session_test.go delete mode 100644 providers/bitbucket/bitbucket.go delete mode 100644 providers/bitbucket/bitbucket_test.go delete mode 100644 providers/bitbucket/session.go delete mode 100644 providers/bitbucket/session_test.go delete mode 100644 providers/bitly/bitly.go delete mode 100644 providers/bitly/bitly_test.go delete mode 100644 providers/bitly/session.go delete mode 100644 providers/bitly/session_test.go delete mode 100644 providers/box/box.go delete mode 100644 providers/box/box_test.go delete mode 100644 providers/box/session.go delete mode 100644 providers/box/session_test.go delete mode 100644 providers/classlink/provider.go delete mode 100644 providers/classlink/provider_test.go delete mode 100644 providers/classlink/session.go delete mode 100644 providers/classlink/session_test.go delete mode 100644 providers/cloudfoundry/cf.go delete mode 100644 providers/cloudfoundry/cf_test.go delete mode 100644 providers/cloudfoundry/session.go delete mode 100644 providers/cloudfoundry/session_test.go delete mode 100644 providers/cognito/cognito.go delete mode 100644 providers/cognito/cognito_test.go delete mode 100644 providers/cognito/session.go delete mode 100644 providers/cognito/session_test.go delete mode 100644 providers/dailymotion/dailymotion.go delete mode 100644 providers/dailymotion/dailymotion_test.go delete mode 100644 providers/dailymotion/session.go delete mode 100644 providers/dailymotion/session_test.go delete mode 100644 providers/deezer/deezer.go delete mode 100644 providers/deezer/deezer_test.go delete mode 100644 providers/deezer/session.go delete mode 100644 providers/deezer/session_test.go delete mode 100644 providers/digitalocean/digitalocean.go delete mode 100644 providers/digitalocean/digitalocean_test.go delete mode 100644 providers/digitalocean/session.go delete mode 100644 providers/digitalocean/session_test.go delete mode 100644 providers/dingtalk/dingtalk.go delete mode 100644 providers/dingtalk/dingtalk_test.go delete mode 100644 providers/dingtalk/session.go delete mode 100644 providers/dingtalk/session_test.go delete mode 100644 providers/discord/discord.go delete mode 100644 providers/discord/discord_test.go delete mode 100644 providers/discord/session.go delete mode 100644 providers/discord/session_test.go delete mode 100644 providers/dropbox/dropbox.go delete mode 100644 providers/dropbox/dropbox_test.go delete mode 100644 providers/eveonline/eveonline.go delete mode 100644 providers/eveonline/eveonline_test.go delete mode 100644 providers/eveonline/session.go delete mode 100644 providers/eveonline/session_test.go delete mode 100644 providers/facebook/facebook.go delete mode 100644 providers/facebook/facebook_test.go delete mode 100644 providers/facebook/session.go delete mode 100644 providers/facebook/session_test.go delete mode 100644 providers/faux/README.md delete mode 100644 providers/faux/faux.go delete mode 100644 providers/fitbit/fitbit.go delete mode 100644 providers/fitbit/fitbit_test.go delete mode 100644 providers/fitbit/session.go delete mode 100644 providers/fitbit/session_test.go delete mode 100644 providers/gitea/gitea.go delete mode 100644 providers/gitea/gitea_test.go delete mode 100644 providers/gitea/session.go delete mode 100644 providers/gitea/session_test.go delete mode 100644 providers/github/github.go delete mode 100644 providers/github/github_test.go delete mode 100644 providers/github/session.go delete mode 100644 providers/github/session_test.go delete mode 100644 providers/gitlab/gitlab.go delete mode 100644 providers/gitlab/gitlab_test.go delete mode 100644 providers/gitlab/session.go delete mode 100644 providers/gitlab/session_test.go delete mode 100644 providers/google/endpoint.go delete mode 100644 providers/google/endpoint_legacy.go delete mode 100644 providers/google/google.go delete mode 100644 providers/google/google_test.go delete mode 100644 providers/google/session.go delete mode 100644 providers/google/session_test.go delete mode 100644 providers/heroku/heroku.go delete mode 100644 providers/heroku/heroku_test.go delete mode 100644 providers/heroku/session.go delete mode 100644 providers/heroku/session_test.go delete mode 100644 providers/hubspot/hubspot.go delete mode 100644 providers/hubspot/hubspot_test.go delete mode 100644 providers/hubspot/session.go delete mode 100644 providers/hubspot/session_test.go delete mode 100644 providers/influxcloud/influxcloud.go delete mode 100644 providers/influxcloud/influxcloud_test.go delete mode 100644 providers/influxcloud/session.go delete mode 100644 providers/influxcloud/session_test.go delete mode 100644 providers/instagram/instagram.go delete mode 100644 providers/instagram/instagram_test.go delete mode 100644 providers/instagram/session.go delete mode 100644 providers/instagram/session_test.go delete mode 100644 providers/intercom/intercom.go delete mode 100644 providers/intercom/intercom_test.go delete mode 100644 providers/intercom/session.go delete mode 100644 providers/intercom/session_test.go delete mode 100644 providers/kakao/kakao.go delete mode 100644 providers/kakao/kakao_test.go delete mode 100644 providers/kakao/session.go delete mode 100644 providers/kakao/session_test.go delete mode 100644 providers/lark/lark.go delete mode 100644 providers/lark/lark_test.go delete mode 100644 providers/lark/session.go delete mode 100644 providers/lark/session_test.go delete mode 100644 providers/lastfm/lastfm.go delete mode 100644 providers/lastfm/lastfm_test.go delete mode 100644 providers/lastfm/session.go delete mode 100644 providers/lastfm/session_test.go delete mode 100644 providers/line/line.go delete mode 100644 providers/line/line_test.go delete mode 100644 providers/line/session.go delete mode 100644 providers/line/session_test.go delete mode 100644 providers/linkedin/linkedin.go delete mode 100644 providers/linkedin/linkedin_test.go delete mode 100644 providers/linkedin/session.go delete mode 100644 providers/linkedin/session_test.go delete mode 100644 providers/mailru/mailru.go delete mode 100644 providers/mailru/mailru_test.go delete mode 100644 providers/mailru/session.go delete mode 100644 providers/mailru/session_test.go delete mode 100644 providers/mastodon/mastodon.go delete mode 100644 providers/mastodon/mastodon_test.go delete mode 100644 providers/mastodon/session.go delete mode 100644 providers/mastodon/session_test.go delete mode 100644 providers/meetup/meetup.go delete mode 100644 providers/meetup/meetup_test.go delete mode 100644 providers/meetup/session.go delete mode 100644 providers/meetup/session_test.go delete mode 100644 providers/microsoftonline/microsoftonline.go delete mode 100644 providers/microsoftonline/microsoftonline_test.go delete mode 100644 providers/microsoftonline/session.go delete mode 100644 providers/microsoftonline/session_test.go delete mode 100644 providers/naver/naver.go delete mode 100644 providers/naver/naver_test.go delete mode 100644 providers/naver/session.go delete mode 100644 providers/naver/session_test.go delete mode 100644 providers/nextcloud/README.md delete mode 100644 providers/nextcloud/nextcloud.go delete mode 100644 providers/nextcloud/nextcloud_setup.png delete mode 100644 providers/nextcloud/nextcloud_test.go delete mode 100644 providers/nextcloud/session.go delete mode 100644 providers/nextcloud/session_test.go delete mode 100644 providers/okta/okta.go delete mode 100644 providers/okta/okta_test.go delete mode 100644 providers/okta/session.go delete mode 100644 providers/okta/session_test.go delete mode 100644 providers/onedrive/onedrive.go delete mode 100644 providers/onedrive/onedrive_test.go delete mode 100644 providers/onedrive/session.go delete mode 100644 providers/onedrive/session_test.go delete mode 100644 providers/openidConnect/openidConnect.go delete mode 100644 providers/openidConnect/openidConnect_test.go delete mode 100644 providers/openidConnect/session.go delete mode 100644 providers/openidConnect/session_test.go delete mode 100644 providers/oura/errors.go delete mode 100644 providers/oura/oura.go delete mode 100644 providers/oura/oura_test.go delete mode 100644 providers/oura/session.go delete mode 100644 providers/oura/session_test.go delete mode 100644 providers/patreon/patreon.go delete mode 100644 providers/patreon/patreon_test.go delete mode 100644 providers/patreon/session.go delete mode 100644 providers/patreon/session_test.go delete mode 100644 providers/paypal/paypal.go delete mode 100644 providers/paypal/paypal_test.go delete mode 100644 providers/paypal/session.go delete mode 100644 providers/paypal/session_test.go delete mode 100644 providers/reddit/reddit.go delete mode 100644 providers/reddit/reddit_test.go delete mode 100644 providers/reddit/session.go delete mode 100644 providers/reddit/session_test.go delete mode 100644 providers/salesforce/salesforce.go delete mode 100644 providers/salesforce/salesforce_test.go delete mode 100644 providers/salesforce/session.go delete mode 100644 providers/salesforce/session_test.go delete mode 100644 providers/seatalk/seatalk.go delete mode 100644 providers/seatalk/seatalk_test.go delete mode 100644 providers/seatalk/session.go delete mode 100644 providers/seatalk/session_test.go delete mode 100644 providers/shopify/scopes.go delete mode 100755 providers/shopify/session.go delete mode 100755 providers/shopify/session_test.go delete mode 100755 providers/shopify/shopify.go delete mode 100755 providers/shopify/shopify_test.go delete mode 100644 providers/slack/session.go delete mode 100644 providers/slack/session_test.go delete mode 100644 providers/slack/slack.go delete mode 100644 providers/slack/slack_test.go delete mode 100644 providers/soundcloud/session.go delete mode 100644 providers/soundcloud/session_test.go delete mode 100644 providers/soundcloud/soundcloud.go delete mode 100644 providers/soundcloud/soundcloud_test.go delete mode 100644 providers/spotify/session.go delete mode 100644 providers/spotify/session_test.go delete mode 100644 providers/spotify/spotify.go delete mode 100644 providers/spotify/spotify_test.go delete mode 100644 providers/steam/session.go delete mode 100644 providers/steam/session_test.go delete mode 100644 providers/steam/steam.go delete mode 100644 providers/steam/steam_test.go delete mode 100644 providers/strava/session.go delete mode 100644 providers/strava/session_test.go delete mode 100644 providers/strava/strava.go delete mode 100644 providers/strava/strava_test.go delete mode 100644 providers/stripe/session.go delete mode 100644 providers/stripe/session_test.go delete mode 100644 providers/stripe/stripe.go delete mode 100644 providers/stripe/stripe_test.go delete mode 100644 providers/tiktok/session.go delete mode 100644 providers/tiktok/session_test.go delete mode 100644 providers/tiktok/tiktok.go delete mode 100644 providers/tiktok/tiktok_test.go delete mode 100644 providers/tumblr/session.go delete mode 100644 providers/tumblr/tumblr.go delete mode 100644 providers/twitch/session.go delete mode 100644 providers/twitch/session_test.go delete mode 100644 providers/twitch/twitch.go delete mode 100644 providers/twitch/twitch_test.go delete mode 100644 providers/twitter/session.go delete mode 100644 providers/twitter/session_test.go delete mode 100644 providers/twitter/twitter.go delete mode 100644 providers/twitter/twitter_test.go delete mode 100644 providers/typetalk/session.go delete mode 100644 providers/typetalk/session_test.go delete mode 100644 providers/typetalk/typetalk.go delete mode 100644 providers/typetalk/typetalk_test.go delete mode 100644 providers/uber/session.go delete mode 100644 providers/uber/session_test.go delete mode 100644 providers/uber/uber.go delete mode 100644 providers/uber/uber_test.go delete mode 100644 providers/vk/session.go delete mode 100644 providers/vk/session_test.go delete mode 100644 providers/vk/vk.go delete mode 100644 providers/vk/vk_test.go delete mode 100644 providers/wechat/session.go delete mode 100644 providers/wechat/session_test.go delete mode 100644 providers/wechat/wechat.go delete mode 100644 providers/wechat/wechat_test.go delete mode 100644 providers/wecom/session.go delete mode 100644 providers/wecom/session_test.go delete mode 100644 providers/wecom/wecom.go delete mode 100644 providers/wecom/wecom_test.go delete mode 100644 providers/wepay/session.go delete mode 100644 providers/wepay/session_test.go delete mode 100644 providers/wepay/wepay.go delete mode 100644 providers/wepay/wepay_test.go delete mode 100644 providers/xero/session.go delete mode 100644 providers/xero/session_test.go delete mode 100644 providers/xero/xero.go delete mode 100644 providers/xero/xero_test.go delete mode 100644 providers/yahoo/session.go delete mode 100644 providers/yahoo/session_test.go delete mode 100644 providers/yahoo/yahoo.go delete mode 100644 providers/yahoo/yahoo_test.go delete mode 100644 providers/yammer/session.go delete mode 100644 providers/yammer/session_test.go delete mode 100644 providers/yammer/yammer.go delete mode 100644 providers/yammer/yammer_test.go delete mode 100644 providers/yandex/session.go delete mode 100644 providers/yandex/session_test.go delete mode 100644 providers/yandex/yandex.go delete mode 100644 providers/yandex/yandex_test.go delete mode 100644 providers/zoom/session.go delete mode 100644 providers/zoom/session_test.go delete mode 100644 providers/zoom/zoom.go delete mode 100644 providers/zoom/zoom_test.go delete mode 100644 session.go delete mode 100644 user.go delete mode 100644 user_test.go diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index aac1886aa..000000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1 +0,0 @@ -042f5311fcab71f9bd8ac33c1e25597799eb34d7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 22d230011..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,37 +0,0 @@ -on: - push: - branches: - - master - pull_request: - branches: - - master - workflow_dispatch: - -name: ci - -jobs: - test: - strategy: - matrix: - go-version: [ 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x ] - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - - name: Checkout code - uses: actions/checkout@v4 - - name: Restore cache - uses: actions/cache@v3 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Format - run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi - if: matrix.os != 'windows-latest' && matrix.go-version == '1.24.x' - - name: Test - run: go test -race ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index fca62a5ee..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: "CodeQL Advanced" - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - schedule: - - cron: '43 17 * * 2' - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: 'ubuntu-latest' - permissions: - # required for all workflows - security-events: write - # required to fetch internal or private CodeQL packs - packages: read - strategy: - fail-fast: false - matrix: - include: - - language: actions - build-mode: none - - language: go - build-mode: autobuild - go-version: [1.26.x] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 5ce409a2f..000000000 --- a/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -*.log -.DS_Store -doc -tmp -pkg -*.gem -*.pid -coverage -coverage.data -build/* -*.pbxuser -*.mode1v3 -.svn -profile -.console_history -.sass-cache/* -.rake_tasks~ -*.log.lck -solr/ -.jhw-cache/ -jhw.* -*.sublime* -node_modules/ -dist/ -generated/ -.vendor/ -vendor -*.swp -.vscode/launch.json -.vscode/settings.json -.idea diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index f8e6d5b27..000000000 --- a/LICENSE.txt +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2014 Mark Bates - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 95a432f8b..000000000 --- a/README.md +++ /dev/null @@ -1,158 +0,0 @@ -# Goth: Multi-Provider Authentication for Go [![GoDoc](https://godoc.org/github.com/markbates/goth?status.svg)](https://godoc.org/github.com/markbates/goth) [![Build Status](https://github.com/markbates/goth/workflows/ci/badge.svg)](https://github.com/markbates/goth/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/markbates/goth)](https://goreportcard.com/report/github.com/markbates/goth) - -Package goth provides a simple, clean, and idiomatic way to write authentication -packages for Go web applications. - -Unlike other similar packages, Goth, lets you write OAuth, OAuth2, or any other -protocol providers, as long as they implement the [Provider](https://github.com/markbates/goth/blob/master/provider.go#L13-L22) and [Session](https://github.com/markbates/goth/blob/master/session.go#L13-L21) interfaces. - -This package was inspired by [https://github.com/intridea/omniauth](https://github.com/intridea/omniauth). - -## Installation - -```text -$ go get github.com/markbates/goth -``` - -## Supported Providers - -* Amazon -* Apple -* Auth0 -* Azure AD -* Battle.net -* Bitbucket -* Box -* ClassLink -* Cloud Foundry -* Dailymotion -* Deezer -* DigitalOcean -* DingTalk -* Discord -* Dropbox -* Eve Online -* Facebook -* Fitbit -* Gitea -* GitHub -* Gitlab -* Google -* Heroku -* InfluxCloud -* Instagram -* Intercom -* Kakao -* Lastfm -* LINE -* Linkedin -* Mailru -* Meetup -* MicrosoftOnline -* Naver -* Nextcloud -* Okta -* OneDrive -* OpenID Connect (auto discovery) -* Oura -* Patreon -* Paypal -* Reddit -* SalesForce -* Shopify -* Slack -* Soundcloud -* Spotify -* Steam -* Strava -* Stripe -* TikTok -* Tumblr -* Twitch -* Twitter -* Typetalk -* Uber -* VK -* WeCom -* Wepay -* Xero -* Yahoo -* Yammer -* Yandex -* Zoom - -## Examples - -See the [examples](examples) folder for a working application that lets users authenticate -through Twitter, Facebook, Google Plus etc. - -To run the example either clone the source from GitHub - -```text -$ git clone git@github.com:markbates/goth.git -``` -or use -```text -$ go get github.com/markbates/goth -``` -```text -$ cd goth/examples -$ go get -v -$ go build -$ ./examples -``` - -Now open up your browser and go to [http://localhost:3000](http://localhost:3000) to see the example. - -To actually use the different providers, please make sure you set environment variables. Example given in the examples/main.go file - -## Security Notes - -By default, gothic uses a `CookieStore` from the `gorilla/sessions` package to store session data. - -As configured, this default store (`gothic.Store`) will generate cookies with `Options`: - -```go -&Options{ - Path: "/", - Domain: "", - MaxAge: 86400 * 30, - HttpOnly: true, - Secure: false, - } -``` - -To tailor these fields for your application, you can override the `gothic.Store` variable at startup. - -The following snippet shows one way to do this: - -```go -key := "" // Replace with your SESSION_SECRET or similar -maxAge := 86400 * 30 // 30 days -isProd := false // Set to true when serving over https - -store := sessions.NewCookieStore([]byte(key)) -store.MaxAge(maxAge) -store.Options.Path = "/" -store.Options.HttpOnly = true // HttpOnly should always be enabled -store.Options.Secure = isProd - -gothic.Store = store -``` - -## Issues - -Issues always stand a significantly better chance of getting fixed if they are accompanied by a -pull request. - -## Contributing - -Would I love to see more providers? Certainly! Would you love to contribute one? Hopefully, yes! - -1. Fork it -2. Create your feature branch (git checkout -b my-new-feature) -3. Write Tests! -4. Make sure the codebase adhere to the Go coding standards by executing `gofmt -s -w ./` -5. Commit your changes (git commit -am 'Add some feature') -6. Push to the branch (git push origin my-new-feature) -7. Create new Pull Request diff --git a/doc.go b/doc.go deleted file mode 100644 index d0bec281c..000000000 --- a/doc.go +++ /dev/null @@ -1,10 +0,0 @@ -/* -Package goth provides a simple, clean, and idiomatic way to write authentication -packages for Go web applications. - -This package was inspired by https://github.com/intridea/omniauth. - -See the examples folder for a working application that lets users authenticate -through Twitter or Facebook. -*/ -package goth diff --git a/examples/main.go b/examples/main.go deleted file mode 100644 index f72938152..000000000 --- a/examples/main.go +++ /dev/null @@ -1,291 +0,0 @@ -package main - -import ( - "fmt" - "html/template" - "log" - "net/http" - "os" - "sort" - - "github.com/gorilla/pat" - "github.com/markbates/goth" - "github.com/markbates/goth/gothic" - "github.com/markbates/goth/providers/amazon" - "github.com/markbates/goth/providers/apple" - "github.com/markbates/goth/providers/auth0" - "github.com/markbates/goth/providers/azuread" - "github.com/markbates/goth/providers/battlenet" - "github.com/markbates/goth/providers/bitbucket" - "github.com/markbates/goth/providers/box" - "github.com/markbates/goth/providers/dailymotion" - "github.com/markbates/goth/providers/deezer" - "github.com/markbates/goth/providers/digitalocean" - "github.com/markbates/goth/providers/dingtalk" - "github.com/markbates/goth/providers/discord" - "github.com/markbates/goth/providers/dropbox" - "github.com/markbates/goth/providers/eveonline" - "github.com/markbates/goth/providers/facebook" - "github.com/markbates/goth/providers/fitbit" - "github.com/markbates/goth/providers/gitea" - "github.com/markbates/goth/providers/github" - "github.com/markbates/goth/providers/gitlab" - "github.com/markbates/goth/providers/google" - "github.com/markbates/goth/providers/heroku" - "github.com/markbates/goth/providers/instagram" - "github.com/markbates/goth/providers/intercom" - "github.com/markbates/goth/providers/kakao" - "github.com/markbates/goth/providers/lastfm" - "github.com/markbates/goth/providers/line" - "github.com/markbates/goth/providers/linkedin" - "github.com/markbates/goth/providers/mastodon" - "github.com/markbates/goth/providers/meetup" - "github.com/markbates/goth/providers/microsoftonline" - "github.com/markbates/goth/providers/naver" - "github.com/markbates/goth/providers/nextcloud" - "github.com/markbates/goth/providers/okta" - "github.com/markbates/goth/providers/onedrive" - "github.com/markbates/goth/providers/openidConnect" - "github.com/markbates/goth/providers/patreon" - "github.com/markbates/goth/providers/paypal" - "github.com/markbates/goth/providers/salesforce" - "github.com/markbates/goth/providers/seatalk" - "github.com/markbates/goth/providers/shopify" - "github.com/markbates/goth/providers/slack" - "github.com/markbates/goth/providers/soundcloud" - "github.com/markbates/goth/providers/spotify" - "github.com/markbates/goth/providers/steam" - "github.com/markbates/goth/providers/strava" - "github.com/markbates/goth/providers/stripe" - "github.com/markbates/goth/providers/tiktok" - "github.com/markbates/goth/providers/twitch" - "github.com/markbates/goth/providers/twitter" - "github.com/markbates/goth/providers/twitterv2" - "github.com/markbates/goth/providers/typetalk" - "github.com/markbates/goth/providers/uber" - "github.com/markbates/goth/providers/vk" - "github.com/markbates/goth/providers/wecom" - "github.com/markbates/goth/providers/wepay" - "github.com/markbates/goth/providers/xero" - "github.com/markbates/goth/providers/yahoo" - "github.com/markbates/goth/providers/yammer" - "github.com/markbates/goth/providers/yandex" - "github.com/markbates/goth/providers/zoom" -) - -func main() { - goth.UseProviders( - // Use twitterv2 instead of twitter if you only have access to the Essential API Level - // the twitter provider uses a v1.1 API that is not available to the Essential Level - twitterv2.New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitterv2/callback"), - // If you'd like to use authenticate instead of authorize in TwitterV2 provider, use this instead. - // twitterv2.NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitterv2/callback"), - - twitter.New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitter/callback"), - // If you'd like to use authenticate instead of authorize in Twitter provider, use this instead. - // twitter.NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitter/callback"), - - tiktok.New(os.Getenv("TIKTOK_KEY"), os.Getenv("TIKTOK_SECRET"), "http://localhost:3000/auth/tiktok/callback"), - facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "http://localhost:3000/auth/facebook/callback"), - fitbit.New(os.Getenv("FITBIT_KEY"), os.Getenv("FITBIT_SECRET"), "http://localhost:3000/auth/fitbit/callback"), - google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGLE_SECRET"), "http://localhost:3000/auth/google/callback"), - github.New(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "http://localhost:3000/auth/github/callback"), - spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "http://localhost:3000/auth/spotify/callback"), - linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "http://localhost:3000/auth/linkedin/callback"), - line.New(os.Getenv("LINE_KEY"), os.Getenv("LINE_SECRET"), "http://localhost:3000/auth/line/callback", "profile", "openid", "email"), - lastfm.New(os.Getenv("LASTFM_KEY"), os.Getenv("LASTFM_SECRET"), "http://localhost:3000/auth/lastfm/callback"), - twitch.New(os.Getenv("TWITCH_KEY"), os.Getenv("TWITCH_SECRET"), "http://localhost:3000/auth/twitch/callback"), - dropbox.New(os.Getenv("DROPBOX_KEY"), os.Getenv("DROPBOX_SECRET"), "http://localhost:3000/auth/dropbox/callback"), - digitalocean.New(os.Getenv("DIGITALOCEAN_KEY"), os.Getenv("DIGITALOCEAN_SECRET"), "http://localhost:3000/auth/digitalocean/callback", "read"), - bitbucket.New(os.Getenv("BITBUCKET_KEY"), os.Getenv("BITBUCKET_SECRET"), "http://localhost:3000/auth/bitbucket/callback"), - instagram.New(os.Getenv("INSTAGRAM_KEY"), os.Getenv("INSTAGRAM_SECRET"), "http://localhost:3000/auth/instagram/callback"), - intercom.New(os.Getenv("INTERCOM_KEY"), os.Getenv("INTERCOM_SECRET"), "http://localhost:3000/auth/intercom/callback"), - box.New(os.Getenv("BOX_KEY"), os.Getenv("BOX_SECRET"), "http://localhost:3000/auth/box/callback"), - salesforce.New(os.Getenv("SALESFORCE_KEY"), os.Getenv("SALESFORCE_SECRET"), "http://localhost:3000/auth/salesforce/callback"), - seatalk.New(os.Getenv("SEATALK_KEY"), os.Getenv("SEATALK_SECRET"), "http://localhost:3000/auth/seatalk/callback"), - amazon.New(os.Getenv("AMAZON_KEY"), os.Getenv("AMAZON_SECRET"), "http://localhost:3000/auth/amazon/callback"), - yammer.New(os.Getenv("YAMMER_KEY"), os.Getenv("YAMMER_SECRET"), "http://localhost:3000/auth/yammer/callback"), - onedrive.New(os.Getenv("ONEDRIVE_KEY"), os.Getenv("ONEDRIVE_SECRET"), "http://localhost:3000/auth/onedrive/callback"), - azuread.New(os.Getenv("AZUREAD_KEY"), os.Getenv("AZUREAD_SECRET"), "http://localhost:3000/auth/azuread/callback", nil), - microsoftonline.New(os.Getenv("MICROSOFTONLINE_KEY"), os.Getenv("MICROSOFTONLINE_SECRET"), "http://localhost:3000/auth/microsoftonline/callback"), - battlenet.New(os.Getenv("BATTLENET_KEY"), os.Getenv("BATTLENET_SECRET"), "http://localhost:3000/auth/battlenet/callback"), - eveonline.New(os.Getenv("EVEONLINE_KEY"), os.Getenv("EVEONLINE_SECRET"), "http://localhost:3000/auth/eveonline/callback"), - kakao.New(os.Getenv("KAKAO_KEY"), os.Getenv("KAKAO_SECRET"), "http://localhost:3000/auth/kakao/callback"), - - // Pointed https://localhost.com to http://localhost:3000/auth/yahoo/callback - // Yahoo only accepts urls that starts with https - yahoo.New(os.Getenv("YAHOO_KEY"), os.Getenv("YAHOO_SECRET"), "https://localhost.com"), - typetalk.New(os.Getenv("TYPETALK_KEY"), os.Getenv("TYPETALK_SECRET"), "http://localhost:3000/auth/typetalk/callback", "my"), - slack.New(os.Getenv("SLACK_KEY"), os.Getenv("SLACK_SECRET"), "http://localhost:3000/auth/slack/callback"), - stripe.New(os.Getenv("STRIPE_KEY"), os.Getenv("STRIPE_SECRET"), "http://localhost:3000/auth/stripe/callback"), - wepay.New(os.Getenv("WEPAY_KEY"), os.Getenv("WEPAY_SECRET"), "http://localhost:3000/auth/wepay/callback", "view_user"), - // By default paypal production auth urls will be used, please set PAYPAL_ENV=sandbox as environment variable for testing - // in sandbox environment - paypal.New(os.Getenv("PAYPAL_KEY"), os.Getenv("PAYPAL_SECRET"), "http://localhost:3000/auth/paypal/callback"), - steam.New(os.Getenv("STEAM_KEY"), "http://localhost:3000/auth/steam/callback"), - heroku.New(os.Getenv("HEROKU_KEY"), os.Getenv("HEROKU_SECRET"), "http://localhost:3000/auth/heroku/callback"), - uber.New(os.Getenv("UBER_KEY"), os.Getenv("UBER_SECRET"), "http://localhost:3000/auth/uber/callback"), - soundcloud.New(os.Getenv("SOUNDCLOUD_KEY"), os.Getenv("SOUNDCLOUD_SECRET"), "http://localhost:3000/auth/soundcloud/callback"), - gitlab.New(os.Getenv("GITLAB_KEY"), os.Getenv("GITLAB_SECRET"), "http://localhost:3000/auth/gitlab/callback"), - dailymotion.New(os.Getenv("DAILYMOTION_KEY"), os.Getenv("DAILYMOTION_SECRET"), "http://localhost:3000/auth/dailymotion/callback", "email"), - deezer.New(os.Getenv("DEEZER_KEY"), os.Getenv("DEEZER_SECRET"), "http://localhost:3000/auth/deezer/callback", "email"), - discord.New(os.Getenv("DISCORD_KEY"), os.Getenv("DISCORD_SECRET"), "http://localhost:3000/auth/discord/callback", discord.ScopeIdentify, discord.ScopeEmail), - meetup.New(os.Getenv("MEETUP_KEY"), os.Getenv("MEETUP_SECRET"), "http://localhost:3000/auth/meetup/callback"), - - // Auth0 allocates domain per customer, a domain must be provided for auth0 to work - auth0.New(os.Getenv("AUTH0_KEY"), os.Getenv("AUTH0_SECRET"), "http://localhost:3000/auth/auth0/callback", os.Getenv("AUTH0_DOMAIN")), - xero.New(os.Getenv("XERO_KEY"), os.Getenv("XERO_SECRET"), "http://localhost:3000/auth/xero/callback"), - vk.New(os.Getenv("VK_KEY"), os.Getenv("VK_SECRET"), "http://localhost:3000/auth/vk/callback"), - naver.New(os.Getenv("NAVER_KEY"), os.Getenv("NAVER_SECRET"), "http://localhost:3000/auth/naver/callback"), - yandex.New(os.Getenv("YANDEX_KEY"), os.Getenv("YANDEX_SECRET"), "http://localhost:3000/auth/yandex/callback"), - nextcloud.NewCustomisedDNS(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "http://localhost:3000/auth/nextcloud/callback", os.Getenv("NEXTCLOUD_URL")), - gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "http://localhost:3000/auth/gitea/callback"), - shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "http://localhost:3000/auth/shopify/callback", shopify.ScopeReadCustomers, shopify.ScopeReadOrders), - apple.New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "http://localhost:3000/auth/apple/callback", nil, apple.ScopeName, apple.ScopeEmail), - strava.New(os.Getenv("STRAVA_KEY"), os.Getenv("STRAVA_SECRET"), "http://localhost:3000/auth/strava/callback"), - okta.New(os.Getenv("OKTA_ID"), os.Getenv("OKTA_SECRET"), os.Getenv("OKTA_ORG_URL"), "http://localhost:3000/auth/okta/callback", "openid", "profile", "email"), - mastodon.New(os.Getenv("MASTODON_KEY"), os.Getenv("MASTODON_SECRET"), "http://localhost:3000/auth/mastodon/callback", "read:accounts"), - wecom.New(os.Getenv("WECOM_CORP_ID"), os.Getenv("WECOM_SECRET"), os.Getenv("WECOM_AGENT_ID"), "http://localhost:3000/auth/wecom/callback"), - zoom.New(os.Getenv("ZOOM_KEY"), os.Getenv("ZOOM_SECRET"), "http://localhost:3000/auth/zoom/callback", "read:user"), - patreon.New(os.Getenv("PATREON_KEY"), os.Getenv("PATREON_SECRET"), "http://localhost:3000/auth/patreon/callback"), - // DingTalk provider - dingtalk.New(os.Getenv("DINGTALK_KEY"), os.Getenv("DINGTALK_SECRET"), "https://f7ca-103-148-203-253.ngrok-free.app/auth/dingtalk/callback", os.Getenv("DINGTALK_CORP_ID"), "openid", "corpid"), - ) - - // OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html) - // because the OpenID Connect provider initialize itself in the New(), it can return an error which should be handled or ignored - // ignore the error for now - openidConnect, _ := openidConnect.New(os.Getenv("OPENID_CONNECT_KEY"), os.Getenv("OPENID_CONNECT_SECRET"), "http://localhost:3000/auth/openid-connect/callback", os.Getenv("OPENID_CONNECT_DISCOVERY_URL")) - if openidConnect != nil { - goth.UseProviders(openidConnect) - } - - m := map[string]string{ - "amazon": "Amazon", - "apple": "Apple", - "auth0": "Auth0", - "azuread": "Azure AD", - "battlenet": "Battle.net", - "bitbucket": "Bitbucket", - "box": "Box", - "dailymotion": "Dailymotion", - "deezer": "Deezer", - "digitalocean": "Digital Ocean", - "dingtalk": "DingTalk", - "discord": "Discord", - "dropbox": "Dropbox", - "eveonline": "Eve Online", - "facebook": "Facebook", - "fitbit": "Fitbit", - "gitea": "Gitea", - "github": "Github", - "gitlab": "Gitlab", - "google": "Google", - "heroku": "Heroku", - "instagram": "Instagram", - "intercom": "Intercom", - "kakao": "Kakao", - "lastfm": "Last FM", - "line": "LINE", - "linkedin": "LinkedIn", - "mastodon": "Mastodon", - "meetup": "Meetup.com", - "microsoftonline": "Microsoft Online", - "naver": "Naver", - "nextcloud": "NextCloud", - "okta": "Okta", - "onedrive": "Onedrive", - "openid-connect": "OpenID Connect", - "patreon": "Patreon", - "paypal": "Paypal", - "salesforce": "Salesforce", - "seatalk": "SeaTalk", - "shopify": "Shopify", - "slack": "Slack", - "soundcloud": "SoundCloud", - "spotify": "Spotify", - "steam": "Steam", - "strava": "Strava", - "stripe": "Stripe", - "tiktok": "TikTok", - "twitch": "Twitch", - "twitter": "Twitter", - "twitterv2": "Twitter", - "typetalk": "Typetalk", - "uber": "Uber", - "vk": "VK", - "wecom": "WeCom", - "wepay": "Wepay", - "xero": "Xero", - "yahoo": "Yahoo", - "yammer": "Yammer", - "yandex": "Yandex", - "zoom": "Zoom", - } - var keys []string - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - - providerIndex := &ProviderIndex{Providers: keys, ProvidersMap: m} - - p := pat.New() - p.Get("/auth/{provider}/callback", func(res http.ResponseWriter, req *http.Request) { - - user, err := gothic.CompleteUserAuth(res, req) - if err != nil { - fmt.Fprintln(res, err) - return - } - t, _ := template.New("foo").Parse(userTemplate) - t.Execute(res, user) - }) - - p.Get("/logout/{provider}", func(res http.ResponseWriter, req *http.Request) { - gothic.Logout(res, req) - res.Header().Set("Location", "/") - res.WriteHeader(http.StatusTemporaryRedirect) - }) - - p.Get("/auth/{provider}", func(res http.ResponseWriter, req *http.Request) { - // try to get the user without re-authenticating - if gothUser, err := gothic.CompleteUserAuth(res, req); err == nil { - t, _ := template.New("foo").Parse(userTemplate) - t.Execute(res, gothUser) - } else { - gothic.BeginAuthHandler(res, req) - } - }) - - p.Get("/", func(res http.ResponseWriter, req *http.Request) { - t, _ := template.New("foo").Parse(indexTemplate) - t.Execute(res, providerIndex) - }) - - log.Println("listening on localhost:3000") - log.Fatal(http.ListenAndServe(":3000", p)) -} - -type ProviderIndex struct { - Providers []string - ProvidersMap map[string]string -} - -var indexTemplate = `{{range $key,$value:=.Providers}} -

Log in with {{index $.ProvidersMap $value}}

-{{end}}` - -var userTemplate = ` -

logout

-

Name: {{.Name}} [{{.LastName}}, {{.FirstName}}]

-

Email: {{.Email}}

-

NickName: {{.NickName}}

-

Location: {{.Location}}

-

AvatarURL: {{.AvatarURL}}

-

Description: {{.Description}}

-

UserID: {{.UserID}}

-

AccessToken: {{.AccessToken}}

-

ExpiresAt: {{.ExpiresAt}}

-

RefreshToken: {{.RefreshToken}}

-` diff --git a/go.sum b/go.sum deleted file mode 100644 index 7bde53395..000000000 --- a/go.sum +++ /dev/null @@ -1,122 +0,0 @@ -cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 h1:LqbZZ9sNMWVjeXS4NN5oVvhMjDyLhmA1LG86oSo+IqY= -github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= -github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= -github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da h1:FjHUJJ7oBW4G/9j1KzlHaXL09LyMVM9rupS39lncbXk= -github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= -github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= -github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= -github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= -github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= -github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx v1.2.29 h1:QT0utmUJ4/12rmsVQrJ3u55bycPkKqGYuGT4tyRhxSQ= -github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8= -github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/markbates/going v1.0.0 h1:DQw0ZP7NbNlFGcKbcE/IVSOAFzScxRtLpd0rLMzLhq0= -github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA= -github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c h1:3wkDRdxK92dF+c1ke2dtj7ZzemFWBHB9plnJOtlwdFA= -github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gothic/gothic.go b/gothic/gothic.go deleted file mode 100644 index b895a12d1..000000000 --- a/gothic/gothic.go +++ /dev/null @@ -1,316 +0,0 @@ -/* -Package gothic wraps common behaviour when using Goth. This makes it quick, and easy, to get up -and running with Goth. Of course, if you want complete control over how things flow, in regard -to the authentication process, feel free and use Goth directly. - -See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action. -*/ -package gothic - -import ( - "bytes" - "compress/gzip" - "context" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - - "github.com/gorilla/sessions" - "github.com/markbates/goth" -) - -// SessionName is the key used to access the session store. -const SessionName = "_gothic_session" - -// Store can/should be set by applications using gothic. The default is a cookie store. -var Store sessions.Store -var defaultStore sessions.Store - -var keySet = false - -type key int - -// ProviderParamKey can be used as a key in context when passing in a provider -const ProviderParamKey key = iota - -func init() { - key := []byte(os.Getenv("SESSION_SECRET")) - keySet = len(key) != 0 - - cookieStore := sessions.NewCookieStore(key) - cookieStore.Options.HttpOnly = true - Store = cookieStore - defaultStore = Store -} - -/* -BeginAuthHandler is a convenience handler for starting the authentication process. -It expects to be able to get the name of the provider from the query parameters -as either "provider" or ":provider". - -BeginAuthHandler will redirect the user to the appropriate authentication end-point -for the requested provider. - -See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action. -*/ -func BeginAuthHandler(res http.ResponseWriter, req *http.Request) { - url, err := GetAuthURL(res, req) - if err != nil { - res.WriteHeader(http.StatusBadRequest) - fmt.Fprintln(res, err) - return - } - - http.Redirect(res, req, url, http.StatusTemporaryRedirect) -} - -// SetState sets the state string associated with the given request. -// If no state string is associated with the request, one will be generated. -// This state is sent to the provider and can be retrieved during the -// callback. -var SetState = func(req *http.Request) string { - state := req.URL.Query().Get("state") - if len(state) > 0 { - return state - } - - // If a state query param is not passed in, generate a random - // base64-encoded nonce so that the state on the auth URL - // is unguessable, preventing CSRF attacks, as described in - // - // https://auth0.com/docs/protocols/oauth2/oauth-state#keep-reading - nonceBytes := make([]byte, 64) - _, err := io.ReadFull(rand.Reader, nonceBytes) - if err != nil { - panic("gothic: source of randomness unavailable: " + err.Error()) - } - return base64.URLEncoding.EncodeToString(nonceBytes) -} - -// GetState gets the state returned by the provider during the callback. -// This is used to prevent CSRF attacks, see -// http://tools.ietf.org/html/rfc6749#section-10.12 -var GetState = func(req *http.Request) string { - params := req.URL.Query() - if params.Encode() == "" && req.Method == http.MethodPost { - return req.FormValue("state") - } - return params.Get("state") -} - -/* -GetAuthURL starts the authentication process with the requested provided. -It will return a URL that should be used to send users to. - -It expects to be able to get the name of the provider from the query parameters -as either "provider" or ":provider". - -I would recommend using the BeginAuthHandler instead of doing all of these steps -yourself, but that's entirely up to you. -*/ -func GetAuthURL(res http.ResponseWriter, req *http.Request) (string, error) { - if !keySet && defaultStore == Store { - fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.") - } - - providerName, err := GetProviderName(req) - if err != nil { - return "", err - } - - provider, err := goth.GetProvider(providerName) - if err != nil { - return "", err - } - sess, err := provider.BeginAuth(SetState(req)) - if err != nil { - return "", err - } - - url, err := sess.GetAuthURL() - if err != nil { - return "", err - } - - err = StoreInSession(providerName, sess.Marshal(), req, res) - - if err != nil { - return "", err - } - - return url, err -} - -/* -CompleteUserAuth does what it says on the tin. It completes the authentication -process and fetches all the basic information about the user from the provider. - -It expects to be able to get the name of the provider from the query parameters -as either "provider" or ":provider". - -See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action. -*/ -var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.User, error) { - if !keySet && defaultStore == Store { - fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.") - } - - providerName, err := GetProviderName(req) - if err != nil { - return goth.User{}, err - } - - provider, err := goth.GetProvider(providerName) - if err != nil { - return goth.User{}, err - } - - value, err := GetFromSession(providerName, req) - if err != nil { - return goth.User{}, err - } - defer Logout(res, req) - sess, err := provider.UnmarshalSession(value) - if err != nil { - return goth.User{}, err - } - - err = validateState(req, sess) - if err != nil { - return goth.User{}, err - } - - user, err := provider.FetchUser(sess) - if err == nil { - // user can be found with existing session data - return user, err - } - - params := req.URL.Query() - if params.Encode() == "" && req.Method == "POST" { - req.ParseForm() - params = req.Form - } - - // get new token and retry fetch - _, err = sess.Authorize(provider, params) - if err != nil { - return goth.User{}, err - } - - err = StoreInSession(providerName, sess.Marshal(), req, res) - - if err != nil { - return goth.User{}, err - } - - gu, err := provider.FetchUser(sess) - return gu, err -} - -// validateState ensures that the state token param from the original -// AuthURL matches the one included in the current (callback) request. -func validateState(req *http.Request, sess goth.Session) error { - rawAuthURL, err := sess.GetAuthURL() - if err != nil { - return err - } - - authURL, err := url.Parse(rawAuthURL) - if err != nil { - return err - } - - reqState := GetState(req) - - originalState := authURL.Query().Get("state") - if originalState != "" && (originalState != reqState) { - return errors.New("state token mismatch") - } - return nil -} - -// Logout invalidates a user session. -func Logout(res http.ResponseWriter, req *http.Request) error { - session, err := Store.Get(req, SessionName) - if err != nil { - return err - } - session.Options.MaxAge = -1 - session.Values = make(map[interface{}]interface{}) - err = session.Save(req, res) - if err != nil { - return errors.New("Could not delete user session ") - } - return nil -} - -// GetContextWithProvider returns a new request context containing the provider -func GetContextWithProvider(req *http.Request, provider string) *http.Request { - return req.WithContext(context.WithValue(req.Context(), ProviderParamKey, provider)) -} - -// StoreInSession stores a specified key/value pair in the session. -func StoreInSession(key string, value string, req *http.Request, res http.ResponseWriter) error { - session, _ := Store.New(req, SessionName) - - if err := updateSessionValue(session, key, value); err != nil { - return err - } - - return session.Save(req, res) -} - -// GetFromSession retrieves a previously-stored value from the session. -// If no value has previously been stored at the specified key, it will return an error. -func GetFromSession(key string, req *http.Request) (string, error) { - session, _ := Store.Get(req, SessionName) - value, err := getSessionValue(session, key) - if err != nil { - return "", errors.New("could not find a matching session for this request") - } - - return value, nil -} - -func getSessionValue(session *sessions.Session, key string) (string, error) { - value := session.Values[key] - if value == nil { - return "", fmt.Errorf("could not find a matching session for this request") - } - - rdata := strings.NewReader(value.(string)) - r, err := gzip.NewReader(rdata) - if err != nil { - return "", err - } - s, err := io.ReadAll(r) - if err != nil { - return "", err - } - - return string(s), nil -} - -func updateSessionValue(session *sessions.Session, key, value string) error { - var b bytes.Buffer - gz := gzip.NewWriter(&b) - if _, err := gz.Write([]byte(value)); err != nil { - return err - } - if err := gz.Flush(); err != nil { - return err - } - if err := gz.Close(); err != nil { - return err - } - - session.Values[key] = b.String() - return nil -} diff --git a/gothic/gothic_test.go b/gothic/gothic_test.go deleted file mode 100644 index 22c8448a2..000000000 --- a/gothic/gothic_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package gothic_test - -import ( - "bytes" - "compress/gzip" - "fmt" - "html" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/gorilla/sessions" - "github.com/markbates/goth" - . "github.com/markbates/goth/gothic" - "github.com/markbates/goth/providers/faux" - "github.com/stretchr/testify/assert" -) - -type mapKey struct { - r *http.Request - n string -} - -type ProviderStore struct { - Store map[mapKey]*sessions.Session -} - -func NewProviderStore() *ProviderStore { - return &ProviderStore{map[mapKey]*sessions.Session{}} -} - -func (p ProviderStore) Get(r *http.Request, name string) (*sessions.Session, error) { - s := p.Store[mapKey{r, name}] - if s == nil { - s, err := p.New(r, name) - return s, err - } - return s, nil -} - -func (p ProviderStore) New(r *http.Request, name string) (*sessions.Session, error) { - s := sessions.NewSession(p, name) - s.Options = &sessions.Options{ - Path: "/", - MaxAge: 86400 * 30, - } - p.Store[mapKey{r, name}] = s - return s, nil -} - -func (p ProviderStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { - p.Store[mapKey{r, s.Name()}] = s - return nil -} - -var fauxProvider goth.Provider - -func init() { - Store = NewProviderStore() - fauxProvider = &faux.Provider{} - goth.UseProviders(fauxProvider) -} - -func Test_BeginAuthHandler(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth?provider=faux", nil) - a.NoError(err) - - BeginAuthHandler(res, req) - - sess, err := Store.Get(req, SessionName) - if err != nil { - t.Fatalf("error getting faux Gothic session: %v", err) - } - - sessStr, ok := sess.Values["faux"].(string) - if !ok { - t.Fatalf("Gothic session not stored as marshalled string; was %T (value %v)", - sess.Values["faux"], sess.Values["faux"]) - } - gothSession, err := fauxProvider.UnmarshalSession(ungzipString(sessStr)) - if err != nil { - t.Fatalf("error unmarshalling faux Gothic session: %v", err) - } - au, _ := gothSession.GetAuthURL() - - a.Equal(http.StatusTemporaryRedirect, res.Code) - a.Contains(res.Body.String(), - fmt.Sprintf(`Temporary Redirect`, html.EscapeString(au))) -} - -func Test_GetAuthURL(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth?provider=faux", nil) - a.NoError(err) - - u, err := GetAuthURL(res, req) - a.NoError(err) - - // Check that we get the correct auth URL with a state parameter - parsed, err := url.Parse(u) - a.NoError(err) - a.Equal("http", parsed.Scheme) - a.Equal("example.com", parsed.Host) - q := parsed.Query() - a.Contains(q, "client_id") - a.Equal("code", q.Get("response_type")) - a.NotZero(q, "state") - - // Check that if we run GetAuthURL on another request, that request's - // auth URL has a different state from the previous one. - req2, err := http.NewRequest("GET", "/auth?provider=faux", nil) - a.NoError(err) - url2, err := GetAuthURL(httptest.NewRecorder(), req2) - a.NoError(err) - parsed2, err := url.Parse(url2) - a.NoError(err) - a.NotEqual(parsed.Query().Get("state"), parsed2.Query().Get("state")) -} - -func Test_CompleteUserAuth(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth/callback?provider=faux", nil) - a.NoError(err) - - sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} - session, _ := Store.Get(req, SessionName) - session.Values["faux"] = gzipString(sess.Marshal()) - err = session.Save(req, res) - a.NoError(err) - - user, err := CompleteUserAuth(res, req) - a.NoError(err) - - a.Equal(user.Name, "Homer Simpson") - a.Equal(user.Email, "homer@example.com") -} - -func Test_CompleteUserAuthWithSessionDeducedProvider(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - // Intentionally omit a provider argument, force looking in session. - req, err := http.NewRequest("GET", "/auth/callback", nil) - a.NoError(err) - - sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} - session, _ := Store.Get(req, SessionName) - session.Values["faux"] = gzipString(sess.Marshal()) - err = session.Save(req, res) - a.NoError(err) - - user, err := CompleteUserAuth(res, req) - a.NoError(err) - - a.Equal(user.Name, "Homer Simpson") - a.Equal(user.Email, "homer@example.com") -} - -func Test_CompleteUserAuthWithContextParamProvider(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth/callback", nil) - a.NoError(err) - - req = GetContextWithProvider(req, "faux") - - sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} - session, _ := Store.Get(req, SessionName) - session.Values["faux"] = gzipString(sess.Marshal()) - err = session.Save(req, res) - a.NoError(err) - - user, err := CompleteUserAuth(res, req) - a.NoError(err) - - a.Equal(user.Name, "Homer Simpson") - a.Equal(user.Email, "homer@example.com") -} - -func Test_Logout(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth/callback?provider=faux", nil) - a.NoError(err) - - sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} - session, _ := Store.Get(req, SessionName) - session.Values["faux"] = gzipString(sess.Marshal()) - err = session.Save(req, res) - a.NoError(err) - - user, err := CompleteUserAuth(res, req) - a.NoError(err) - - a.Equal(user.Name, "Homer Simpson") - a.Equal(user.Email, "homer@example.com") - err = Logout(res, req) - a.NoError(err) - session, _ = Store.Get(req, SessionName) - a.Equal(session.Values, make(map[interface{}]interface{})) - a.Equal(session.Options.MaxAge, -1) -} - -func Test_SetState(t *testing.T) { - a := assert.New(t) - - req, _ := http.NewRequest("GET", "/auth?state=state", nil) - a.Equal(SetState(req), "state") -} - -func Test_GetState(t *testing.T) { - a := assert.New(t) - - req, _ := http.NewRequest("GET", "/auth?state=state", nil) - a.Equal(GetState(req), "state") -} - -func Test_StateValidation(t *testing.T) { - a := assert.New(t) - - Store = NewProviderStore() - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth?provider=faux&state=state_REAL", nil) - a.NoError(err) - - BeginAuthHandler(res, req) - session, _ := Store.Get(req, SessionName) - - // Assert that matching states will return a nil error - req, _ = http.NewRequest("GET", "/auth/callback?provider=faux&state=state_REAL", nil) - session.Save(req, res) - _, err = CompleteUserAuth(res, req) - a.NoError(err) - - // Assert that mismatched states will return an error - req, _ = http.NewRequest("GET", "/auth/callback?provider=faux&state=state_FAKE", nil) - session.Save(req, res) - _, err = CompleteUserAuth(res, req) - a.Error(err) -} - -func Test_AppleStateValidation(t *testing.T) { - a := assert.New(t) - appleStateValue := "xyz123-#" - form := url.Values{} - form.Add("state", appleStateValue) - req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) - req.Form = form - a.Equal(appleStateValue, GetState(req)) -} - -func gzipString(value string) string { - var b bytes.Buffer - gz := gzip.NewWriter(&b) - if _, err := gz.Write([]byte(value)); err != nil { - return "err" - } - if err := gz.Flush(); err != nil { - return "err" - } - if err := gz.Close(); err != nil { - return "err" - } - - return b.String() -} - -func ungzipString(value string) string { - rdata := strings.NewReader(value) - r, err := gzip.NewReader(rdata) - if err != nil { - return "err" - } - s, err := io.ReadAll(r) - if err != nil { - return "err" - } - - return string(s) -} diff --git a/gothic/provider.go b/gothic/provider.go deleted file mode 100644 index f27d78dd2..000000000 --- a/gothic/provider.go +++ /dev/null @@ -1,68 +0,0 @@ -package gothic - -import ( - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/gorilla/mux" - "github.com/markbates/goth" -) - -// GetProviderName is a function used to get the name of a provider -// for a given request. By default, this provider is fetched from -// the URL query string. If you provide it in a different way, -// assign your own function to this variable that returns the provider -// name for your request. -var GetProviderName = getProviderName - -func getProviderName(req *http.Request) (string, error) { - // try to get it from the url param "provider" - if p := req.URL.Query().Get("provider"); p != "" { - return p, nil - } - - // try to get it from the url param ":provider" - if p := req.URL.Query().Get(":provider"); p != "" { - return p, nil - } - - // try to get it from the context's value of "provider" key - if p, ok := mux.Vars(req)["provider"]; ok { - return p, nil - } - - // try to get it from the go-context's value of "provider" key - if p, ok := req.Context().Value("provider").(string); ok { - return p, nil - } - - // try to get it from the url param "provider", when req is routed through 'chi' - if p := chi.URLParam(req, "provider"); p != "" { - return p, nil - } - - // try to get it from the route param for go >= 1.22 - if p := req.PathValue("provider"); p != "" { - return p, nil - } - - // try to get it from the go-context's value of providerContextKey key - if p, ok := req.Context().Value(ProviderParamKey).(string); ok { - return p, nil - } - - // As a fallback, loop over the used providers, if we already have a valid session for any provider (ie. user has already begun authentication with a provider), then return that provider name - providers := goth.GetProviders() - session, _ := Store.Get(req, SessionName) - for _, provider := range providers { - p := provider.Name() - value := session.Values[p] - if _, ok := value.(string); ok { - return p, nil - } - } - - // if not found then return an empty string with the corresponding error - return "", errors.New("you must select a provider") -} diff --git a/gothic/provider_test.go b/gothic/provider_test.go deleted file mode 100644 index f17db86ed..000000000 --- a/gothic/provider_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package gothic_test - -import ( - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/markbates/goth/gothic" - "github.com/stretchr/testify/assert" -) - -func Test_GetAuthURL122(t *testing.T) { - a := assert.New(t) - - res := httptest.NewRecorder() - req, err := http.NewRequest("GET", "/auth", nil) - a.NoError(err) - req.SetPathValue("provider", "faux") - - u, err := gothic.GetAuthURL(res, req) - a.NoError(err) - - // Check that we get the correct auth URL with a state parameter - parsed, err := url.Parse(u) - a.NoError(err) - a.Equal("http", parsed.Scheme) - a.Equal("example.com", parsed.Host) - q := parsed.Query() - a.Contains(q, "client_id") - a.Equal("code", q.Get("response_type")) - a.NotZero(q, "state") - - // Check that if we run GetAuthURL on another request, that request's - // auth URL has a different state from the previous one. - req2, err := http.NewRequest("GET", "/auth?provider=faux", nil) - a.NoError(err) - req2.SetPathValue("provider", "faux") - url2, err := gothic.GetAuthURL(httptest.NewRecorder(), req2) - a.NoError(err) - parsed2, err := url.Parse(url2) - a.NoError(err) - a.NotEqual(parsed.Query().Get("state"), parsed2.Query().Get("state")) -} diff --git a/provider.go b/provider.go deleted file mode 100644 index 1aaf1b4bb..000000000 --- a/provider.go +++ /dev/null @@ -1,87 +0,0 @@ -package goth - -import ( - "context" - "fmt" - "net/http" - "sync" - - "golang.org/x/oauth2" -) - -// Provider needs to be implemented for each 3rd party authentication provider -// e.g. Facebook, Twitter, etc... -type Provider interface { - Name() string - SetName(name string) - BeginAuth(state string) (Session, error) - UnmarshalSession(string) (Session, error) - FetchUser(Session) (User, error) - Debug(bool) - RefreshToken(refreshToken string) (*oauth2.Token, error) // Get new access token based on the refresh token - RefreshTokenAvailable() bool // Refresh token is provided by auth provider or not -} - -const NoAuthUrlErrorMessage = "an AuthURL has not been set" - -// Providers is the list of known/available providers. -type Providers map[string]Provider - -var ( - providersHat sync.RWMutex - providers = Providers{} -) - -// UseProviders adds a list of available providers for use with Goth. -// Can be called multiple times. If you pass the same provider more -// than once, the last will be used. -func UseProviders(viders ...Provider) { - providersHat.Lock() - defer providersHat.Unlock() - - for _, provider := range viders { - providers[provider.Name()] = provider - } -} - -// GetProviders returns a list of all the providers currently in use. -func GetProviders() Providers { - return providers -} - -// GetProvider returns a previously created provider. If Goth has not -// been told to use the named provider it will return an error. -func GetProvider(name string) (Provider, error) { - providersHat.RLock() - provider := providers[name] - providersHat.RUnlock() - if provider == nil { - return nil, fmt.Errorf("no provider for %s exists", name) - } - return provider, nil -} - -// ClearProviders will remove all providers currently in use. -// This is useful, mostly, for testing purposes. -func ClearProviders() { - providersHat.Lock() - defer providersHat.Unlock() - - providers = Providers{} -} - -// ContextForClient provides a context for use with oauth2. -func ContextForClient(h *http.Client) context.Context { - if h == nil { - return oauth2.NoContext - } - return context.WithValue(oauth2.NoContext, oauth2.HTTPClient, h) -} - -// HTTPClientWithFallBack to be used in all fetch operations. -func HTTPClientWithFallBack(h *http.Client) *http.Client { - if h != nil { - return h - } - return http.DefaultClient -} diff --git a/provider_test.go b/provider_test.go deleted file mode 100644 index 8890577dc..000000000 --- a/provider_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package goth_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/faux" - "github.com/stretchr/testify/assert" -) - -func Test_UseProviders(t *testing.T) { - a := assert.New(t) - - provider := &faux.Provider{} - goth.UseProviders(provider) - a.Equal(len(goth.GetProviders()), 1) - a.Equal(goth.GetProviders()[provider.Name()], provider) - goth.ClearProviders() -} - -func Test_GetProvider(t *testing.T) { - a := assert.New(t) - - provider := &faux.Provider{} - goth.UseProviders(provider) - - p, err := goth.GetProvider(provider.Name()) - a.NoError(err) - a.Equal(p, provider) - - _, err = goth.GetProvider("unknown") - a.Error(err) - a.Equal(err.Error(), "no provider for unknown exists") - goth.ClearProviders() -} diff --git a/providers/amazon/amazon.go b/providers/amazon/amazon.go deleted file mode 100644 index 5a0b175cb..000000000 --- a/providers/amazon/amazon.go +++ /dev/null @@ -1,166 +0,0 @@ -// Package amazon implements the OAuth2 protocol for authenticating users through amazon. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package amazon - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.amazon.com/ap/oa" - tokenURL string = "https://api.amazon.com/auth/o2/token" - endpointProfile string = "https://api.amazon.com/user/profile" -) - -// Provider is the implementation of `goth.Provider` for accessing Amazon. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Amazon provider and sets up important connection details. -// You should always call `amazon.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "amazon", - } - p.config = newConfig(p, scopes) - return p -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Debug is a no-op for the amazon package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Amazon for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Amazon and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := goth.HTTPClientWithFallBack(p.Client()).Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "profile", "postal_code") - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Location string `json:"postal_code"` - Email string `json:"email"` - ID string `json:"user_id"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.NickName = u.Name - user.UserID = u.ID - user.Location = u.Location - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/amazon/amazon_test.go b/providers/amazon/amazon_test.go deleted file mode 100644 index 6360836bd..000000000 --- a/providers/amazon/amazon_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package amazon_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/amazon" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("AMAZON_KEY")) - a.Equal(p.Secret, os.Getenv("AMAZON_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*amazon.Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.amazon.com/ap/oa") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.amazon.com/ap/oa","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*amazon.Session) - a.Equal(s.AuthURL, "https://www.amazon.com/ap/oa") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *amazon.Provider { - return amazon.New(os.Getenv("AMAZON_KEY"), os.Getenv("AMAZON_SECRET"), "/foo") -} diff --git a/providers/amazon/session.go b/providers/amazon/session.go deleted file mode 100644 index 173f2a5b4..000000000 --- a/providers/amazon/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package amazon - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Amazon. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Amazon provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Amazon and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/amazon/session_test.go b/providers/amazon/session_test.go deleted file mode 100644 index 32cadeb14..000000000 --- a/providers/amazon/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package amazon_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/amazon" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &amazon.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &amazon.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &amazon.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &amazon.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/apple/apple.go b/providers/apple/apple.go deleted file mode 100644 index cd3926b77..000000000 --- a/providers/apple/apple.go +++ /dev/null @@ -1,187 +0,0 @@ -// Package `apple` implements the OAuth2 protocol for authenticating users through Apple. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package apple - -import ( - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authEndpoint = "https://appleid.apple.com/auth/authorize" - tokenEndpoint = "https://appleid.apple.com/auth/token" - - ScopeEmail = "email" - ScopeName = "name" - - AppleAudOrIss = "https://appleid.apple.com" -) - -type Provider struct { - providerName string - clientId string - secret string - redirectURL string - config *oauth2.Config - httpClient *http.Client - formPostResponseMode bool - timeNowFn func() time.Time -} - -func New(clientId, secret, redirectURL string, httpClient *http.Client, scopes ...string) *Provider { - p := &Provider{ - clientId: clientId, - secret: secret, - redirectURL: redirectURL, - providerName: "apple", - } - p.configure(scopes) - p.httpClient = httpClient - return p -} - -func (p Provider) Name() string { - return p.providerName -} - -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p Provider) ClientId() string { - return p.clientId -} - -type SecretParams struct { - PKCS8PrivateKey, TeamId, KeyId, ClientId string - Iat, Exp int -} - -func MakeSecret(sp SecretParams) (*string, error) { - block, rest := pem.Decode([]byte(strings.TrimSpace(sp.PKCS8PrivateKey))) - if block == nil || len(rest) > 0 { - return nil, errors.New("invalid private key") - } - pk, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, err - } - token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ - "iss": sp.TeamId, - "iat": sp.Iat, - "exp": sp.Exp, - "aud": AppleAudOrIss, - "sub": sp.ClientId, - }) - token.Header["kid"] = sp.KeyId - ss, err := token.SignedString(pk) - return &ss, err -} - -func (p Provider) Secret() string { - return p.secret -} - -func (p Provider) RedirectURL() string { - return p.redirectURL -} - -func (p Provider) BeginAuth(state string) (goth.Session, error) { - opts := make([]oauth2.AuthCodeOption, 0, 1) - if p.formPostResponseMode { - opts = append(opts, oauth2.SetAuthURLParam("response_mode", "form_post")) - } - authURL := p.config.AuthCodeURL(state, opts...) - if authURL != "" { - if u, err := url.Parse(authURL); err == nil { - // Apple requires spaces to be encoded as %20 instead of + - u.RawQuery = strings.ReplaceAll(u.RawQuery, "+", "%20") - authURL = u.String() - } - } - return &Session{ - AuthURL: authURL, - }, nil -} - -func (Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} - -// Apple doesn't seem to provide a user profile endpoint like all the other providers do. -// Therefore this will return a User with the unique identifier obtained through authorization -// as the only identifying attribute. -// A full name and email can be obtained from the form post response (parameter 'user') -// to the redirect page following authentication, if the name and email scopes are requested. -// Additionally, if the response type is form_post and the email scope is requested, the email -// will be encoded into the ID token in the email claim. -func (p Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - if s.AccessToken == "" { - return goth.User{}, fmt.Errorf("no access token obtained for session with provider %s", p.Name()) - } - return goth.User{ - Provider: p.Name(), - UserID: s.ID.Sub, - Email: s.ID.Email, - AccessToken: s.AccessToken, - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - }, nil -} - -// Debug is a no-op for the apple package. -func (Provider) Debug(bool) {} - -func (p Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.httpClient) -} - -func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func (Provider) RefreshTokenAvailable() bool { - return true -} - -func (p *Provider) configure(scopes []string) { - c := &oauth2.Config{ - ClientID: p.clientId, - ClientSecret: p.secret, - RedirectURL: p.redirectURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authEndpoint, - TokenURL: tokenEndpoint, - }, - Scopes: make([]string, 0, len(scopes)), - } - - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - if scope == "name" || scope == "email" { - p.formPostResponseMode = true - } - } - - p.config = c -} diff --git a/providers/apple/apple_test.go b/providers/apple/apple_test.go deleted file mode 100644 index 2b5021f34..000000000 --- a/providers/apple/apple_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package apple - -import ( - "net/http" - "net/url" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientId(), os.Getenv("APPLE_KEY")) - a.Equal(p.Secret(), os.Getenv("APPLE_SECRET")) - a.Equal(p.RedirectURL(), "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "appleid.apple.com/auth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://appleid.apple.com/auth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*Session) - a.Equal(s.AuthURL, "https://appleid.apple.com/auth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *Provider { - return New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "/foo", nil) -} - -func TestMakeSecret(t *testing.T) { - a := assert.New(t) - - iat := 1570636633 - ss, err := MakeSecret(SecretParams{ - PKCS8PrivateKey: `-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPALVklHT2n9FNxeP -c1+TCP+Ep7YOU7T9KB5MTVpjL1ShRANCAATXAbDMQ/URATKRoSIFMkwetLH/M2S4 -nNFzkp23qt9IJDivieB/BBJct1UvhoICg5eZDhSR+x7UH3Uhog8qgoIC ------END PRIVATE KEY-----`, // example - TeamId: "TK...", - KeyId: "", - ClientId: "", - Iat: iat, - Exp: iat + 15777000, - }) - a.NoError(err) - a.NotZero(ss) - // fmt.Printf("signed secret: %s", *ss) -} - -func TestAuthorize(t *testing.T) { - ss := "" // a value from MakeSecret - if ss == "" { - t.Skip() - } - - a := assert.New(t) - - client := http.DefaultClient - p := New( - "", - ss, - "https://example-app.com/redirect", - client, - "name", "email") - session, _ := p.BeginAuth("test_state") - - _, err := session.Authorize(p, url.Values{ - "code": []string{""}, - }) - if err != nil { - errStr := err.Error() - a.Fail(errStr) - } -} - -func TestBeginAuth(t *testing.T) { - a := assert.New(t) - - client := http.DefaultClient - p := New( - "", - "", - "https://example-app.com/redirect", - client, - "name", "email") - session, _ := p.BeginAuth("test_state") - - s := session.(*Session) - - // Apple requires spaces to be encoded as %20 instead of + - a.Equal(s.AuthURL, "https://appleid.apple.com/auth/authorize?client_id=%3CclientId%3E&redirect_uri=https%3A%2F%2Fexample-app.com%2Fredirect&response_mode=form_post&response_type=code&scope=name%20email&state=test_state") -} diff --git a/providers/apple/session.go b/providers/apple/session.go deleted file mode 100644 index becfef364..000000000 --- a/providers/apple/session.go +++ /dev/null @@ -1,162 +0,0 @@ -package apple - -import ( - "context" - "crypto/rsa" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/lestrrat-go/jwx/jwk" - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - idTokenVerificationKeyEndpoint = "https://appleid.apple.com/auth/keys" -) - -type ID struct { - Sub string `json:"sub"` - Email string `json:"email"` - IsPrivateEmail bool `json:"is_private_email"` - EmailVerified bool `json:"email_verified"` -} - -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - ID -} - -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -type IDTokenClaims struct { - jwt.RegisteredClaims - AccessTokenHash string `json:"at_hash"` - AuthTime int `json:"auth_time"` - Email string `json:"email"` - IsPrivateEmail BoolString `json:"is_private_email"` - EmailVerified BoolString `json:"email_verified,omitempty"` -} - -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - opts := []oauth2.AuthCodeOption{ - // Apple requires client id & secret as headers - oauth2.SetAuthURLParam("client_id", p.clientId), - oauth2.SetAuthURLParam("client_secret", p.secret), - } - token, err := p.config.Exchange(context.Background(), params.Get("code"), opts...) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - - if idToken := token.Extra("id_token"); idToken != nil { - idToken, err := jwt.ParseWithClaims(idToken.(string), &IDTokenClaims{}, func(t *jwt.Token) (interface{}, error) { - kid := t.Header["kid"].(string) - claims := t.Claims.(*IDTokenClaims) - validator := jwt.NewValidator(jwt.WithAudience(p.clientId), jwt.WithIssuer(AppleAudOrIss)) - err := validator.Validate(claims) - if err != nil { - return nil, err - } - - // per OpenID Connect Core 1.0 §3.2.2.9, Access Token Validation - hash := sha256.Sum256([]byte(s.AccessToken)) - halfHash := hash[0:(len(hash) / 2)] - encodedHalfHash := base64.RawURLEncoding.EncodeToString(halfHash) - if encodedHalfHash != claims.AccessTokenHash { - return nil, fmt.Errorf(`identity token invalid`) - } - - // get the public key for verifying the identity token signature - set, err := jwk.Fetch(context.Background(), idTokenVerificationKeyEndpoint, jwk.WithHTTPClient(p.Client())) - if err != nil { - return nil, err - } - selectedKey, found := set.LookupKeyID(kid) - if !found { - return nil, errors.New("could not find matching public key") - } - pubKey := &rsa.PublicKey{} - err = selectedKey.Raw(pubKey) - if err != nil { - return nil, err - } - return pubKey, nil - }) - if err != nil { - return "", err - } - s.ID = ID{ - Sub: idToken.Claims.(*IDTokenClaims).Subject, - Email: idToken.Claims.(*IDTokenClaims).Email, - IsPrivateEmail: idToken.Claims.(*IDTokenClaims).IsPrivateEmail.Value(), - EmailVerified: idToken.Claims.(*IDTokenClaims).EmailVerified.Value(), - } - } - - return token.AccessToken, err -} - -func (s Session) String() string { - return s.Marshal() -} - -// BoolString is a type that can be unmarshalled from a JSON field that can be either a boolean or a string. -// It is used to unmarshal some fields in the Apple ID token that can be sent as either boolean or string. -// See https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773 -type BoolString struct { - BoolValue bool - StringValue string - IsValidBool bool -} - -func (bs *BoolString) UnmarshalJSON(data []byte) error { - var b bool - if err := json.Unmarshal(data, &b); err == nil { - bs.BoolValue = b - bs.IsValidBool = true - return nil - } - - var s string - if err := json.Unmarshal(data, &s); err == nil { - bs.StringValue = s - return nil - } - - return errors.New("json field can be either boolean or string") -} - -func (bs *BoolString) Value() bool { - if bs.IsValidBool { - return bs.BoolValue - } - return bs.StringValue == "true" -} diff --git a/providers/apple/session_test.go b/providers/apple/session_test.go deleted file mode 100644 index 031b91637..000000000 --- a/providers/apple/session_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package apple - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/markbates/goth" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":"","email":"","is_private_email":false,"email_verified":false}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Equal(s.String(), s.Marshal()) -} - -func TestIDTokenClaimsUnmarshal(t *testing.T) { - t.Parallel() - a := assert.New(t) - - cases := []struct { - name string - idToken string - expectedClaims IDTokenClaims - }{ - { - name: "'is_private_email' claim is a string", - idToken: `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":"","email":"test-email@privaterelay.appleid.com","is_private_email":"true", "email_verified":"true"}`, - expectedClaims: IDTokenClaims{ - Email: "test-email@privaterelay.appleid.com", - IsPrivateEmail: BoolString{ - StringValue: "true", - }, - EmailVerified: BoolString{ - StringValue: "true", - }, - }, - }, - { - name: "'is_private_email' claim is a boolean", - idToken: `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":"","email":"test-email@privaterelay.appleid.com","is_private_email":true,"email_verified":true}`, - expectedClaims: IDTokenClaims{ - Email: "test-email@privaterelay.appleid.com", - IsPrivateEmail: BoolString{ - BoolValue: true, - IsValidBool: true, - }, - EmailVerified: BoolString{ - BoolValue: true, - IsValidBool: true, - }, - }, - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - idTokenClaims := IDTokenClaims{} - err := json.Unmarshal([]byte(c.idToken), &idTokenClaims) - a.NoError(err) - a.Equal(idTokenClaims, c.expectedClaims) - }) - } -} diff --git a/providers/auth0/auth0.go b/providers/auth0/auth0.go deleted file mode 100644 index c07b9db47..000000000 --- a/providers/auth0/auth0.go +++ /dev/null @@ -1,183 +0,0 @@ -// Package auth0 implements the OAuth2 protocol for authenticating users through uber. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package auth0 - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authEndpoint string = "/authorize" - tokenEndpoint string = "/oauth/token" - endpointProfile string = "/userinfo" - protocol string = "https://" -) - -// Provider is the implementation of `goth.Provider` for accessing Auth0. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - Domain string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -type auth0UserResp struct { - Name string `json:"name"` - NickName string `json:"nickname"` - Email string `json:"email"` - UserID string `json:"sub"` - AvatarURL string `json:"picture"` -} - -// New creates a new Auth0 provider and sets up important connection details. -// You should always call `auth0.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, auth0Domain string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - Domain: auth0Domain, - providerName: "auth0", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the auth0 package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Auth0 for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Auth0 and access basic information about the user. -// the full response will be included in RawData -// https://auth0.com/docs/api/authentication#get-user-info - -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - userProfileURL := protocol + p.Domain + endpointProfile - req, err := http.NewRequest("GET", userProfileURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: protocol + provider.Domain + authEndpoint, - TokenURL: protocol + provider.Domain + tokenEndpoint, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "profile", "openid") - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - var rawData map[string]interface{} - - buf := new(bytes.Buffer) - buf.ReadFrom(r) - err := json.Unmarshal(buf.Bytes(), &rawData) - if err != nil { - return err - } - - u := auth0UserResp{} - err = json.Unmarshal(buf.Bytes(), &u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.NickName = u.NickName - user.UserID = u.UserID - user.AvatarURL = u.AvatarURL - user.RawData = rawData - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(oauth2.NoContext, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/auth0/auth0_test.go b/providers/auth0/auth0_test.go deleted file mode 100644 index 06be18197..000000000 --- a/providers/auth0/auth0_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package auth0_test - -import ( - "os" - "testing" - - "github.com/jarcoal/httpmock" - "github.com/markbates/goth" - "github.com/markbates/goth/providers/auth0" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("AUTH0_KEY")) - a.Equal(p.Secret, os.Getenv("AUTH0_SECRET")) - a.Equal(p.Domain, os.Getenv("AUTH0_DOMAIN")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*auth0.Session) - a.NoError(err) - expectedAuthURL := "https://" + os.Getenv("AUTH0_DOMAIN") + "/authorize" - a.Contains(s.AuthURL, expectedAuthURL) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - sessionResp := `{"AuthURL":"https://` + p.Domain + `/oauth/authorize","AccessToken":"1234567890"}` - session, err := p.UnmarshalSession(sessionResp) - a.NoError(err) - - s := session.(*auth0.Session) - expectedAuthURL := "https://" + os.Getenv("AUTH0_DOMAIN") + "/oauth/authorize" - a.Equal(s.AuthURL, expectedAuthURL) - a.Equal(s.AccessToken, "1234567890") -} - -func Test_FetchUser(t *testing.T) { - // t.Parallel() - a := assert.New(t) - - httpmock.Activate() - defer httpmock.DeactivateAndReset() - - sampleResp := `{ - "email_verified": false, - "email": "test.account@userinfo.com", - "clientID": "q2hnj2iu...", - "updated_at": "2016-12-05T15:15:40.545Z", - "name": "test.account@userinfo.com", - "picture": "https://s.gravatar.com/avatar/dummy.png", - "user_id": "auth0|58454...", - "nickname": "test.account", - "identities": [ - { - "user_id": "58454...", - "provider": "auth0", - "connection": "Username-Password-Authentication", - "isSocial": false - }], - "created_at": "2016-12-05T11:16:59.640Z", - "sub": "auth0|58454..." - }` - - httpmock.RegisterResponder("GET", "https://"+os.Getenv("AUTH0_DOMAIN")+"/userinfo", httpmock.NewStringResponder(200, sampleResp)) - - p := provider() - session, _ := p.BeginAuth("test_state") - s := session.(*auth0.Session) - s.AccessToken = "token" - u, err := p.FetchUser(s) - a.Nil(err) - a.Equal(u.Email, "test.account@userinfo.com") - a.Equal(u.UserID, "auth0|58454...") - a.Equal(u.NickName, "test.account") - a.Equal(u.Name, "test.account@userinfo.com") - a.Equal("token", u.AccessToken) - -} - -func provider() *auth0.Provider { - return auth0.New(os.Getenv("AUTH0_KEY"), os.Getenv("AUTH0_SECRET"), "/foo", os.Getenv("AUTH0_DOMAIN")) -} diff --git a/providers/auth0/session.go b/providers/auth0/session.go deleted file mode 100644 index ad2f7e29b..000000000 --- a/providers/auth0/session.go +++ /dev/null @@ -1,64 +0,0 @@ -package auth0 - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Auth0. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Auth0 provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Auth0 and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/auth0/session_test.go b/providers/auth0/session_test.go deleted file mode 100644 index 2ddaaa684..000000000 --- a/providers/auth0/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package auth0_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/auth0" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &auth0.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &auth0.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &auth0.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &auth0.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/azuread/azuread.go b/providers/azuread/azuread.go deleted file mode 100644 index 8717ddf37..000000000 --- a/providers/azuread/azuread.go +++ /dev/null @@ -1,187 +0,0 @@ -// Package azuread implements the OAuth2 protocol for authenticating users through AzureAD. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -// To use microsoft personal account use microsoftonline provider -package azuread - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://login.microsoftonline.com/common/oauth2/authorize" - tokenURL string = "https://login.microsoftonline.com/common/oauth2/token" - endpointProfile string = "https://graph.windows.net/me?api-version=1.6" - graphAPIResource string = "https://graph.windows.net/" -) - -// New creates a new AzureAD provider, and sets up important connection details. -// You should always call `AzureAD.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, resources []string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "azuread", - } - - p.resources = make([]string, 0, 1+len(resources)) - p.resources = append(p.resources, graphAPIResource) - p.resources = append(p.resources, resources...) - - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing AzureAD. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - resources []string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client is HTTP client to be used in all fetch operations. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks AzureAD for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - authURL := p.config.AuthCodeURL(state) - - // Azure ad requires at least one resource - authURL += "&resource=" + url.QueryEscape(strings.Join(p.resources, " ")) - - return &Session{ - AuthURL: authURL, - }, nil -} - -// FetchUser will go to AzureAD and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - msSession := session.(*Session) - user := goth.User{ - AccessToken: msSession.AccessToken, - Provider: p.Name(), - ExpiresAt: msSession.ExpiresAt, - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - - req.Header.Set(authorizationHeader(msSession)) - - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - err = userFromReader(response.Body, &user) - return user, err -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "user_impersonation") - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Email string `json:"mail"` - FirstName string `json:"givenName"` - LastName string `json:"surname"` - NickName string `json:"mailNickname"` - UserPrincipalName string `json:"userPrincipalName"` - Location string `json:"usageLocation"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - user.Email = u.Email - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.Name - user.Location = u.Location - user.UserID = u.UserPrincipalName // AzureAD doesn't provide separate user_id - - return nil -} - -func authorizationHeader(session *Session) (string, string) { - return "Authorization", fmt.Sprintf("Bearer %s", session.AccessToken) -} diff --git a/providers/azuread/azuread_test.go b/providers/azuread/azuread_test.go deleted file mode 100644 index 5608a4756..000000000 --- a/providers/azuread/azuread_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package azuread_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/azuread" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := azureadProvider() - - a.Equal(provider.ClientKey, os.Getenv("AZUREAD_KEY")) - a.Equal(provider.Secret, os.Getenv("AZUREAD_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := azureadProvider() - a.Implements((*goth.Provider)(nil), p) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := azureadProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*azuread.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.microsoftonline.com/common/oauth2/authorize") - a.Contains(s.AuthURL, "https%3A%2F%2Fgraph.windows.net%2F") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := azureadProvider() - session, err := provider.UnmarshalSession(`{"AuthURL":"https://login.microsoftonline.com/common/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*azuread.Session) - a.Equal(s.AuthURL, "https://login.microsoftonline.com/common/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func azureadProvider() *azuread.Provider { - return azuread.New(os.Getenv("AZUREAD_KEY"), os.Getenv("AZUREAD_SECRET"), "/foo", nil) -} diff --git a/providers/azuread/session.go b/providers/azuread/session.go deleted file mode 100644 index 098a9dc6d..000000000 --- a/providers/azuread/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package azuread - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session is the implementation of `goth.Session` for accessing AzureAD. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - - return s.AuthURL, nil -} - -// Authorize the session with AzureAD and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - session := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(session) - return session, err -} diff --git a/providers/azuread/session_test.go b/providers/azuread/session_test.go deleted file mode 100644 index 4192d4a50..000000000 --- a/providers/azuread/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package azuread_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/azuread" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azuread.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azuread.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azuread.Session{} - - data := s.Marshal() - a.Equal(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`, data) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azuread.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/azureadv2/azureadv2.go b/providers/azureadv2/azureadv2.go deleted file mode 100644 index e76419f80..000000000 --- a/providers/azureadv2/azureadv2.go +++ /dev/null @@ -1,232 +0,0 @@ -package azureadv2 - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints -const ( - authURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize" - tokenURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" - graphAPIResource string = "https://graph.microsoft.com/v1.0/" -) - -type ( - // TenantType are the well known tenant types to scope the users that can authenticate. TenantType is not an - // exclusive list of Azure Tenants which can be used. A consumer can also use their own Tenant ID to scope - // authentication to their specific Tenant either through the Tenant ID or the friendly domain name. - // - // see also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints - TenantType string - - // Provider is the implementation of `goth.Provider` for accessing AzureAD V2. - Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - } - - // ProviderOptions are the collection of optional configuration to provide when constructing a Provider - ProviderOptions struct { - Scopes []ScopeType - Tenant TenantType - } -) - -// These are the well known Azure AD Tenants. These are not an exclusive list of all Tenants -// -// See also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints -const ( - // CommonTenant allows users with both personal Microsoft accounts and work/school accounts from Azure Active - // Directory to sign into the application. - CommonTenant TenantType = "common" - - // OrganizationsTenant allows only users with work/school accounts from Azure Active Directory to sign into the application. - OrganizationsTenant TenantType = "organizations" - - // ConsumersTenant allows only users with personal Microsoft accounts (MSA) to sign into the application. - ConsumersTenant TenantType = "consumers" -) - -// New creates a new AzureAD provider, and sets up important connection details. -// You should always call `AzureAD.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, opts ProviderOptions) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "azureadv2", - } - - p.config = newConfig(p, opts) - return p -} - -func newConfig(provider *Provider, opts ProviderOptions) *oauth2.Config { - tenant := opts.Tenant - if tenant == "" { - tenant = CommonTenant - } - - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf(authURLTemplate, tenant), - TokenURL: fmt.Sprintf(tokenURLTemplate, tenant), - }, - Scopes: []string{}, - } - - if len(opts.Scopes) > 0 { - c.Scopes = append(c.Scopes, scopesToStrings(opts.Scopes...)...) - } else { - defaultScopes := scopesToStrings(OpenIDScope, ProfileScope, EmailScope, UserReadScope) - c.Scopes = append(c.Scopes, defaultScopes...) - } - - return c -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client is HTTP client to be used in all fetch operations. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the package -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks for an authentication end-point for AzureAD. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - authURL := p.config.AuthCodeURL(state) - - return &Session{ - AuthURL: authURL, - }, nil -} - -// FetchUser will go to AzureAD and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - msSession := session.(*Session) - user := goth.User{ - AccessToken: msSession.AccessToken, - Provider: p.Name(), - ExpiresAt: msSession.ExpiresAt, - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", graphAPIResource+"me", nil) - if err != nil { - return user, err - } - - req.Header.Set(authorizationHeader(msSession)) - - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - err = userFromReader(response.Body, &user) - user.AccessToken = msSession.AccessToken - user.RefreshToken = msSession.RefreshToken - user.ExpiresAt = msSession.ExpiresAt - return user, err -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func authorizationHeader(session *Session) (string, string) { - return "Authorization", fmt.Sprintf("Bearer %s", session.AccessToken) -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - ID string `json:"id"` // The unique identifier for the user. - BusinessPhones []string `json:"businessPhones"` // The user's phone numbers. - DisplayName string `json:"displayName"` // The name displayed in the address book for the user. - FirstName string `json:"givenName"` // The first name of the user. - JobTitle string `json:"jobTitle"` // The user's job title. - Email string `json:"mail"` // The user's email address. - MobilePhone string `json:"mobilePhone"` // The user's cellphone number. - OfficeLocation string `json:"officeLocation"` // The user's physical office location. - PreferredLanguage string `json:"preferredLanguage"` // The user's language of preference. - LastName string `json:"surname"` // The last name of the user. - UserPrincipalName string `json:"userPrincipalName"` // The user's principal name. - }{} - - userBytes, err := io.ReadAll(r) - if err != nil { - return err - } - - if err := json.Unmarshal(userBytes, &u); err != nil { - return err - } - - user.Email = u.Email - user.Name = u.DisplayName - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.DisplayName - user.Location = u.OfficeLocation - user.UserID = u.ID - user.AvatarURL = graphAPIResource + fmt.Sprintf("users/%s/photo/$value", u.ID) - // Make sure all the information returned is available via RawData - if err := json.Unmarshal(userBytes, &user.RawData); err != nil { - return err - } - - return nil -} - -func scopesToStrings(scopes ...ScopeType) []string { - strs := make([]string, len(scopes)) - for i := 0; i < len(scopes); i++ { - strs[i] = string(scopes[i]) - } - return strs -} diff --git a/providers/azureadv2/azureadv2_test.go b/providers/azureadv2/azureadv2_test.go deleted file mode 100644 index 265ec90a6..000000000 --- a/providers/azureadv2/azureadv2_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package azureadv2_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/azureadv2" - "github.com/stretchr/testify/assert" -) - -const ( - applicationID = "6731de76-14a6-49ae-97bc-6eba6914391e" - secret = "foo" - redirectUri = "https://localhost:3000" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := azureadProvider() - - a.Equal(provider.Name(), "azureadv2") - a.Equal(provider.ClientKey, applicationID) - a.Equal(provider.Secret, secret) - a.Equal(provider.CallbackURL, redirectUri) -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := azureadProvider() - a.Implements((*goth.Provider)(nil), p) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := azureadProvider() - session, err := provider.BeginAuth("test_state") - a.NoError(err) - s := session.(*azureadv2.Session) - a.Contains(s.AuthURL, "login.microsoftonline.com/common/oauth2/v2.0/authorize") - a.Contains(s.AuthURL, "redirect_uri=https%3A%2F%2Flocalhost%3A3000") - a.Contains(s.AuthURL, "scope=openid+profile+email") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := azureadProvider() - session, err := provider.UnmarshalSession(`{"au":"http://foo","at":"1234567890"}`) - a.NoError(err) - - s := session.(*azureadv2.Session) - a.Equal(s.AuthURL, "http://foo") - a.Equal(s.AccessToken, "1234567890") -} - -func azureadProvider() *azureadv2.Provider { - return azureadv2.New(applicationID, secret, redirectUri, azureadv2.ProviderOptions{}) -} diff --git a/providers/azureadv2/scopes.go b/providers/azureadv2/scopes.go deleted file mode 100644 index fc6c6f367..000000000 --- a/providers/azureadv2/scopes.go +++ /dev/null @@ -1,717 +0,0 @@ -package azureadv2 - -type ( - // ScopeType are the well known scopes which can be requested - ScopeType string -) - -// OpenID Permissions -// -// You can use these permissions to specify artifacts that you want returned in Azure AD authorization and token -// requests. They are supported differently by the Azure AD v1.0 and v2.0 endpoints. -// -// With the Azure AD (v1.0) endpoint, only the openid permission is used. You specify it in the scope parameter in an -// authorization request to return an ID token when you use the OpenID Connect protocol to sign in a user to your app. -// For more information, see Authorize access to web applications using OpenID Connect and Azure Active Directory. To -// successfully return an ID token, you must also make sure that the User.Read permission is configured when you -// register your app. -// -// With the Azure AD v2.0 endpoint, you specify the offline_access permission in the scope parameter to explicitly -// request a refresh token when using the OAuth 2.0 or OpenID Connect protocols. With OpenID Connect, you specify the -// openid permission to request an ID token. You can also specify the email permission, profile permission, or both to -// return additional claims in the ID token. You do not need to specify User.Read to return an ID token with the v2.0 -// endpoint. For more information, see OpenID Connect scopes. -const ( - // OpenIDScope shows on the work account consent page as the "Sign you in" permission, and on the personal Microsoft - // account consent page as the "View your profile and connect to apps and services using your Microsoft account" - // permission. With this permission, an app can receive a unique identifier for the user in the form of the sub - // claim. It also gives the app access to the UserInfo endpoint. The openid scope can be used at the v2.0 token - // endpoint to acquire ID tokens, which can be used to secure HTTP calls between different components of an app. - OpenIDScope ScopeType = "openid" - - // EmailScope can be used with the openid scope and any others. It gives the app access to the user's primary - // email address in the form of the email claim. The email claim is included in a token only if an email address is - // associated with the user account, which is not always the case. If it uses the email scope, your app should be - // prepared to handle a case in which the email claim does not exist in the token. - EmailScope ScopeType = "email" - - // ProfileScope can be used with the openid scope and any others. It gives the app access to a substantial - // amount of information about the user. The information it can access includes, but is not limited to, the user's - // given name, surname, preferred username, and object ID. For a complete list of the profile claims available in - // the id_tokens parameter for a specific user, see the v2.0 tokens reference: - // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-id-and-access-tokens. - ProfileScope ScopeType = "profile" - - // OfflineAccessScope gives your app access to resources on behalf of the user for an extended time. On the work - // account consent page, this scope appears as the "Access your data anytime" permission. On the personal Microsoft - // account consent page, it appears as the "Access your info anytime" permission. When a user approves the - // offline_access scope, your app can receive refresh tokens from the v2.0 token endpoint. Refresh tokens are - // long-lived. Your app can get new access tokens as older ones expire. - // - // If your app does not request the offline_access scope, it won't receive refresh tokens. This means that when you - // redeem an authorization code in the OAuth 2.0 authorization code flow, you'll receive only an access token from - // the /token endpoint. The access token is valid for a short time. The access token usually expires in one hour. - // At that point, your app needs to redirect the user back to the /authorize endpoint to get a new authorization - // code. During this redirect, depending on the type of app, the user might need to enter their credentials again - // or consent again to permissions. - OfflineAccessScope ScopeType = "offline_access" -) - -// Calendar Permissions -// -// Calendars.Read.Shared and Calendars.ReadWrite.Shared are only valid for work or school accounts. All other -// permissions are valid for both Microsoft accounts and work or school accounts. -// -// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference -const ( - // CalendarsReadScope allows the app to read events in user calendars. - CalendarsReadScope ScopeType = "Calendars.Read" - - // CalendarsReadSharedScope allows the app to read events in all calendars that the user can access, including - // delegate and shared calendars. - CalendarsReadSharedScope ScopeType = "Calendars.Read.Shared" - - // CalendarsReadWriteScope allows the app to create, read, update, and delete events in user calendars. - CalendarsReadWriteScope ScopeType = "Calendars.ReadWrite" - - // CalendarsReadWriteSharedScope allows the app to create, read, update and delete events in all calendars the user - // has permissions to access. This includes delegate and shared calendars. - CalendarsReadWriteSharedScope ScopeType = "Calendars.ReadWrite.Shared" -) - -// Contacts Permissions -// -// Only the Contacts.Read and Contacts.ReadWrite delegated permissions are valid for Microsoft accounts. -// -// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference -const ( - // ContactsReadScope allows the app to read contacts that the user has permissions to access, including the user's - // own and shared contacts. - ContactsReadScope ScopeType = "Contacts.Read" - - // ContactsReadSharedScope allows the app to read contacts that the user has permissions to access, including the - // user's own and shared contacts. - ContactsReadSharedScope ScopeType = "Contacts.Read.Shared" - - // ContactsReadWriteScope allows the app to create, read, update, and delete user contacts. - ContactsReadWriteScope ScopeType = "Contacts.ReadWrite" - - // ContactsReadWriteSharedScope allows the app to create, read, update and delete contacts that the user has - // permissions to, including the user's own and shared contacts. - ContactsReadWriteSharedScope ScopeType = "Contacts.ReadWrite.Shared" -) - -// Device Permissions -// -// The Device.Read and Device.Command delegated permissions are valid only for personal Microsoft accounts. -// -// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference -const ( - // DeviceReadScope allows the app to read a user's list of devices on behalf of the signed-in user. - DeviceReadScope ScopeType = "Device.Read" - - // DeviceCommandScope allows the app to launch another app or communicate with another app on a user's device on - // behalf of the signed-in user. - DeviceCommandScope ScopeType = "Device.Command" -) - -// Directory Permissions -// -// Directory permissions are not supported on Microsoft accounts. -// -// Directory permissions provide the highest level of privilege for accessing directory resources such as User, Group, -// and Device in an organization. -// -// They also exclusively control access to other directory resources like: organizational contacts, schema extension -// APIs, Privileged Identity Management (PIM) APIs, as well as many of the resources and APIs listed under the Azure -// Active Directory node in the v1.0 and beta API reference documentation. These include administrative units, directory -// roles, directory settings, policy, and many more. -// -// The Directory.ReadWrite.All permission grants the following privileges: -// - Full read of all directory resources (both declared properties and navigation properties) -// - Create and update users -// - Disable and enable users (but not company administrator) -// - Set user alternative security id (but not administrators) -// - Create and update groups -// - Manage group memberships -// - Update group owner -// - Manage license assignments -// - Define schema extensions on applications -// - Note: No rights to reset user passwords -// - Note: No rights to delete resources (including users or groups) -// - Note: Specifically excludes create or update for resources not listed above. This includes: application, -// oAauth2Permissiongrant, appRoleAssignment, device, servicePrincipal, organization, domains, and so on. -// -// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference -const ( - // DirectoryReadAllScope allows the app to read data in your organization's directory, such as users, groups and - // apps. - // - // Note: Users may consent to applications that require this permission if the application is registered in their - // own organization’s tenant. - // - // requires admin consent - DirectoryReadAllScope ScopeType = "Directory.Read.All" - - // DirectoryReadWriteAllScope allows the app to read and write data in your organization's directory, such as users, - // and groups. It does not allow the app to delete users or groups, or reset user passwords. - // - // requires admin consent - DirectoryReadWriteAllScope ScopeType = "Directory.ReadWrite.All" - - // DirectoryAccessAsUserAllScope allows the app to have the same access to information in the directory as the - // signed-in user. - // - // requires admin consent - DirectoryAccessAsUserAllScope ScopeType = "Directory.AccessAsUser.All" -) - -// Education Administration Permissions -const ( - // EduAdministrationReadScope allows the app to read education app settings on behalf of the user. - // - // requires admin consent - EduAdministrationReadScope ScopeType = "EduAdministration.Read" - - // EduAdministrationReadWriteScope allows the app to manage education app settings on behalf of the user. - // - // requires admin consent - EduAdministrationReadWriteScope ScopeType = "EduAdministration.ReadWrite" - - // EduAssignmentsReadBasicScope allows the app to read assignments without grades on behalf of the user - // - // requires admin consent - EduAssignmentsReadBasicScope ScopeType = "EduAssignments.ReadBasic" - - // EduAssignmentsReadWriteBasicScope allows the app to read and write assignments without grades on behalf of the - // user - EduAssignmentsReadWriteBasicScope ScopeType = "EduAssignments.ReadWriteBasic" - - // EduAssignmentsReadScope allows the app to read assignments and their grades on behalf of the user - // - // requires admin consent - EduAssignmentsReadScope ScopeType = "EduAssignments.Read" - - // EduAssignmentsReadWriteScope allows the app to read and write assignments and their grades on behalf of the user - // - // requires admin consent - EduAssignmentsReadWriteScope ScopeType = "EduAssignments.ReadWrite" - - // EduRosteringReadBasicScope allows the app to read a limited subset of the data from the structure of schools and - // classes in an organization's roster and education-specific information about users to be read on behalf of the - // user. - // - // requires admin consent - EduRosteringReadBasicScope ScopeType = "EduRostering.ReadBasic" -) - -// Files Permissions -// -// The Files.Read, Files.ReadWrite, Files.Read.All, and Files.ReadWrite.All delegated permissions are valid on both -// personal Microsoft accounts and work or school accounts. Note that for personal accounts, Files.Read and -// Files.ReadWrite also grant access to files shared with the signed-in user. -// -// The Files.Read.Selected and Files.ReadWrite.Selected delegated permissions are only valid on work or school accounts -// and are only exposed for working with Office 365 file handlers (v1.0) -// https://msdn.microsoft.com/office/office365/howto/using-cross-suite-apps. They should not be used for directly -// calling Microsoft Graph APIs. -// -// The Files.ReadWrite.AppFolder delegated permission is only valid for personal accounts and is used for accessing the -// App Root special folder https://dev.onedrive.com/misc/appfolder.htm with the OneDrive Get special folder -// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/drive_get_specialfolder Microsoft Graph API. -const ( - // FilesReadScope allows the app to read the signed-in user's files. - FilesReadScope ScopeType = "Files.Read" - - // FilesReadAllScope allows the app to read all files the signed-in user can access. - FilesReadAllScope ScopeType = "Files.Read.All" - - // FilesReadWrite allows the app to read, create, update, and delete the signed-in user's files. - FilesReadWriteScope ScopeType = "Files.ReadWrite" - - // FilesReadWriteAllScope allows the app to read, create, update, and delete all files the signed-in user can access. - FilesReadWriteAllScope ScopeType = "Files.ReadWrite.All" - - // FilesReadWriteAppFolderScope allows the app to read, create, update, and delete files in the application's folder. - FilesReadWriteAppFolderScope ScopeType = "Files.ReadWrite.AppFolder" - - // FilesReadSelectedScope allows the app to read files that the user selects. The app has access for several hours - // after the user selects a file. - // - // preview - FilesReadSelectedScope ScopeType = "Files.Read.Selected" - - // FilesReadWriteSelectedScope allows the app to read and write files that the user selects. The app has access for - // several hours after the user selects a file - // - // preview - FilesReadWriteSelectedScope ScopeType = "Files.ReadWrite.Selected" -) - -// Group Permissions -// -// Group functionality is not supported on personal Microsoft accounts. -// -// For Office 365 groups, Group permissions grant the app access to the contents of the group; for example, -// conversations, files, notes, and so on. -// -// For application permissions, there are some limitations for the APIs that are supported. For more information, see -// known issues. -// -// In some cases, an app may need Directory permissions to read some group properties like member and memberOf. For -// example, if a group has a one or more servicePrincipals as members, the app will need effective permissions to read -// service principals through being granted one of the Directory.* permissions, otherwise Microsoft Graph will return an -// error. (In the case of delegated permissions, the signed-in user will also need sufficient privileges in the -// organization to read service principals.) The same guidance applies for the memberOf property, which can return -// administrativeUnits. -// -// Group permissions are also used to control access to Microsoft Planner resources and APIs. Only delegated permissions -// are supported for Microsoft Planner APIs; application permissions are not supported. Personal Microsoft accounts are -// not supported. -const ( - // GroupReadAllScope allows the app to list groups, and to read their properties and all group memberships on behalf - // of the signed-in user. Also allows the app to read calendar, conversations, files, and other group content for - // all groups the signed-in user can access. - GroupReadAllScope ScopeType = "Group.Read.All" - - // GroupReadWriteAllScope allows the app to create groups and read all group properties and memberships on behalf of - // the signed-in user. Additionally allows group owners to manage their groups and allows group members to update - // group content. - GroupReadWriteAllScope ScopeType = "Group.ReadWrite.All" -) - -// Identity Risk Event Permissions -// -// IdentityRiskEvent.Read.All is valid only for work or school accounts. For an app with delegated permissions to read -// identity risk information, the signed-in user must be a member of one of the following administrator roles: Global -// Administrator, Security Administrator, or Security Reader. For more information about administrator roles, see -// Assigning administrator roles in Azure Active Directory. -const ( - // IdentityRiskEventReadAllScope allows the app to read identity risk event information for all users in your - // organization on behalf of the signed-in user. - // - // requires admin consent - IdentityRiskEventReadAllScope ScopeType = "IdentityRiskEvent.Read.All" -) - -// Identity Provider Permissions -// -// IdentityProvider.Read.All and IdentityProvider.ReadWrite.All are valid only for work or school accounts. For an app -// to read or write identity providers with delegated permissions, the signed-in user must be assigned the Global -// Administrator role. For more information about administrator roles, see Assigning administrator roles in Azure Active -// Directory. -const ( - // IdentityProviderReadAllScope allows the app to read identity providers configured in your Azure AD or Azure AD - // B2C tenant on behalf of the signed-in user. - // - // requires admin consent - IdentityProviderReadAllScope ScopeType = "IdentityProvider.Read.All" - - // IdentityProviderReadWriteAllScope allows the app to read or write identity providers configured in your Azure AD - // or Azure AD B2C tenant on behalf of the signed-in user. - // - // requires admin consent - IdentityProviderReadWriteAllScope ScopeType = "IdentityProvider.ReadWrite.All" -) - -// Device Management Permissions -// -// Using the Microsoft Graph APIs to configure Intune controls and policies still requires that the Intune service is -// correctly licensed by the customer. -// -// These permissions are only valid for work or school accounts. -const ( - // DeviceManagementAppsReadAllScope allows the app to read the properties, group assignments and status of apps, app - // configurations and app protection policies managed by Microsoft Intune. - // - // requires admin consent - DeviceManagementAppsReadAllScope ScopeType = "DeviceManagementApps.Read.All" - - // DeviceManagementAppsReadWriteAllScope allows the app to read and write the properties, group assignments and - // status of apps, app configurations and app protection policies managed by Microsoft Intune. - // - // requires admin consent - DeviceManagementAppsReadWriteAllScope ScopeType = "DeviceManagementApps.ReadWrite.All" - - // DeviceManagementConfigurationReadAllScope allows the app to read properties of Microsoft Intune-managed device - // configuration and device compliance policies and their assignment to groups. - // - // requires admin consent - DeviceManagementConfigurationReadAllScope ScopeType = "DeviceManagementConfiguration.Read.All" - - // DeviceManagementConfigurationReadWriteAllScope allows the app to read and write properties of Microsoft - // Intune-managed device configuration and device compliance policies and their assignment to groups. - // - // requires admin consent - DeviceManagementConfigurationReadWriteAllScope ScopeType = "DeviceManagementConfiguration.ReadWrite.All" - - // DeviceManagementManagedDevicesPrivilegedOperationsAllScope allows the app to perform remote high impact actions - // such as wiping the device or resetting the passcode on devices managed by Microsoft Intune. - // - // requires admin consent - DeviceManagementManagedDevicesPrivilegedOperationsAllScope ScopeType = "DeviceManagementManagedDevices.PrivilegedOperations.All" - - // DeviceManagementManagedDevicesReadAllScope allows the app to read the properties of devices managed by Microsoft - // Intune. - // - // requires admin consent - DeviceManagementManagedDevicesReadAllScope ScopeType = "DeviceManagementManagedDevices.Read.All" - - // DeviceManagementManagedDevicesReadWriteAllScope allows the app to read and write the properties of devices - // managed by Microsoft Intune. Does not allow high impact operations such as remote wipe and password reset on the - // device’s owner. - // - // requires admin consent - DeviceManagementManagedDevicesReadWriteAllScope ScopeType = "DeviceManagementManagedDevices.ReadWrite.All" - - // DeviceManagementRBACReadAllScope allows the app to read the properties relating to the Microsoft Intune - // Role-Based Access Control (RBAC) settings. - // - // requires admin consent - DeviceManagementRBACReadAllScope ScopeType = "DeviceManagementRBAC.Read.All" - - // DeviceManagementRBACReadWriteAllScope allows the app to read and write the properties relating to the Microsoft - // Intune Role-Based Access Control (RBAC) settings. - // - // requires admin consent - DeviceManagementRBACReadWriteAllScope ScopeType = "DeviceManagementRBAC.ReadWrite.All" - - // DeviceManagementServiceConfigReadAllScope allows the app to read Intune service properties including device - // enrollment and third party service connection configuration. - // - // requires admin consent - DeviceManagementServiceConfigReadAllScope ScopeType = "DeviceManagementServiceConfig.Read.All" - - // DeviceManagementServiceConfigReadWriteAllScope allows the app to read and write Microsoft Intune service - // properties including device enrollment and third party service connection configuration. - // - // requires admin consent - DeviceManagementServiceConfigReadWriteAllScope ScopeType = "DeviceManagementServiceConfig.ReadWrite.All" -) - -// Mail Permissions -// -// Mail.Read.Shared, Mail.ReadWrite.Shared, and Mail.Send.Shared are only valid for work or school accounts. All other -// permissions are valid for both Microsoft accounts and work or school accounts. -// -// With the Mail.Send or Mail.Send.Shared permission, an app can send mail and save a copy to the user's Sent Items -// folder, even if the app does not use a corresponding Mail.ReadWrite or Mail.ReadWrite.Shared permission. -const ( - // MailReadScope allows the app to read email in user mailboxes. - MailReadScope ScopeType = "Mail.Read" - - // MailReadWriteScope allows the app to create, read, update, and delete email in user mailboxes. Does not include - // permission to send mail. - MailReadWriteScope ScopeType = "Mail.ReadWrite" - - // MailReadSharedScope allows the app to read mail that the user can access, including the user's own and shared - // mail. - MailReadSharedScope ScopeType = "Mail.Read.Shared" - - // MailReadWriteSharedScope allows the app to create, read, update, and delete mail that the user has permission to - // access, including the user's own and shared mail. Does not include permission to send mail. - MailReadWriteSharedScope ScopeType = "Mail.ReadWrite.Shared" - - // MailSend allowsScope the app to send mail as users in the organization. - MailSendScope ScopeType = "Mail.Send" - - // MailSendSharedScope allows the app to send mail as the signed-in user, including sending on-behalf of others. - MailSendSharedScope ScopeType = "Mail.Send.Shared" - - // MailboxSettingsReadScope allows the app to the read user's mailbox settings. Does not include permission to send - // mail. - MailboxSettingsReadScope ScopeType = "Mailbox.Settings.Read" - - // MailboxSettingsReadWriteScope allows the app to create, read, update, and delete user's mailbox settings. Does - // not include permission to directly send mail, but allows the app to create rules that can forward or redirect - // messages. - MailboxSettingsReadWriteScope ScopeType = "MailboxSettings.ReadWrite" -) - -// Member Permissions -// -// Member.Read.Hidden is valid only on work or school accounts. -// -// Membership in some Office 365 groups can be hidden. This means that only the members of the group can view its -// members. This feature can be used to help comply with regulations that require an organization to hide group -// membership from outsiders (for example, an Office 365 group that represents students enrolled in a class). -const ( - // MemberReadHiddenScope allows the app to read the memberships of hidden groups and administrative units on behalf - // of the signed-in user, for those hidden groups and administrative units that the signed-in user has access to. - // - // requires admin consent - MemberReadHiddenScope ScopeType = "Member.Read.Hidden" -) - -// Notes Permissions -// -// Notes.Read.All and Notes.ReadWrite.All are only valid for work or school accounts. All other permissions are valid -// for both Microsoft accounts and work or school accounts. -// -// With the Notes.Create permission, an app can view the OneNote notebook hierarchy of the signed-in user and create -// OneNote content (notebooks, section groups, sections, pages, etc.). -// -// Notes.ReadWrite and Notes.ReadWrite.All also allow the app to modify the permissions on the OneNote content that can -// be accessed by the signed-in user. -// -// For work or school accounts, Notes.Read.All and Notes.ReadWrite.All allow the app to access other users' OneNote -// content that the signed-in user has permission to within the organization. -const ( - // NotesReadScope allows the app to read OneNote notebooks on behalf of the signed-in user. - NotesReadScope ScopeType = "Notes.Read" - - // NotesCreateScope allows the app to read the titles of OneNote notebooks and sections and to create new pages, - // notebooks, and sections on behalf of the signed-in user. - NotesCreateScope ScopeType = "Notes.Create" - - // NotesReadWriteScope allows the app to read, share, and modify OneNote notebooks on behalf of the signed-in user. - NotesReadWriteScope ScopeType = "Notes.ReadWrite" - - // NotesReadAllScope allows the app to read OneNote notebooks that the signed-in user has access to in the - // organization. - NotesReadAllScope ScopeType = "Notes.Read.All" - - // NotesReadWriteAllScope allows the app to read, share, and modify OneNote notebooks that the signed-in user has - // access to in the organization. - NotesReadWriteAllScope ScopeType = "Notes.ReadWrite.All" -) - -// People Permissions -// -// The People.Read.All permission is only valid for work and school accounts. -const ( - // PeopleReadScope allows the app to read a scored list of people relevant to the signed-in user. The list can - // include local contacts, contacts from social networking or your organization's directory, and people from recent - // communications (such as email and Skype). - PeopleReadScope ScopeType = "People.Read" - - // PeopleReadAllScope allows the app to read a scored list of people relevant to the signed-in user or other users - // in the signed-in user's organization. The list can include local contacts, contacts from social networking or - // your organization's directory, and people from recent communications (such as email and Skype). Also allows the - // app to search the entire directory of the signed-in user's organization. - // - // requires admin consent - PeopleReadAllScope ScopeType = "People.Read.All" -) - -// Report Permissions -// -// Reports permissions are only valid for work or school accounts. -const ( - // ReportsReadAllScope allows an app to read all service usage reports without a signed-in user. Services that - // provide usage reports include Office 365 and Azure Active Directory. - // - // requires admin consent - ReportsReadAllScope ScopeType = "Reports.Read.All" -) - -// Security Permissions -// -// Security permissions are valid only on work or school accounts. -const ( - // SecurityEventsReadAllScope allows the app to read your organization’s security events on behalf of the signed-in - // user. - // requires admin consent - SecurityEventsReadAllScope ScopeType = "SecurityEvents.Read.All" - - // SecurityEventsReadWriteAllScope allows the app to read your organization’s security events on behalf of the - // signed-in user. Also allows the app to update editable properties in security events on behalf of the signed-in - // user. - // - // requires admin consent - SecurityEventsReadWriteAllScope ScopeType = "SecurityEvents.ReadWrite.All" -) - -// Sites Permissions -// -// Sites permissions are valid only on work or school accounts. -const ( - // SitesReadAllScope allows the app to read documents and list items in all site collections on behalf of the - // signed-in user. - SitesReadAllScope ScopeType = "Sites.Read.All" - - // SitesReadWriteAllScope allows the app to edit or delete documents and list items in all site collections on - // behalf of the signed-in user. - SitesReadWriteAllScope ScopeType = "Sites.ReadWrite.All" - - // SitesManageAllScope allows the app to manage and create lists, documents, and list items in all site collections - // on behalf of the signed-in user. - SitesManageAllScope ScopeType = "Sites.Manage.All" - - // SitesFullControlAllScope allows the app to have full control to SharePoint sites in all site collections on - // behalf of the signed-in user. - // - // requires admin consent - SitesFullControlAllScope ScopeType = "Sites.FullControl.All" -) - -// Tasks Permissions -// -// Tasks permissions are used to control access for Outlook tasks. Access for Microsoft Planner tasks is controlled by -// Group permissions. -// -// Shared permissions are currently only supported for work or school accounts. Even with Shared permissions, reads and -// writes may fail if the user who owns the shared content has not granted the accessing user permissions to modify -// content within the folder. -const ( - // TasksReadScope allows the app to read user tasks. - TasksReadScope ScopeType = "Tasks.Read" - - // TasksReadSharedScope allows the app to read tasks a user has permissions to access, including their own and - // shared tasks. - TasksReadSharedScope ScopeType = "Tasks.Read.Shared" - - // TasksReadWriteScope allows the app to create, read, update and delete tasks and containers (and tasks in them) - // that are assigned to or shared with the signed-in user. - TasksReadWriteScope ScopeType = "Tasks.ReadWrite" - - // TasksReadWriteSharedScope allows the app to create, read, update, and delete tasks a user has permissions to, - // including their own and shared tasks. - TasksReadWriteSharedScope ScopeType = "Tasks.ReadWrite.Shared" -) - -// Terms of Use Permissions -// -// All the permissions above are valid only for work or school accounts. -// -// For an app to read or write all agreements or agreement acceptances with delegated permissions, the signed-in user -// must be assigned the Global Administrator, Conditional Access Administrator or Security Administrator role. For more -// information about administrator roles, see Assigning administrator roles in Azure Active Directory -// https://docs.microsoft.com/azure/active-directory/active-directory-assign-admin-roles. -const ( - // AgreementReadAllScope allows the app to read terms of use agreements on behalf of the signed-in user. - // - // requires admin consent - AgreementReadAllScope ScopeType = "Agreement.Read.All" - - // AgreementReadWriteAllScope allows the app to read and write terms of use agreements on behalf of the signed-in - // user. - // - // requires admin consent - AgreementReadWriteAllScope ScopeType = "Agreement.ReadWrite.All" - - // AgreementAcceptanceReadScope allows the app to read terms of use acceptance statuses on behalf of the signed-in - // user. - // - // requires admin consent - AgreementAcceptanceReadScope ScopeType = "AgreementAcceptance.Read" - - // AgreementAcceptanceReadAllScope allows the app to read terms of use acceptance statuses on behalf of the - // signed-in user. - // - // requires admin consent - AgreementAcceptanceReadAllScope ScopeType = "AgreementAcceptance.Read.All" -) - -// User Permissions -// -// The only permissions valid for Microsoft accounts are User.Read and User.ReadWrite. For work or school accounts, all -// permissions are valid. -// -// With the User.Read permission, an app can also read the basic company information of the signed-in user for a work or -// school account through the organization resource. The following properties are available: id, displayName, and -// verifiedDomains. -// -// For work or school accounts, the full profile includes all of the declared properties of the User resource. On reads, -// only a limited number of properties are returned by default. To read properties that are not in the default set, use -// $select. The default properties are: -// -// displayName -// givenName -// jobTitle -// mail -// mobilePhone -// officeLocation -// preferredLanguage -// surname -// userPrincipalName -// -// User.ReadWrite and User.Readwrite.All delegated permissions allow the app to update the following profile properties -// for work or school accounts: -// -// aboutMe -// birthday -// hireDate -// interests -// mobilePhone -// mySite -// pastProjects -// photo -// preferredName -// responsibilities -// schools -// skills -// -// With the User.ReadWrite.All application permission, the app can update all of the declared properties of work or -// school accounts except for password. -// -// To read or write direct reports (directReports) or the manager (manager) of a work or school account, the app must -// have either User.Read.All (read only) or User.ReadWrite.All. -// -// The User.ReadBasic.All permission constrains app access to a limited set of properties known as the basic profile. -// This is because the full profile might contain sensitive directory information. The basic profile includes only the -// following properties: -// -// displayName -// givenName -// mail -// photo -// surname -// userPrincipalName -// -// To read the group memberships of a user (memberOf), the app must have either Group.Read.All or Group.ReadWrite.All. -// However, if the user also has membership in a directoryRole or an administrativeUnit, the app will need effective -// permissions to read those resources too, or Microsoft Graph will return an error. This means the app will also need -// Directory permissions, and, for delegated permissions, the signed-in user will also need sufficient privileges in the -// organization to access directory roles and administrative units. -const ( - // UserReadScope allows users to sign-in to the app, and allows the app to read the profile of signed-in users. It - // also allows the app to read basic company information of signed-in users. - UserReadScope ScopeType = "User.Read" - - // UserReadWriteScope allows the app to read the signed-in user's full profile. It also allows the app to update the - // signed-in user's profile information on their behalf. - UserReadWriteScope ScopeType = "User.ReadWrite" - - // UserReadBasicAllScope allows the app to read a basic set of profile properties of other users in your - // organization on behalf of the signed-in user. This includes display name, first and last name, email address, - // open extensions and photo. Also allows the app to read the full profile of the signed-in user. - UserReadBasicAllScope ScopeType = "User.ReadBasic.All" - - // UserReadAllScope allows the app to read the full set of profile properties, reports, and managers of other users - // in your organization, on behalf of the signed-in user. - // - // requires admin consent - UserReadAllScope ScopeType = "User.Read.All" - - // UserReadWriteAllScope allows the app to read and write the full set of profile properties, reports, and managers - // of other users in your organization, on behalf of the signed-in user. Also allows the app to create and delete - // users as well as reset user passwords on behalf of the signed-in user. - // - // requires admin consent - UserReadWriteAllScope ScopeType = "User.ReadWrite.All" - - // UserInviteAllScope allows the app to invite guest users to your organization, on behalf of the signed-in user. - // - // requires admin consent - UserInviteAllScope ScopeType = "User.Invite.All" - - // UserExportAllScope allows the app to export an organizational user's data, when performed by a Company - // Administrator. - // - // requires admin consent - UserExportAllScope ScopeType = "User.Export.All" -) - -// User Activity Permissions -// -// UserActivity.ReadWrite.CreatedByApp is valid for both Microsoft accounts and work or school accounts. -// -// The CreatedByApp constraint associated with this permission indicates the service will apply implicit filtering to -// results based on the identity of the calling app, either the MSA app id or a set of app ids configured for a -// cross-platform application identity. -const ( - // UserActivityReadWriteCreatedByAppScope allows the app to read and report the signed-in user's activity in the - // app. - UserActivityReadWriteCreatedByAppScope ScopeType = "UserActivity.ReadWrite.CreatedByApp" -) diff --git a/providers/azureadv2/session.go b/providers/azureadv2/session.go deleted file mode 100644 index f2f0cd07c..000000000 --- a/providers/azureadv2/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package azureadv2 - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session is the implementation of `goth.Session` -type Session struct { - AuthURL string `json:"au"` - AccessToken string `json:"at"` - RefreshToken string `json:"rt"` - ExpiresAt time.Time `json:"exp"` -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` func -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - - return s.AuthURL, nil -} - -// Authorize the session with AzureAD and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - session := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(session) - return session, err -} diff --git a/providers/azureadv2/session_test.go b/providers/azureadv2/session_test.go deleted file mode 100644 index 7edfde4e6..000000000 --- a/providers/azureadv2/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package azureadv2_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/azureadv2" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azureadv2.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azureadv2.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azureadv2.Session{} - - data := s.Marshal() - a.Equal(`{"au":"","at":"","rt":"","exp":"0001-01-01T00:00:00Z"}`, data) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &azureadv2.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/battlenet/battlenet.go b/providers/battlenet/battlenet.go deleted file mode 100644 index 47abdaca8..000000000 --- a/providers/battlenet/battlenet.go +++ /dev/null @@ -1,153 +0,0 @@ -// Package battlenet implements the OAuth2 protocol for authenticating users through Battle.net. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package battlenet - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://us.battle.net/oauth/authorize" - tokenURL string = "https://us.battle.net/oauth/token" - endpointUser string = "https://us.battle.net/oauth/userinfo" -) - -// Provider is the implementation of `goth.Provider` for accessing Battle.net. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Battle.net provider and sets up important connection details. -// You should always call `battlenet.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "battlenet", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the battlenet package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Battle.net for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Battle.net and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // Get the userID, battlenet needs userID in order to get user profile info - c := p.Client() - req, err := http.NewRequest("GET", endpointUser, nil) - if err != nil { - return user, err - } - - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - - response, err := c.Do(req) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - u := struct { - ID int64 `json:"id"` - Battletag string `json:"battletag"` - }{} - - if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { - return user, err - } - - user.NickName = u.Battletag - user.UserID = fmt.Sprintf("%d", u.ID) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} diff --git a/providers/battlenet/battlenet_test.go b/providers/battlenet/battlenet_test.go deleted file mode 100644 index 618a9e8dc..000000000 --- a/providers/battlenet/battlenet_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package battlenet_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/battlenet" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("BATTLENET_KEY")) - a.Equal(p.Secret, os.Getenv("BATTLENET_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*battlenet.Session) - a.NoError(err) - a.Contains(s.AuthURL, "us.battle.net/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://us.battle.net/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*battlenet.Session) - a.Equal(s.AuthURL, "https://us.battle.net/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *battlenet.Provider { - return battlenet.New(os.Getenv("BATTLENET_KEY"), os.Getenv("BATTLENET_SECRET"), "/foo") -} diff --git a/providers/battlenet/session.go b/providers/battlenet/session.go deleted file mode 100644 index 98fff650f..000000000 --- a/providers/battlenet/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package battlenet - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Battle.net. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Battle.net provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Battle.net and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/battlenet/session_test.go b/providers/battlenet/session_test.go deleted file mode 100644 index fd39dfcaf..000000000 --- a/providers/battlenet/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package battlenet_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/battlenet" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &battlenet.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &battlenet.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &battlenet.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &battlenet.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/bitbucket/bitbucket.go b/providers/bitbucket/bitbucket.go deleted file mode 100644 index 7c27a913d..000000000 --- a/providers/bitbucket/bitbucket.go +++ /dev/null @@ -1,241 +0,0 @@ -// Package bitbucket implements the OAuth2 protocol for authenticating users through Bitbucket. -package bitbucket - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://bitbucket.org/site/oauth2/authorize" - tokenURL string = "https://bitbucket.org/site/oauth2/access_token" - endpointProfile string = "https://api.bitbucket.org/2.0/user" - endpointEmail string = "https://api.bitbucket.org/2.0/user/emails" -) - -type EmailAddress struct { - Type string `json:"type"` - Links Links `json:"links"` - Email string `json:"email"` - IsPrimary bool `json:"is_primary"` - IsConfirmed bool `json:"is_confirmed"` -} - -type Links struct { - Self Self `json:"self"` -} - -type Self struct { - Href string `json:"href"` -} - -type MailList struct { - Values []EmailAddress `json:"values"` - Pagelen int `json:"pagelen"` - Size int `json:"size"` - Page int `json:"page"` -} - -// New creates a new Bitbucket provider, and sets up important connection details. -// You should always call `bitbucket.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "bitbucket", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Bitbucket. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the bitbucket package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Bitbucket for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Bitbucket and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - if err := p.getUserInfo(&user, sess); err != nil { - return user, err - } - - if err := p.getEmail(&user, sess); err != nil { - return user, err - } - - return user, nil -} - -func (p *Provider) getUserInfo(user *goth.User, sess *Session) error { - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return err - } - authenticateRequest(req, sess) - response, err := p.Client().Do(req) - if err != nil { - return err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return err - } - - u := struct { - ID string `json:"uuid"` - Links struct { - Avatar struct { - URL string `json:"href"` - } `json:"avatar"` - } `json:"links"` - Username string `json:"username"` - Name string `json:"display_name"` - Location string `json:"location"` - }{} - - if err := json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { - return err - } - - user.Name = u.Name - user.NickName = u.Username - user.AvatarURL = u.Links.Avatar.URL - user.UserID = u.ID - user.Location = u.Location - - return nil -} - -func (p *Provider) getEmail(user *goth.User, sess *Session) error { - req, err := http.NewRequest("GET", endpointEmail, nil) - if err != nil { - return err - } - authenticateRequest(req, sess) - response, err := p.Client().Do(req) - if err != nil { - return err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return fmt.Errorf("%s responded with a %d trying to fetch email addresses", p.providerName, response.StatusCode) - } - - var mailList MailList - err = json.NewDecoder(response.Body).Decode(&mailList) - if err != nil { - return err - } - - for _, emailAddress := range mailList.Values { - if emailAddress.IsPrimary && emailAddress.IsConfirmed { - user.Email = emailAddress.Email - return nil - } - } - - return fmt.Errorf("%s did not return any confirmed, primary email address", p.providerName) -} - -func authenticateRequest(req *http.Request, sess *Session) { - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/bitbucket/bitbucket_test.go b/providers/bitbucket/bitbucket_test.go deleted file mode 100644 index 22c8db3d3..000000000 --- a/providers/bitbucket/bitbucket_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package bitbucket_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/bitbucket" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := bitbucketProvider() - a.Equal(provider.ClientKey, os.Getenv("BITBUCKET_KEY")) - a.Equal(provider.Secret, os.Getenv("BITBUCKET_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), bitbucketProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := bitbucketProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*bitbucket.Session) - a.NoError(err) - a.Contains(s.AuthURL, "bitbucket.org/site/oauth2/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("BITBUCKET_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=user") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := bitbucketProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://bitbucket.org/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*bitbucket.Session) - a.Equal(session.AuthURL, "http://bitbucket.org/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func bitbucketProvider() *bitbucket.Provider { - return bitbucket.New(os.Getenv("BITBUCKET_KEY"), os.Getenv("BITBUCKET_SECRET"), "/foo", "user") -} diff --git a/providers/bitbucket/session.go b/providers/bitbucket/session.go deleted file mode 100644 index a65242151..000000000 --- a/providers/bitbucket/session.go +++ /dev/null @@ -1,61 +0,0 @@ -package bitbucket - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Bitbucket. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Bitbucket provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Bitbucket and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} - -func (s Session) String() string { - return s.Marshal() -} diff --git a/providers/bitbucket/session_test.go b/providers/bitbucket/session_test.go deleted file mode 100644 index ac181a775..000000000 --- a/providers/bitbucket/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package bitbucket_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/bitbucket" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &bitbucket.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &bitbucket.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &bitbucket.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &bitbucket.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/bitly/bitly.go b/providers/bitly/bitly.go deleted file mode 100644 index fc1b122c0..000000000 --- a/providers/bitly/bitly.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package bitly implements the OAuth2 protocol for authenticating users through Bitly. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package bitly - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authEndpoint string = "https://bitly.com/oauth/authorize" - tokenEndpoint string = "https://api-ssl.bitly.com/oauth/access_token" - profileEndpoint string = "https://api-ssl.bitly.com/v4/user" -) - -// New creates a new Bitly provider and sets up important connection details. -// You should always call `bitly.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - } - p.newConfig(scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Bitly. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Ensure `bitly.Provider` implements `goth.Provider`. -var _ goth.Provider = &Provider{} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type). -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the bitly package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Bitly for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Bitly and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - u := goth.User{ - Provider: p.Name(), - AccessToken: s.AccessToken, - } - - if u.AccessToken == "" { - return u, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", profileEndpoint, nil) - if err != nil { - return u, err - } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", u.AccessToken)) - - resp, err := p.Client().Do(req) - if err != nil { - return u, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - defer resp.Body.Close() - - buf, err := io.ReadAll(resp.Body) - if err != nil { - return u, err - } - - if err := json.NewDecoder(bytes.NewReader(buf)).Decode(&u.RawData); err != nil { - return u, err - } - - return u, userFromReader(bytes.NewReader(buf), &u) -} - -// RefreshToken refresh token is not provided by bitly. -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by bitly") -} - -// RefreshTokenAvailable refresh token is not provided by bitly. -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -func (p *Provider) newConfig(scopes []string) { - conf := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authEndpoint, - TokenURL: tokenEndpoint, - }, - Scopes: make([]string, 0), - } - - conf.Scopes = append(conf.Scopes, scopes...) - - p.config = conf -} - -func userFromReader(reader io.Reader, user *goth.User) (err error) { - u := struct { - Login string `json:"login"` - Name string `json:"name"` - Emails []struct { - Email string `json:"email"` - IsPrimary bool `json:"is_primary"` - IsVerified bool `json:"is_verified"` - } `json:"emails"` - }{} - if err := json.NewDecoder(reader).Decode(&u); err != nil { - return err - } - - user.Name = u.Name - user.NickName = u.Login - user.Email, err = getEmail(u.Emails) - return err -} - -func getEmail(emails []struct { - Email string `json:"email"` - IsPrimary bool `json:"is_primary"` - IsVerified bool `json:"is_verified"` -}) (string, error) { - for _, email := range emails { - if email.IsPrimary && email.IsVerified { - return email.Email, nil - } - } - - return "", fmt.Errorf("The user does not have a verified, primary email address on Bitly") -} diff --git a/providers/bitly/bitly_test.go b/providers/bitly/bitly_test.go deleted file mode 100644 index d48078a80..000000000 --- a/providers/bitly/bitly_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package bitly_test - -import ( - "fmt" - "net/url" - "testing" - - "github.com/markbates/goth/providers/bitly" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := bitlyProvider() - a.Equal(p.ClientKey, "bitly_client_id") - a.Equal(p.Secret, "bitly_client_secret") - a.Equal(p.CallbackURL, "/foo") -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := bitlyProvider() - s, err := p.BeginAuth("state") - s1 := s.(*bitly.Session) - - a.NoError(err) - a.Contains(s1.AuthURL, "https://bitly.com/oauth/authorize") - a.Contains(s1.AuthURL, fmt.Sprintf("client_id=%s", p.ClientKey)) - a.Contains(s1.AuthURL, "state=state") - a.Contains(s1.AuthURL, fmt.Sprintf("redirect_uri=%s", url.QueryEscape(p.CallbackURL))) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := bitlyProvider() - s, err := p.UnmarshalSession(`{"AuthURL":"https://bitly.com/oauth/authorize","AccessToken":"access_token"}`) - s1 := s.(*bitly.Session) - - a.NoError(err) - a.Equal(s1.AuthURL, "https://bitly.com/oauth/authorize") - a.Equal(s1.AccessToken, "access_token") -} - -func bitlyProvider() *bitly.Provider { - return bitly.New("bitly_client_id", "bitly_client_secret", "/foo") -} diff --git a/providers/bitly/session.go b/providers/bitly/session.go deleted file mode 100644 index dbe876af7..000000000 --- a/providers/bitly/session.go +++ /dev/null @@ -1,59 +0,0 @@ -package bitly - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Bitly. -type Session struct { - AuthURL string - AccessToken string -} - -// Ensure `bitly.Session` implements `goth.Session`. -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Bitly provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Bitly and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - return token.AccessToken, err -} - -// Marshal the session into a string. -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/bitly/session_test.go b/providers/bitly/session_test.go deleted file mode 100644 index c734f0ccf..000000000 --- a/providers/bitly/session_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package bitly_test - -import ( - "testing" - - "github.com/markbates/goth/providers/bitly" - "github.com/stretchr/testify/assert" -) - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - - s := &bitly.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/bar" - url, _ := s.GetAuthURL() - a.Equal(url, "/bar") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - s := &bitly.Session{ - AuthURL: "https://bitly.com/oauth/authorize", - AccessToken: "access_token", - } - a.Equal(s.Marshal(), `{"AuthURL":"https://bitly.com/oauth/authorize","AccessToken":"access_token"}`) -} diff --git a/providers/box/box.go b/providers/box/box.go deleted file mode 100644 index 92b8b730b..000000000 --- a/providers/box/box.go +++ /dev/null @@ -1,158 +0,0 @@ -// Package box implements the OAuth2 protocol for authenticating users through box. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package box - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://app.box.com/api/oauth2/authorize" - tokenURL string = "https://app.box.com/api/oauth2/token" - endpointProfile string = "https://api.box.com/2.0/users/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Box. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - config *oauth2.Config - HTTPClient *http.Client - providerName string -} - -// New creates a new Box provider and sets up important connection details. -// You should always call `box.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "box", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the box package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Box for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Box and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Location string `json:"address"` - Email string `json:"login"` - AvatarURL string `json:"avatar_url"` - ID string `json:"id"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.NickName = u.Name - user.UserID = u.ID - user.Location = u.Location - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/box/box_test.go b/providers/box/box_test.go deleted file mode 100644 index 19bc14ca4..000000000 --- a/providers/box/box_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package box_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/box" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("BOX_KEY")) - a.Equal(p.Secret, os.Getenv("BOX_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*box.Session) - a.NoError(err) - a.Contains(s.AuthURL, "app.box.com/api/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://app.box.com/api/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*box.Session) - a.Equal(s.AuthURL, "https://app.box.com/api/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *box.Provider { - return box.New(os.Getenv("BOX_KEY"), os.Getenv("BOX_SECRET"), "/foo") -} diff --git a/providers/box/session.go b/providers/box/session.go deleted file mode 100644 index 69925ea5c..000000000 --- a/providers/box/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package box - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Box. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Box provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Box and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/box/session_test.go b/providers/box/session_test.go deleted file mode 100644 index 0f681d5c9..000000000 --- a/providers/box/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package box_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/box" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &box.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &box.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &box.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &box.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/classlink/provider.go b/providers/classlink/provider.go deleted file mode 100644 index 1dc683689..000000000 --- a/providers/classlink/provider.go +++ /dev/null @@ -1,156 +0,0 @@ -package classlink - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const infoURL = "https://nodeapi.classlink.com/v2/my/info" - -// Provider is an implementation of -type Provider struct { - ClientKey string - ClientSecret string - CallbackURL string - HTTPClient *http.Client - providerName string - config *oauth2.Config -} - -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - prov := &Provider{ - ClientKey: clientKey, - ClientSecret: secret, - CallbackURL: callbackURL, - providerName: "classlink", - } - prov.config = newConfig(prov, scopes) - return prov -} - -func (p Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -func (p Provider) Name() string { - return p.providerName -} - -func (p Provider) SetName(name string) { - p.providerName = name -} - -func (p Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - return &Session{ - AuthURL: url, - }, nil -} - -func (p Provider) UnmarshalSession(s string) (goth.Session, error) { - var sess Session - err := json.Unmarshal([]byte(s), &sess) - - if err != nil { - return nil, err - } - - return &sess, nil -} - -// classLinkUser contains all relevant fields from the ClassLink response -// to -type classLinkUser struct { - UserID int `json:"UserId"` - Email string `json:"Email"` - DisplayName string `json:"DisplayName"` - FirstName string `json:"FirstName"` - LastName string `json:"LastName"` -} - -func (p Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // Data is not yet retrieved, since accessToken is still empty. - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", infoURL, nil) - if err != nil { - return user, err - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sess.AccessToken)) - - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - - defer resp.Body.Close() - - bytes, err := io.ReadAll(resp.Body) - if err != nil { - return user, err - } - - var u classLinkUser - if err := json.Unmarshal(bytes, &user.RawData); err != nil { - return user, err - } - - if err := json.Unmarshal(bytes, &u); err != nil { - return user, err - } - - user.UserID = fmt.Sprintf("%d", u.UserID) - user.FirstName = u.FirstName - user.LastName = u.LastName - user.Email = u.Email - user.Name = u.DisplayName - return user, nil -} - -func (p Provider) Debug(b bool) {} - -func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("refresh token is not provided by ClassLink") -} - -func (p Provider) RefreshTokenAvailable() bool { - return false -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.ClientSecret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://launchpad.classlink.com/oauth2/v2/auth", - TokenURL: "https://launchpad.classlink.com/oauth2/v2/token", - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = append(c.Scopes, scopes...) - } else { - c.Scopes = append(c.Scopes, "profile") - } - - return c -} diff --git a/providers/classlink/provider_test.go b/providers/classlink/provider_test.go deleted file mode 100644 index d40ea3c43..000000000 --- a/providers/classlink/provider_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package classlink_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/classlink" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := classLinkProvider() - a.Equal(provider.ClientKey, os.Getenv("CLASSLINK_KEY")) - a.Equal(provider.ClientSecret, os.Getenv("CLASSLINK_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := classLinkProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*classlink.Session) - a.NoError(err) - a.Contains(s.AuthURL, "launchpad.classlink.com/oauth2/v2/") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=profile") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), classLinkProvider()) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := classLinkProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://launchpad.classlink.com/oauth2/v2/","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*classlink.Session) - a.Equal(session.AuthURL, "https://launchpad.classlink.com/oauth2/v2/") - a.Equal(session.AccessToken, "1234567890") -} - -func classLinkProvider() *classlink.Provider { - return classlink.New(os.Getenv("CLASSLINK_KEY"), os.Getenv("CLASSLINK_SECRET"), "/foo") -} diff --git a/providers/classlink/session.go b/providers/classlink/session.go deleted file mode 100644 index bbb2d5d05..000000000 --- a/providers/classlink/session.go +++ /dev/null @@ -1,49 +0,0 @@ -package classlink - -import ( - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" -) - -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -func (s *Session) Marshal() string { - bytes, _ := json.Marshal(s) - return string(bytes) -} - -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -func (s *Session) String() string { - return s.Marshal() -} diff --git a/providers/classlink/session_test.go b/providers/classlink/session_test.go deleted file mode 100644 index 6112d599b..000000000 --- a/providers/classlink/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package classlink_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/classlink" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &classlink.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &classlink.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &classlink.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &classlink.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/cloudfoundry/cf.go b/providers/cloudfoundry/cf.go deleted file mode 100644 index 5c06763c8..000000000 --- a/providers/cloudfoundry/cf.go +++ /dev/null @@ -1,176 +0,0 @@ -// Package cloudfoundry implements the OAuth2 protocol for authenticating users through Cloud Foundry. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package cloudfoundry - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Provider is the implementation of `goth.Provider` for accessing Cloud Foundry. -type Provider struct { - AuthURL string - TokenURL string - UserInfoURL string - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Cloud Foundry provider and sets up important connection details. -// You should always call `cloudfoundry.New` to get a new provider. Never try to -// create one manually. -func New(uaaURL, clientKey, secret, callbackURL string, scopes ...string) *Provider { - uaaURL = strings.TrimSuffix(uaaURL, "/") - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - AuthURL: uaaURL + "/oauth/authorize", - TokenURL: uaaURL + "/oauth/token", - UserInfoURL: uaaURL + "/userinfo", - providerName: "cloudfoundry", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the cloudfoundry package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Cloud Foundry for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Cloud Foundry and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.UserInfoURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - bits, err := io.ReadAll(resp.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: provider.AuthURL, - TokenURL: provider.TokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - FirstName string `json:"given_name"` - LastName string `json:"family_name"` - Email string `json:"email"` - ID string `json:"user_id"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.Name - user.UserID = u.ID - user.Email = u.Email - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ctx := context.WithValue(goth.ContextForClient(p.Client()), oauth2.HTTPClient, goth.HTTPClientWithFallBack(p.Client())) - ts := p.config.TokenSource(ctx, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/cloudfoundry/cf_test.go b/providers/cloudfoundry/cf_test.go deleted file mode 100644 index b20384842..000000000 --- a/providers/cloudfoundry/cf_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package cloudfoundry_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/cloudfoundry" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("UAA_CLIENT_ID")) - a.Equal(p.Secret, os.Getenv("UAA_CLIENT_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*cloudfoundry.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://cf.example.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://cf.example.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*cloudfoundry.Session) - a.Equal(s.AuthURL, "https://cf.example.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *cloudfoundry.Provider { - return cloudfoundry.New("https://cf.example.com/", os.Getenv("UAA_CLIENT_ID"), os.Getenv("UAA_CLIENT_SECRET"), "/foo") -} diff --git a/providers/cloudfoundry/session.go b/providers/cloudfoundry/session.go deleted file mode 100644 index 896d4631e..000000000 --- a/providers/cloudfoundry/session.go +++ /dev/null @@ -1,66 +0,0 @@ -package cloudfoundry - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Cloud Foundry. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Cloud Foundry provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New("an AuthURL has not be set") - } - return s.AuthURL, nil -} - -// Authorize the session with Cloud Foundry and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - ctx := context.WithValue(goth.ContextForClient(p.Client()), oauth2.HTTPClient, p.Client()) - token, err := p.config.Exchange(ctx, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/cloudfoundry/session_test.go b/providers/cloudfoundry/session_test.go deleted file mode 100644 index 19b5c1381..000000000 --- a/providers/cloudfoundry/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package cloudfoundry_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/cloudfoundry" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &cloudfoundry.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &cloudfoundry.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &cloudfoundry.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &cloudfoundry.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/cognito/cognito.go b/providers/cognito/cognito.go deleted file mode 100644 index ea4b23509..000000000 --- a/providers/cognito/cognito.go +++ /dev/null @@ -1,238 +0,0 @@ -package cognito - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Provider is the implementation of `goth.Provider` for accessing AWS Cognito. -// New takes 3 parameters all from the Cognito console: -// - The client ID -// - The client secret -// - The base URL for your service, either a custom domain or cognito pool based URL -// You need to ensure that the source login URL is whitelisted as a login page in the client configuration in the cognito console. -// GOTH does not provide a full token logout, to do that you need to do it in your code. -// If you do not perform a full logout their existing token will be used on a login and the user won't be prompted to login until after expiry. -// To perform a logout -// - Destroy your session (or however else you handle the logout internally) -// - redirect to https://CUSTOM_DOMAIN.auth.us-east-1.amazoncognito.com/logout?client_id=clinet_id&logout_uri=http://localhost:8080/ -// (or whatever your login/start page is). -// - Note that this page needs to be white-labeled as a logout page in the cognito console as well. - -// This is based upon the implementation for okta - -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - issuerURL string - profileURL string -} - -// New creates a new AWS Cognito provider and sets up important connection details. -// You should always call `cognito.New` to get a new provider. Never try to -// create one manually. -func New(clientID, secret, baseUrl, callbackURL string, scopes ...string) *Provider { - issuerURL := baseUrl + "/oauth2/default" - authURL := baseUrl + "/oauth2/authorize" - tokenURL := baseUrl + "/oauth2/token" - profileURL := baseUrl + "/oauth2/userInfo" - return NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientID, - Secret: secret, - CallbackURL: callbackURL, - providerName: "cognito", - issuerURL: issuerURL, - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the aws package. -func (p *Provider) Debug(debug bool) { - if debug { - fmt.Println("WARNING: Debug request for goth/providers/cognito but no debug is available") - } -} - -// BeginAuth asks AWS for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to aws and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - UserID: sess.UserID, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - if response != nil { - _ = response.Body.Close() - } - return user, err - } - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(response.Body) - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -// userFromReader -// These are the standard cognito attributes -// from: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html -// all attributes are optional -// it is possible for there to be custom attributes in cognito, but they don't seem to be passed as in the claims -// all the standard claims are mapped into the raw data -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - ID string `json:"sub"` - Address string `json:"address"` - Birthdate string `json:"birthdate"` - Email string `json:"email"` - EmailVerified string `json:"email_verified"` - FirstName string `json:"given_name"` - LastName string `json:"family_name"` - MiddleName string `json:"middle_name"` - Name string `json:"name"` - NickName string `json:"nickname"` - Locale string `json:"locale"` - PhoneNumber string `json:"phone_number"` - PictureURL string `json:"picture"` - ProfileURL string `json:"profile"` - Username string `json:"preferred_username"` - UpdatedAt string `json:"updated_at"` - WebSite string `json:"website"` - Zoneinfo string `json:"zoneinfo"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - // Ensure all standard claims are in the raw data - rd := make(map[string]interface{}) - rd["Address"] = u.Address - rd["Birthdate"] = u.Birthdate - rd["Locale"] = u.Locale - rd["MiddleName"] = u.MiddleName - rd["PhoneNumber"] = u.PhoneNumber - rd["PictureURL"] = u.PictureURL - rd["ProfileURL"] = u.ProfileURL - rd["UpdatedAt"] = u.UpdatedAt - rd["Username"] = u.Username - rd["WebSite"] = u.WebSite - rd["EmailVerified"] = u.EmailVerified - - user.UserID = u.ID - user.Email = u.Email - user.Name = u.Name - user.NickName = u.NickName - user.FirstName = u.FirstName - user.LastName = u.LastName - user.AvatarURL = u.PictureURL - user.RawData = rd - - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/cognito/cognito_test.go b/providers/cognito/cognito_test.go deleted file mode 100644 index 732c989dc..000000000 --- a/providers/cognito/cognito_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package cognito - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/okta" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("COGNITO_ID")) - a.Equal(p.Secret, os.Getenv("COGNITO_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*okta.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*okta.Session) - a.NoError(err) - a.Contains(s.AuthURL, os.Getenv("COGNITO_ISSUER_URL")) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"` + os.Getenv("COGNITO_ISSUER_URL") + `/oauth2/authorize", "AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*okta.Session) - a.Equal(s.AuthURL, os.Getenv("COGNITO_ISSUER_URL")+"/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *okta.Provider { - return okta.New(os.Getenv("COGNITO_ID"), os.Getenv("COGNITO_SECRET"), os.Getenv("COGNITO_ISSUER_URL"), "/foo") -} - -func urlCustomisedURLProvider() *okta.Provider { - return okta.NewCustomisedURL(os.Getenv("CLIENT_ID"), os.Getenv("CLIENT_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://issuerURL", "http://profileURL") -} diff --git a/providers/cognito/session.go b/providers/cognito/session.go deleted file mode 100644 index 8aebda8bb..000000000 --- a/providers/cognito/session.go +++ /dev/null @@ -1,64 +0,0 @@ -package cognito - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with AWS Cognito. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - UserID string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the AWS Cognito provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with cognito and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/cognito/session_test.go b/providers/cognito/session_test.go deleted file mode 100644 index e1a8179c3..000000000 --- a/providers/cognito/session_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package cognito - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/dailymotion/dailymotion.go b/providers/dailymotion/dailymotion.go deleted file mode 100644 index a8657a596..000000000 --- a/providers/dailymotion/dailymotion.go +++ /dev/null @@ -1,188 +0,0 @@ -// Package dailymotion implements the OAuth2 protocol for authenticating users through Dailymotion. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package dailymotion - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.dailymotion.com/oauth/authorize" - tokenURL string = "https://www.dailymotion.com/oauth/token" - endpointProfile string = "https://api.dailymotion.com/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Dailymotion. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Dailymotion provider and sets up important connection details. -// You should always call `dailymotion.New` to get a new provider. Never try to -// create one manually. -func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "dailymotion", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the dailymotion package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Dailymotion for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser goes to Dailymotion to access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -// [Private] userFromReader will decode the json user and set the -// *goth.User attributes -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"fullname"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - NickName string `json:"username"` - Description string `json:"description"` - AvatarURL string `json:"avatar_720_url"` - Location string `json:"city"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.UserID = u.ID - user.Email = u.Email - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.NickName - user.Description = u.Description - user.AvatarURL = u.AvatarURL - user.Location = u.Location - - return nil -} - -// [Private] newConfig creates a new OAuth2 config -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ - "email", - }, - } - - defaultScopes := map[string]struct{}{ - "email": {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(oauth2.NoContext, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/dailymotion/dailymotion_test.go b/providers/dailymotion/dailymotion_test.go deleted file mode 100644 index 9b239f6b5..000000000 --- a/providers/dailymotion/dailymotion_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package dailymotion_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/dailymotion" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := dailymotionProvider() - a.Equal(provider.ClientKey, os.Getenv("DAILYMOTION_KEY")) - a.Equal(provider.Secret, os.Getenv("DAILYMOTION_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), dailymotionProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := dailymotionProvider() - session, err := p.BeginAuth("test_state") - s := session.(*dailymotion.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://www.dailymotion.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := dailymotionProvider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.dailymotion.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*dailymotion.Session) - a.Equal(s.AuthURL, "https://www.dailymotion.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func dailymotionProvider() *dailymotion.Provider { - return dailymotion.New(os.Getenv("DAILYMOTION_KEY"), os.Getenv("DAILYMOTION_SECRET"), "/foo", "email") -} diff --git a/providers/dailymotion/session.go b/providers/dailymotion/session.go deleted file mode 100644 index 5bf27e821..000000000 --- a/providers/dailymotion/session.go +++ /dev/null @@ -1,62 +0,0 @@ -package dailymotion - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Dailymotion. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Dailymotion provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New("an AuthURL has not be set") - } - return s.AuthURL, nil -} - -// Authorize the session with Dailymotion and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/dailymotion/session_test.go b/providers/dailymotion/session_test.go deleted file mode 100644 index ad6c35035..000000000 --- a/providers/dailymotion/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package dailymotion_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/dailymotion" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dailymotion.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dailymotion.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dailymotion.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dailymotion.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/deezer/deezer.go b/providers/deezer/deezer.go deleted file mode 100644 index 7f5ed1f6d..000000000 --- a/providers/deezer/deezer.go +++ /dev/null @@ -1,179 +0,0 @@ -// Package deezer implements the OAuth2 protocol for authenticating users through Deezer. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package deezer - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://connect.deezer.com/oauth/auth.php" - tokenURL string = "https://connect.deezer.com/oauth/access_token.php?output=json" - endpointProfile string = "https://api.deezer.com/user/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Deezer. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Deezer provider and sets up important connection details. -// You should always call `deezer.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "deezer", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the deezer package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Deezer for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser goes to Deezer to access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -// [Private] userFromReader will decode the json user and set the -// *goth.User attributes -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID int `json:"id"` - Email string `json:"email"` - FirstName string `json:"firstname"` - LastName string `json:"lastname"` - NickName string `json:"name"` - AvatarURL string `json:"picture"` - Location string `json:"city"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.UserID = strconv.Itoa(u.ID) - user.Email = u.Email - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.NickName - user.AvatarURL = u.AvatarURL - user.Location = u.Location - - return nil -} - -// [Private] newConfig creates a new OAuth2 config -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ - "email", - }, - } - - defaultScopes := map[string]struct{}{ - "email": {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshTokenAvailable refresh token is not provided by deezer -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken refresh token is not provided by deezer -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by deezer") -} diff --git a/providers/deezer/deezer_test.go b/providers/deezer/deezer_test.go deleted file mode 100644 index e471620e5..000000000 --- a/providers/deezer/deezer_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package deezer_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/deezer" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := deezerProvider() - a.Equal(provider.ClientKey, os.Getenv("DEEZER_KEY")) - a.Equal(provider.Secret, os.Getenv("DEEZER_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), deezerProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := deezerProvider() - session, err := p.BeginAuth("test_state") - s := session.(*deezer.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://connect.deezer.com/oauth/auth.php") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := deezerProvider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://connect.deezer.com/oauth/auth.php","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*deezer.Session) - a.Equal(s.AuthURL, "https://connect.deezer.com/oauth/auth.php") - a.Equal(s.AccessToken, "1234567890") -} - -func deezerProvider() *deezer.Provider { - return deezer.New(os.Getenv("DEEZER_KEY"), os.Getenv("DEEZER_SECRET"), "/foo", "email") -} diff --git a/providers/deezer/session.go b/providers/deezer/session.go deleted file mode 100644 index 83f8c8074..000000000 --- a/providers/deezer/session.go +++ /dev/null @@ -1,66 +0,0 @@ -package deezer - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Deezer. -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Deezer provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Deezer and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - - ctx := goth.ContextForClient(p.Client()) - token, err := p.config.Exchange(ctx, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - expires, ok := token.Extra("expires").(float64) - if ok != true { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.ExpiresAt = time.Now().Add(time.Second * time.Duration(expires)) - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/deezer/session_test.go b/providers/deezer/session_test.go deleted file mode 100644 index 0ea219877..000000000 --- a/providers/deezer/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package deezer_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/deezer" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &deezer.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &deezer.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &deezer.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &deezer.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/digitalocean/digitalocean.go b/providers/digitalocean/digitalocean.go deleted file mode 100644 index 560952f7f..000000000 --- a/providers/digitalocean/digitalocean.go +++ /dev/null @@ -1,177 +0,0 @@ -// Package digitalocean implements the OAuth2 protocol for authenticating users through Digital Ocean. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package digitalocean - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://cloud.digitalocean.com/v1/oauth/authorize" - tokenURL string = "https://cloud.digitalocean.com/v1/oauth/token" - endpointProfile string = "https://api.digitalocean.com/v2/account" -) - -// New creates a new DigitalOcean provider, and sets up important connection details. -// You should always call `digitalocean.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "digitalocean", - } - - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing DigitalOcean. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -var _ goth.Provider = &Provider{} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the digitalocean package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks DigitalOcean for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to DigitalOcean and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - - req.Header.Set("Authorization", "Bearer "+sess.AccessToken) - - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - bits, err := io.ReadAll(resp.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - Account struct { - DropletLimit int `json:"droplet_limit"` - Email string `json:"email"` - UUID string `json:"uuid"` - EmailVerified bool `json:"email_verified"` - Status string `json:"status"` - StatusMessage string `json:"status_message"` - } `json:"account"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Email = u.Account.Email - user.UserID = u.Account.UUID - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/digitalocean/digitalocean_test.go b/providers/digitalocean/digitalocean_test.go deleted file mode 100644 index ff2c0daec..000000000 --- a/providers/digitalocean/digitalocean_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package digitalocean_test - -import ( - "fmt" - "testing" - - "github.com/markbates/goth/providers/digitalocean" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := digitaloceanProvider() - a.Equal(provider.ClientKey, "digitalocean_key") - a.Equal(provider.Secret, "digitalocean_secret") - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := digitaloceanProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*digitalocean.Session) - - a.NoError(err) - a.Contains(s.AuthURL, "cloud.digitalocean.com/v1/oauth/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", "digitalocean_key")) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=read") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := digitaloceanProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://github.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*digitalocean.Session) - a.Equal(session.AuthURL, "http://github.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func digitaloceanProvider() *digitalocean.Provider { - return digitalocean.New("digitalocean_key", "digitalocean_secret", "/foo", "read") -} diff --git a/providers/digitalocean/session.go b/providers/digitalocean/session.go deleted file mode 100644 index 5d0045a3b..000000000 --- a/providers/digitalocean/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package digitalocean - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with DigitalOcean. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the DigitalOcean provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with DigitalOcean and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/digitalocean/session_test.go b/providers/digitalocean/session_test.go deleted file mode 100644 index 7970440df..000000000 --- a/providers/digitalocean/session_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package digitalocean_test - -import ( - "testing" - - "github.com/markbates/goth/providers/digitalocean" - "github.com/stretchr/testify/assert" -) - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &digitalocean.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &digitalocean.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &digitalocean.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/dingtalk/dingtalk.go b/providers/dingtalk/dingtalk.go deleted file mode 100644 index fafb0ca1d..000000000 --- a/providers/dingtalk/dingtalk.go +++ /dev/null @@ -1,400 +0,0 @@ -// Package dingtalk implements the OAuth2 protocol for authenticating users through DingTalk. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -// -// # Configuration -// -// To use the DingTalk provider, you need to create an application in the DingTalk Open Platform (https://open.dingtalk.com/): -// 1. Register a corporate/organization application to get AppKey and AppSecret -// 2. Set callback URL: http://your-domain/auth/dingtalk/callback -// 3. Request these necessary API permissions: -// - Contact.User.Read (必须/Required) -// - Contact.Member.Read (必须/Required) -// -// # Example -// -// // Basic use: -// dingTalkProvider := dingtalk.New( -// os.Getenv("DINGTALK_KEY"), -// os.Getenv("DINGTALK_SECRET"), -// "http://localhost:3000/auth/dingtalk/callback", -// "", // empty string if you don't need corporate ID verification -// "openid" // minimum scope -// ) -// -// // With corporate verification (limit to specific company): -// dingTalkProvider := dingtalk.New( -// os.Getenv("DINGTALK_KEY"), -// os.Getenv("DINGTALK_SECRET"), -// "http://localhost:3000/auth/dingtalk/callback", -// os.Getenv("DINGTALK_CORP_ID"), // corporate ID for verification -// "openid", -// "corpid" // needed for corporate verification -// ) -// -// // Enable debug mode for detailed logging -// dingTalkProvider.Debug(true) -// -// goth.UseProviders(dingTalkProvider) -// -// # Environment Variables -// -// DINGTALK_KEY: Your DingTalk application's client key/app key -// DINGTALK_SECRET: Your DingTalk application's client secret/app secret -// DINGTALK_CORP_ID: (Optional) For corporate ID verification, to limit authentication to a specific company -// -// See the examples/main.go file for a working example of this provider. -package dingtalk - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "os" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the Authentication, Token, and API URLS for DingTalk. -// See: https://open.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information -var ( - AuthURL = "https://login.dingtalk.com/oauth2/auth" - TokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" - ProfileURL = "https://api.dingtalk.com/v1.0/contact/users/me" -) - -// Logger for Debug output -var logger = log.New(os.Stdout, "[DingTalk Debug] ", log.LstdFlags|log.Lshortfile) - -// New creates a new DingTalk provider, and sets up important connection details. -// You should always call `dingtalk.New` to get a new Provider. Never try to create -// one manually. -// -// When using with "corpid" scope, include "openid" and "corpid" in scopes parameter. -func New(clientKey, secret, callbackURL string, expectedCorpID string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, expectedCorpID, scopes...) -} - -// NewWithCorpID creates a new DingTalk provider with company ID verification. -// If expectedCorpID is non-empty, the provider will verify that authenticated users -// belong to the specified company. Authentication will fail if the user's corpID doesn't match. -// -// Use this constructor when you need to restrict access to users from a specific company. -// Be sure to include "openid" and "corpid" in the scopes parameter. -func NewWithCorpID(clientKey, secret, callbackURL, expectedCorpID string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, expectedCorpID, "openid", "corpid") -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -// If expectedCorpID is non-empty, the provider will verify that authenticated users -// belong to the specified company. -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL, expectedCorpID string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "dingtalk", - profileURL: profileURL, - debug: false, - expectedCorpID: expectedCorpID, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing DingTalk. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - profileURL string - debug bool - expectedCorpID string // Corporate ID to validate against for company-specific authentication -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug sets the debug mode -func (p *Provider) Debug(debug bool) { - p.debug = debug -} - -// logDebug prints debug information -func (p *Provider) logDebug(format string, v ...interface{}) { - if p.debug { - logger.Printf(format, v...) - } -} - -// BeginAuth asks DingTalk for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - - // Add prompt=consent parameter to force showing the consent screen every time - if !strings.Contains(url, "prompt=") { - if strings.Contains(url, "?") { - url += "&prompt=consent" - } else { - url += "?prompt=consent" - } - } - - p.logDebug("Authorization URL with consent prompt: %s", url) - session := &Session{ - AuthURL: url, - ExpectedCorpID: p.expectedCorpID, - } - return session, nil -} - -// FetchUser will go to DingTalk and access basic information about the user. -// If expectedCorpID is set and the user's corpID doesn't match, an error will be returned. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - p.logDebug("Starting to fetch user info, AccessToken: %s", user.AccessToken[:10]+"...") - - // Get user information - reqProfile, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - p.logDebug("Failed to create request: %v", err) - return user, err - } - - reqProfile.Header.Add("x-acs-dingtalk-access-token", sess.AccessToken) - reqProfile.Header.Add("Content-Type", "application/json") - - p.logDebug("Sending request for user info: %s", p.profileURL) - p.logDebug("Request headers: %v", reqProfile.Header) - - response, err := p.Client().Do(reqProfile) - if err != nil { - p.logDebug("Failed to send request: %v", err) - return user, err - } - defer response.Body.Close() - - p.logDebug("Received response status code: %d", response.StatusCode) - - if response.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(response.Body) - p.logDebug("API error response: %s", string(respBody)) - return user, fmt.Errorf("DingTalk API responded with a %d trying to fetch user information: %s", - response.StatusCode, string(respBody)) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - p.logDebug("Failed to read response body: %v", err) - return user, err - } - - p.logDebug("Response content: %s", string(bits)) - - // Parse user information directly from the profile response - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - p.logDebug("Failed to parse JSON response: %v", err) - return user, err - } - - // Extract user fields directly - userInfo := struct { - UnionID string `json:"unionId"` - Email string `json:"email"` - Mobile string `json:"mobile"` - AvatarURL string `json:"avatarUrl"` - Nick string `json:"nick"` - OpenID string `json:"openId"` - }{} - - if err := json.NewDecoder(bytes.NewReader(bits)).Decode(&userInfo); err != nil { - p.logDebug("Failed to extract user fields: %v", err) - return user, err - } - - // Populate user struct - user.Name = userInfo.Nick - user.NickName = userInfo.Nick - user.Email = userInfo.Email - user.UserID = userInfo.UnionID - user.AvatarURL = userInfo.AvatarURL - - // Add corpID from session to user data - if sess.CorpID != "" && user.RawData != nil { - user.RawData["corpId"] = sess.CorpID - } - - p.logDebug("Successfully retrieved user info: Name=%s, Email=%s", user.Name, user.Email) - return user, nil -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = append(c.Scopes, scopes...) - } else { - // If no scope is provided, add the default "openid" - c.Scopes = []string{"openid"} - } - - return c -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - if refreshToken == "" { - return nil, errors.New("refresh token is required") - } - - p.logDebug("Attempting to refresh token with refreshToken: %s...", refreshToken[:10]) - - data := struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - RefreshToken string `json:"refreshToken"` - GrantType string `json:"grantType"` - }{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RefreshToken: refreshToken, - GrantType: "refresh_token", - } - - payload, err := json.Marshal(data) - if err != nil { - p.logDebug("Failed to marshal refresh token request: %v", err) - return nil, err - } - - req, err := http.NewRequest("POST", TokenURL, bytes.NewBuffer(payload)) - if err != nil { - p.logDebug("Failed to create refresh token request: %v", err) - return nil, err - } - - req.Header.Add("Content-Type", "application/json") - - p.logDebug("Sending refresh token request") - p.logDebug("Request body: %s", string(payload)) - - resp, err := p.Client().Do(req) - if err != nil { - p.logDebug("Failed to send refresh token request: %v", err) - return nil, err - } - defer resp.Body.Close() - - p.logDebug("Refresh token response status code: %d", resp.StatusCode) - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - p.logDebug("Refresh token error response: %s", string(respBody)) - return nil, fmt.Errorf("DingTalk API responded with a %d trying to refresh token: %s", - resp.StatusCode, string(respBody)) - } - - var tokenResponse struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresIn int `json:"expireIn"` - CorpID string `json:"corpId"` // Corporate ID from token response - } - - respBody, _ := io.ReadAll(resp.Body) - p.logDebug("Refresh token response: %s", string(respBody)) - - err = json.NewDecoder(bytes.NewReader(respBody)).Decode(&tokenResponse) - if err != nil { - p.logDebug("Failed to parse refresh token response: %v", err) - return nil, err - } - - // Verify corporate ID if expected is set - if p.expectedCorpID != "" && tokenResponse.CorpID != "" { - if tokenResponse.CorpID != p.expectedCorpID { - p.logDebug("Corporate ID verification failed during token refresh. Expected: %s, Got: %s", - p.expectedCorpID, tokenResponse.CorpID) - return nil, fmt.Errorf("user does not belong to the expected company (corpid mismatch)") - } - p.logDebug("Corporate ID verification succeeded during token refresh") - } - - p.logDebug("Successfully refreshed token. New token: %s...", tokenResponse.AccessToken[:10]) - - token := &oauth2.Token{ - AccessToken: tokenResponse.AccessToken, - RefreshToken: tokenResponse.RefreshToken, - TokenType: "Bearer", - } - - // Add corpID as extra data - if tokenResponse.CorpID != "" { - extraData := map[string]interface{}{ - "corpId": tokenResponse.CorpID, - } - token = token.WithExtra(extraData) - } - - return token, nil -} - -// RefreshTokenAvailable refresh token is provided by DingTalk -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// GetCorpID retrieves the company ID from user data -// Returns the corpID and whether it was found -func GetCorpID(user goth.User) (string, bool) { - if user.RawData == nil { - return "", false - } - - if corpID, ok := user.RawData["corpId"].(string); ok { - return corpID, true - } - - return "", false -} diff --git a/providers/dingtalk/dingtalk_test.go b/providers/dingtalk/dingtalk_test.go deleted file mode 100644 index f27229978..000000000 --- a/providers/dingtalk/dingtalk_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package dingtalk_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/dingtalk" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("DINGTALK_KEY")) - a.Equal(p.Secret, os.Getenv("DINGTALK_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*dingtalk.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.dingtalk.com/oauth2/auth") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://login.dingtalk.com/oauth2/auth","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*dingtalk.Session) - a.Equal(s.AuthURL, "https://login.dingtalk.com/oauth2/auth") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *dingtalk.Provider { - return dingtalk.New(os.Getenv("DINGTALK_KEY"), os.Getenv("DINGTALK_SECRET"), "/foo", os.Getenv("DINGTALK_CORP_ID")) -} diff --git a/providers/dingtalk/session.go b/providers/dingtalk/session.go deleted file mode 100644 index bb6a55083..000000000 --- a/providers/dingtalk/session.go +++ /dev/null @@ -1,139 +0,0 @@ -package dingtalk - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with DingTalk. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - CorpID string // Corporate ID of the authenticated user - ExpectedCorpID string // Expected Corporate ID for validation -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the DingTalk provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with DingTalk and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - - // DingTalk uses a non-standard OAuth2 flow, using JSON request to get the token - code := params.Get("code") - if code == "" { - return "", errors.New("no code received") - } - - p.logDebug("Authorizing with code: %s", code) - - data := struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - Code string `json:"code"` - GrantType string `json:"grantType"` - }{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - Code: code, - GrantType: "authorization_code", - } - - payload, err := json.Marshal(data) - if err != nil { - p.logDebug("Failed to marshal authorization data: %v", err) - return "", err - } - - client := p.Client() - req, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", strings.NewReader(string(payload))) - if err != nil { - p.logDebug("Failed to create authorization request: %v", err) - return "", err - } - - req.Header.Add("Content-Type", "application/json") - - p.logDebug("Sending authorization request") - p.logDebug("Request body: %s", string(payload)) - - resp, err := client.Do(req) - if err != nil { - p.logDebug("Failed to send authorization request: %v", err) - return "", err - } - defer resp.Body.Close() - - p.logDebug("Authorization response status code: %d", resp.StatusCode) - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - p.logDebug("Authorization error response: %s", string(respBody)) - return "", fmt.Errorf("DingTalk auth error (status %d): %s", resp.StatusCode, string(respBody)) - } - - respBody, _ := io.ReadAll(resp.Body) - p.logDebug("Authorization response: %s", string(respBody)) - - var tokenResponse struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresIn int `json:"expireIn"` - CorpID string `json:"corpId"` // Corporate ID field from token response - } - - if err = json.NewDecoder(strings.NewReader(string(respBody))).Decode(&tokenResponse); err != nil { - p.logDebug("Failed to parse authorization response: %v", err) - return "", err - } - - s.AccessToken = tokenResponse.AccessToken - s.RefreshToken = tokenResponse.RefreshToken - s.ExpiresAt = time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)) - s.CorpID = tokenResponse.CorpID - - p.logDebug("Successfully authorized. Access token: %s...", s.AccessToken[:10]) - - // Verify Corporate ID if expected is set - if s.ExpectedCorpID != "" && s.CorpID != "" { - if s.CorpID != s.ExpectedCorpID { - p.logDebug("Corporate ID verification failed. Expected: %s, Got: %s", s.ExpectedCorpID, s.CorpID) - return "", fmt.Errorf("user does not belong to the expected company (corpid mismatch)") - } - p.logDebug("Corporate ID verification succeeded") - } - - return s.AccessToken, nil -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/dingtalk/session_test.go b/providers/dingtalk/session_test.go deleted file mode 100644 index 38a71596a..000000000 --- a/providers/dingtalk/session_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package dingtalk_test - -import ( - "testing" - "time" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/dingtalk" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dingtalk.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dingtalk.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dingtalk.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","CorpID":"","ExpectedCorpID":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dingtalk.Session{} - - a.Equal(s.String(), s.Marshal()) -} - -func Test_GetExpiresAt(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &dingtalk.Session{} - - a.Equal(s.ExpiresAt, time.Time{}) -} diff --git a/providers/discord/discord.go b/providers/discord/discord.go deleted file mode 100644 index 118f5e476..000000000 --- a/providers/discord/discord.go +++ /dev/null @@ -1,236 +0,0 @@ -// Package discord implements the OAuth2 protocol for authenticating users through Discord. -// This package can be used as a reference implementation of an OAuth2 provider for Discord. -package discord - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://discord.com/api/oauth2/authorize" - tokenURL string = "https://discord.com/api/oauth2/token" - userEndpoint string = "https://discord.com/api/users/@me" -) - -const ( - // ScopeIdentify allows /users/@me without email - ScopeIdentify string = "identify" - // ScopeEmail enables /users/@me to return an email - ScopeEmail string = "email" - // ScopeConnections allows /users/@me/connections to return linked Twitch and YouTube accounts - ScopeConnections string = "connections" - // ScopeGuilds allows /users/@me/guilds to return basic information about all of a user's guilds - ScopeGuilds string = "guilds" - // ScopeJoinGuild allows /invites/{invite.id} to be used for joining a user's guild - ScopeJoinGuild string = "guilds.join" - // ScopeGroupDMjoin allows your app to join users to a group dm - ScopeGroupDMjoin string = "gdm.join" - // ScopeBot is for oauth2 bots, this puts the bot in the user's selected guild by default - ScopeBot string = "bot" - // ScopeWebhook generates a webhook that is returned in the oauth token response for authorization code grants - ScopeWebhook string = "webhook.incoming" - // ScopeReadGuilds allows /users/@me/guilds/{guild.id}/member to return a user's member information in a guild - ScopeReadGuilds string = "guilds.members.read" -) - -// New creates a new Discord provider, and sets up important connection details. -// You should always call `discord.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "discord", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Discord -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - permissions string -} - -// Name gets the name used to retrieve this provider. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// SetPermissions is to update the bot permissions (used for when ScopeBot is set) -func (p *Provider) SetPermissions(permissions string) { - p.permissions = permissions -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is no-op for the Discord package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Discord for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - - opts := []oauth2.AuthCodeOption{ - oauth2.AccessTypeOnline, - oauth2.SetAuthURLParam("prompt", "none"), - } - - if p.permissions != "" { - opts = append(opts, oauth2.SetAuthURLParam("permissions", p.permissions)) - } - - url := p.config.AuthCodeURL(state, opts...) - - s := &Session{ - AuthURL: url, - } - return s, nil -} - -// FetchUser will go to Discord and access basic info about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", userEndpoint, nil) - if err != nil { - return user, err - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - bits, err := io.ReadAll(resp.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - if err != nil { - return user, err - } - - return user, err -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"username"` - Email string `json:"email"` - AvatarID string `json:"avatar"` - MFAEnabled bool `json:"mfa_enabled"` - Discriminator string `json:"discriminator"` - Verified bool `json:"verified"` - ID string `json:"id"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - // If this prefix is present, the image should be available as a gif, - // See : https://discord.com/developers/docs/reference#image-formatting - // Introduced by : Yyewolf - - if u.AvatarID != "" { - avatarExtension := ".png" - prefix := "a_" - if len(u.AvatarID) >= len(prefix) && u.AvatarID[0:len(prefix)] == prefix { - avatarExtension = ".gif" - } - user.AvatarURL = "https://media.discordapp.net/avatars/" + u.ID + "/" + u.AvatarID + avatarExtension - } - - user.Name = u.Name - user.Email = u.Email - user.UserID = u.ID - - return nil -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = []string{ScopeIdentify} - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(oauth2.NoContext, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/discord/discord_test.go b/providers/discord/discord_test.go deleted file mode 100644 index 8bc077f0b..000000000 --- a/providers/discord/discord_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package discord - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func provider() *Provider { - return New(os.Getenv("DISCORD_KEY"), - os.Getenv("DISCORD_SECRET"), "/foo", "user") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("DISCORD_KEY")) - a.Equal(p.Secret, os.Getenv("DISCORD_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_ImplementsProvider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "discord.com/api/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://discord.com/api/oauth2/authorize", "AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*Session) - a.Equal(s.AuthURL, "https://discord.com/api/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} diff --git a/providers/discord/session.go b/providers/discord/session.go deleted file mode 100644 index 228237e8d..000000000 --- a/providers/discord/session.go +++ /dev/null @@ -1,66 +0,0 @@ -package discord - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Discord -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on -// the Discord provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize completes the authorization with Discord and returns the access -// token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal marshals a session into a JSON string. -func (s Session) Marshal() string { - j, _ := json.Marshal(s) - return string(j) -} - -// String is equivalent to Marshal. It returns a JSON representation of the -// session. -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/discord/session_test.go b/providers/discord/session_test.go deleted file mode 100644 index ee412e7e9..000000000 --- a/providers/discord/session_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package discord - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} diff --git a/providers/dropbox/dropbox.go b/providers/dropbox/dropbox.go deleted file mode 100644 index e02dce853..000000000 --- a/providers/dropbox/dropbox.go +++ /dev/null @@ -1,211 +0,0 @@ -// Package dropbox implements the OAuth2 protocol for authenticating users through Dropbox. -package dropbox - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL = "https://www.dropbox.com/oauth2/authorize" - tokenURL = "https://api.dropbox.com/oauth2/token" - accountURL = "https://api.dropbox.com/2/users/get_current_account" -) - -// Provider is the implementation of `goth.Provider` for accessing Dropbox. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - AccountURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Session stores data during the auth process with Dropbox. -type Session struct { - AuthURL string - Token string -} - -// New creates a new Dropbox provider and sets up important connection details. -// You should always call `dropbox.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - AccountURL: accountURL, - providerName: "dropbox", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the dropbox package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Dropbox for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Dropbox and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.Token, - Provider: p.Name(), - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("POST", p.AccountURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.Token) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - bits, err := io.ReadAll(resp.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} - -// GetAuthURL gets the URL set by calling the `BeginAuth` function on the Dropbox provider. -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New("dropbox: missing AuthURL") - } - return s.AuthURL, nil -} - -// Authorize the session with Dropbox and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.Token = token.AccessToken - return token.AccessToken, nil -} - -// Marshal the session into a string -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - AccountID string `json:"account_id"` - Name struct { - GivenName string `json:"given_name"` - Surname string `json:"surname"` - DisplayName string `json:"display_name"` - } `json:"name"` - Country string `json:"country"` - Email string `json:"email"` - ProfilePhotoURL string `json:"profile_photo_url"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.UserID = u.AccountID // The user's unique Dropbox ID. - user.FirstName = u.Name.GivenName - user.LastName = u.Name.Surname - user.Name = strings.TrimSpace(fmt.Sprintf("%s %s", u.Name.GivenName, u.Name.Surname)) - user.Description = u.Name.DisplayName // Full name plus parenthetical team name - user.Email = u.Email - user.NickName = u.Email // Email is the dropbox username - user.Location = u.Country - user.AvatarURL = u.ProfilePhotoURL // May be blank - return nil -} - -// RefreshToken refresh token is not provided by dropbox -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by dropbox") -} - -// RefreshTokenAvailable refresh token is not provided by dropbox -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/dropbox/dropbox_test.go b/providers/dropbox/dropbox_test.go deleted file mode 100644 index fe69232cb..000000000 --- a/providers/dropbox/dropbox_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package dropbox - -import ( - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func provider() *Provider { - return New(os.Getenv("DROPBOX_KEY"), os.Getenv("DROPBOX_SECRET"), "/foo", "email") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("DROPBOX_KEY")) - a.Equal(p.Secret, os.Getenv("DROPBOX_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.dropbox.com/oauth2/authorize") -} - -func Test_FetchUser(t *testing.T) { - accountPath := "/2/users/get_current_account" - - t.Parallel() - a := assert.New(t) - p := provider() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a.Equal(r.Header.Get("Authorization"), "Bearer 1234567890") - a.Equal(r.Method, "POST") - a.Equal(r.URL.Path, accountPath) - w.Write([]byte(testAccountResponse)) - })) - p.AccountURL = ts.URL + accountPath - - // AuthURL is superfluous for this test but ok - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.dropbox.com/oauth2/authorize","Token":"1234567890"}`) - a.NoError(err) - user, err := p.FetchUser(session) - a.NoError(err) - a.Equal(user.UserID, "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc") - a.Equal(user.FirstName, "Franz") - a.Equal(user.LastName, "Ferdinand") - a.Equal(user.Name, "Franz Ferdinand") - a.Equal(user.Description, "Franz Ferdinand (Personal)") - a.Equal(user.NickName, "franz@dropbox.com") - a.Equal(user.Email, "franz@dropbox.com") - a.Equal(user.Location, "US") - a.Equal(user.AccessToken, "1234567890") - a.Equal(user.AccessTokenSecret, "") - a.Equal(user.AvatarURL, "https://dl-web.dropbox.com/account_photo/get/dbid%3AAAH4f99T0taONIb-OurWxbNQ6ywGRopQngc?vers=1453416673259\u0026size=128x128") - a.Equal(user.Provider, "dropbox") - a.Len(user.RawData, 14) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.dropbox.com/oauth2/authorize","Token":"1234567890"}`) - a.NoError(err) - - s := session.(*Session) - a.Equal(s.AuthURL, "https://www.dropbox.com/oauth2/authorize") - a.Equal(s.Token, "1234567890") -} - -func Test_SessionToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","Token":""}`) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -var testAccountResponse = ` -{ - "account_id": "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc", - "name": { - "given_name": "Franz", - "surname": "Ferdinand", - "familiar_name": "Franz", - "display_name": "Franz Ferdinand (Personal)", - "abbreviated_name": "FF" - }, - "email": "franz@dropbox.com", - "email_verified": true, - "disabled": false, - "locale": "en", - "referral_link": "https://db.tt/ZITNuhtI", - "is_paired": true, - "account_type": { - ".tag": "business" - }, - "root_info": { - ".tag": "user", - "root_namespace_id": "3235641", - "home_namespace_id": "3235641" - }, - "country": "US", - "team": { - "id": "dbtid:AAFdgehTzw7WlXhZJsbGCLePe8RvQGYDr-I", - "name": "Acme, Inc.", - "sharing_policies": { - "shared_folder_member_policy": { - ".tag": "team" - }, - "shared_folder_join_policy": { - ".tag": "from_anyone" - }, - "shared_link_create_policy": { - ".tag": "team_only" - } - }, - "office_addin_policy": { - ".tag": "disabled" - } - }, - "profile_photo_url": "https://dl-web.dropbox.com/account_photo/get/dbid%3AAAH4f99T0taONIb-OurWxbNQ6ywGRopQngc?vers=1453416673259\u0026size=128x128", - "team_member_id": "dbmid:AAHhy7WsR0x-u4ZCqiDl5Fz5zvuL3kmspwU" -} -` diff --git a/providers/eveonline/eveonline.go b/providers/eveonline/eveonline.go deleted file mode 100644 index 6bf156321..000000000 --- a/providers/eveonline/eveonline.go +++ /dev/null @@ -1,162 +0,0 @@ -// Package eveonline implements the OAuth2 protocol for authenticating users through eveonline. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package eveonline - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authPath string = "https://login.eveonline.com/oauth/authorize/" - tokenPath string = "https://login.eveonline.com/oauth/token" - verifyPath string = "https://login.eveonline.com/oauth/verify" -) - -// Provider is the implementation of `goth.Provider` for accessing eveonline. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Eve Online provider and sets up important connection details. -// You should always call `eveonline.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "eveonline", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns the default http.client -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the eveonline package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Eve Online for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Eve Online and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // Get the userID, eveonline needs userID in order to get user profile info - req, err := http.NewRequest("GET", verifyPath, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+user.AccessToken) - - response, err := p.Client().Do(req) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - u := struct { - CharacterID int64 - CharacterName string - ExpiresOn string - Scopes string - TokenType string - CharacterOwnerHash string - }{} - - if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { - return user, err - } - - user.NickName = u.CharacterName - user.UserID = fmt.Sprintf("%d", u.CharacterID) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authPath, - TokenURL: tokenPath, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/eveonline/eveonline_test.go b/providers/eveonline/eveonline_test.go deleted file mode 100644 index 50181ad82..000000000 --- a/providers/eveonline/eveonline_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package eveonline_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/eveonline" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("EVEONLINE_KEY")) - a.Equal(p.Secret, os.Getenv("EVEONLINE_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*eveonline.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.eveonline.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://login.eveonline.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*eveonline.Session) - a.Equal(s.AuthURL, "https://login.eveonline.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *eveonline.Provider { - return eveonline.New(os.Getenv("EVEONLINE_KEY"), os.Getenv("EVEONLINE_SECRET"), "/foo") -} diff --git a/providers/eveonline/session.go b/providers/eveonline/session.go deleted file mode 100644 index d07d0ec47..000000000 --- a/providers/eveonline/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package eveonline - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Eve Online. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Eve Online provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Eve Online and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/eveonline/session_test.go b/providers/eveonline/session_test.go deleted file mode 100644 index 3e1f4f213..000000000 --- a/providers/eveonline/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package eveonline_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/eveonline" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &eveonline.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &eveonline.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &eveonline.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &eveonline.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/facebook/facebook.go b/providers/facebook/facebook.go deleted file mode 100644 index f740f46f5..000000000 --- a/providers/facebook/facebook.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package facebook implements the OAuth2 protocol for authenticating users through Facebook. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package facebook - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.facebook.com/dialog/oauth" - tokenURL string = "https://graph.facebook.com/oauth/access_token" - endpointProfile string = "https://graph.facebook.com/me?fields=" -) - -// New creates a new Facebook provider, and sets up important connection details. -// You should always call `facebook.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "facebook", - } - p.config = newConfig(p, scopes) - p.Fields = "email,first_name,last_name,link,about,id,name,picture,location" - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Facebook. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - Fields string - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// SetCustomFields sets the fields used to return information -// for a user. -// -// A list of available field values can be found at -// https://developers.facebook.com/docs/graph-api/reference/user -func (p *Provider) SetCustomFields(fields []string) *Provider { - p.Fields = strings.Join(fields, ",") - return p -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the facebook package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Facebook for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - authUrl := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: authUrl, - } - return session, nil -} - -// FetchUser will go to Facebook and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // always add appsecretProof to make calls more protected - // https://github.com/markbates/goth/issues/96 - // https://developers.facebook.com/docs/graph-api/securing-requests - hash := hmac.New(sha256.New, []byte(p.Secret)) - hash.Write([]byte(sess.AccessToken)) - appsecretProof := hex.EncodeToString(hash.Sum(nil)) - - reqUrl := fmt.Sprint( - endpointProfile, - p.Fields, - "&access_token=", - url.QueryEscape(sess.AccessToken), - "&appsecret_proof=", - appsecretProof, - ) - response, err := p.Client().Get(reqUrl) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID string `json:"id"` - Email string `json:"email"` - About string `json:"about"` - Name string `json:"name"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Link string `json:"link"` - Picture struct { - Data struct { - URL string `json:"url"` - } `json:"data"` - } `json:"picture"` - Location struct { - Name string `json:"name"` - } `json:"location"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.Name - user.Email = u.Email - user.Description = u.About - user.AvatarURL = u.Picture.Data.URL - user.UserID = u.ID - user.Location = u.Location.Name - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ - "email", - }, - } - - defaultScopes := map[string]struct{}{ - "email": {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshToken refresh token is not provided by facebook -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by facebook") -} - -// RefreshTokenAvailable refresh token is not provided by facebook -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/facebook/facebook_test.go b/providers/facebook/facebook_test.go deleted file mode 100644 index df9dd1cf5..000000000 --- a/providers/facebook/facebook_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package facebook_test - -import ( - "fmt" - "os" - "strings" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/facebook" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := facebookProvider() - a.Equal(provider.ClientKey, os.Getenv("FACEBOOK_KEY")) - a.Equal(provider.Secret, os.Getenv("FACEBOOK_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), facebookProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := facebookProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*facebook.Session) - a.NoError(err) - a.Contains(s.AuthURL, "facebook.com/dialog/oauth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("FACEBOOK_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=email") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := facebookProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://facebook.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*facebook.Session) - a.Equal(session.AuthURL, "http://facebook.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func Test_SetCustomFields(t *testing.T) { - t.Parallel() - defaultFields := "email,first_name,last_name,link,about,id,name,picture,location" - cf := []string{"email", "picture.type(large)"} - a := assert.New(t) - - provider := facebookProvider() - a.Equal(provider.Fields, defaultFields) - provider.SetCustomFields(cf) - a.Equal(provider.Fields, strings.Join(cf, ",")) -} - -func facebookProvider() *facebook.Provider { - return facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "/foo", "email") -} diff --git a/providers/facebook/session.go b/providers/facebook/session.go deleted file mode 100644 index 5cdcca443..000000000 --- a/providers/facebook/session.go +++ /dev/null @@ -1,59 +0,0 @@ -package facebook - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Facebook. -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Facebook and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/facebook/session_test.go b/providers/facebook/session_test.go deleted file mode 100644 index 0b709a16a..000000000 --- a/providers/facebook/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package facebook_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/facebook" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &facebook.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &facebook.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &facebook.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &facebook.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/faux/README.md b/providers/faux/README.md deleted file mode 100644 index 6654a6178..000000000 --- a/providers/faux/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# FauxProvider - -This provider is merely here to help with testing other parts of these packages. I wouldn't recommend using it. :) diff --git a/providers/faux/faux.go b/providers/faux/faux.go deleted file mode 100644 index 4b0cc5dd1..000000000 --- a/providers/faux/faux.go +++ /dev/null @@ -1,110 +0,0 @@ -// Package faux is used exclusively for testing purposes. I would strongly suggest you move along -// as there's nothing to see here. -package faux - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Provider is used only for testing. -type Provider struct { - HTTPClient *http.Client - providerName string -} - -// Session is used only for testing. -type Session struct { - ID string - Name string - Email string - AuthURL string - AccessToken string -} - -// Name is used only for testing. -func (p *Provider) Name() string { - return "faux" -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// BeginAuth is used only for testing. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - c := &oauth2.Config{ - Endpoint: oauth2.Endpoint{ - AuthURL: "http://example.com/auth", - }, - } - url := c.AuthCodeURL(state) - return &Session{ - ID: "id", - AuthURL: url, - }, nil -} - -// FetchUser is used only for testing. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - UserID: sess.ID, - Name: sess.Name, - Email: sess.Email, - Provider: p.Name(), - AccessToken: sess.AccessToken, - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - return user, nil -} - -// UnmarshalSession is used only for testing. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is used only for testing. -func (p *Provider) Debug(debug bool) {} - -// RefreshTokenAvailable is used only for testing -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken is used only for testing -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} - -// Authorize is used only for testing. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - s.AccessToken = "access" - return s.AccessToken, nil -} - -// Marshal is used only for testing. -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -// GetAuthURL is used only for testing. -func (s *Session) GetAuthURL() (string, error) { - return s.AuthURL, nil -} diff --git a/providers/fitbit/fitbit.go b/providers/fitbit/fitbit.go deleted file mode 100644 index 8f402ada7..000000000 --- a/providers/fitbit/fitbit.go +++ /dev/null @@ -1,195 +0,0 @@ -// Package fitbit implements the OAuth protocol for authenticating users through Fitbit. -// This package can be used as a reference implementation of an OAuth provider for Goth. -package fitbit - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.fitbit.com/oauth2/authorize" - tokenURL string = "https://api.fitbit.com/oauth2/token" - endpointProfile string = "https://api.fitbit.com/1/user/-/profile.json" // '-' for logged in user -) - -const ( - // ScopeActivity includes activity data and exercise log related features, such as steps, distance, calories burned, and active minutes - ScopeActivity = "activity" - // ScopeHeartRate includes the continuous heart rate data and related analysis - ScopeHeartRate = "heartrate" - // ScopeLocation includes the GPS and other location data - ScopeLocation = "location" - // ScopeNutrition includes calorie consumption and nutrition related features, such as food/water logging, goals, and plans - ScopeNutrition = "nutrition" - // ScopeProfile is the basic user information - ScopeProfile = "profile" - // ScopeSettings includes user account and device settings, such as alarms - ScopeSettings = "settings" - // ScopeSleep includes sleep logs and related sleep analysis - ScopeSleep = "sleep" - // ScopeSocial includes friend-related features, such as friend list, invitations, and leaderboard - ScopeSocial = "social" - // ScopeWeight includes weight and related information, such as body mass index, body fat percentage, and goals - ScopeWeight = "weight" -) - -// New creates a new Fitbit provider, and sets up important connection details. -// You should always call `fitbit.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "fitbit", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Fitbit. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the fitbit package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Fitbit for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Fitbit and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - UserID: s.UserID, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - // err = userFromReader(io.TeeReader(resp.Body, os.Stdout), &user) - err = userFromReader(resp.Body, &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - User struct { - Avatar string `json:"avatar"` - Country string `json:"country"` - FullName string `json:"fullName"` - DisplayName string `json:"displayName"` - } `json:"user"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Location = u.User.Country - user.Name = u.User.FullName - user.NickName = u.User.DisplayName - user.AvatarURL = u.User.Avatar - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ - ScopeProfile, - }, - } - - defaultScopes := map[string]struct{}{ - ScopeProfile: {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(oauth2.NoContext, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -// RefreshTokenAvailable refresh token is not provided by fitbit -func (p *Provider) RefreshTokenAvailable() bool { - return true -} diff --git a/providers/fitbit/fitbit_test.go b/providers/fitbit/fitbit_test.go deleted file mode 100644 index bf7f30153..000000000 --- a/providers/fitbit/fitbit_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package fitbit_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/fitbit" - "github.com/stretchr/testify/assert" -) - -func provider() *fitbit.Provider { - return fitbit.New(os.Getenv("FITBIT_KEY"), os.Getenv("FITBIT_SECRET"), "/foo", "user") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("FITBIT_KEY")) - a.Equal(p.Secret, os.Getenv("FITBIT_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_ImplementsProvider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*fitbit.Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.fitbit.com/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.fitbit.com/oauth2/authorize","AccessToken":"1234567890","UserID":"abc"}`) - a.NoError(err) - - s := session.(*fitbit.Session) - a.Equal(s.AuthURL, "https://www.fitbit.com/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") - a.Equal(s.UserID, "abc") -} diff --git a/providers/fitbit/session.go b/providers/fitbit/session.go deleted file mode 100644 index 4a499d2dd..000000000 --- a/providers/fitbit/session.go +++ /dev/null @@ -1,61 +0,0 @@ -package fitbit - -import ( - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Fitbit. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - UserID string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the -// Fitbit provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize completes the authorization with Fitbit and returns the access -// token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code"), oauth2.SetAuthURLParam("code_verifier", params.Get("code_verifier"))) - if err != nil { - return "", err - } - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - s.UserID = token.Extra("user_id").(string) - return token.AccessToken, err -} - -// Marshal marshals a session into a JSON string. -func (s Session) Marshal() string { - j, _ := json.Marshal(s) - return string(j) -} - -// String is equivalent to Marshal. It returns a JSON representation of the session. -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := Session{} - err := json.Unmarshal([]byte(data), &s) - return &s, err -} diff --git a/providers/fitbit/session_test.go b/providers/fitbit/session_test.go deleted file mode 100644 index 0c30636c6..000000000 --- a/providers/fitbit/session_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package fitbit_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/fitbit" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &fitbit.Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &fitbit.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &fitbit.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) -} diff --git a/providers/gitea/gitea.go b/providers/gitea/gitea.go deleted file mode 100644 index d04f2046c..000000000 --- a/providers/gitea/gitea.go +++ /dev/null @@ -1,186 +0,0 @@ -// Package gitea implements the OAuth2 protocol for authenticating users through gitea. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package gitea - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the default Authentication, Token, and Profile URLS for Gitea. -// -// Examples: -// -// gitea.AuthURL = "https://gitea.acme.com/oauth/authorize -// gitea.TokenURL = "https://gitea.acme.com/oauth/token -// gitea.ProfileURL = "https://gitea.acme.com/api/v3/user -var ( - AuthURL = "https://gitea.com/login/oauth/authorize" - TokenURL = "https://gitea.com/login/oauth/access_token" - ProfileURL = "https://gitea.com/api/v1/user" -) - -// Provider is the implementation of `goth.Provider` for accessing Gitea. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - authURL string - tokenURL string - profileURL string -} - -// New creates a new Gitea provider and sets up important connection details. -// You should always call `gitea.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "gitea", - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the gitea package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Gitea for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Gitea and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(p.profileURL + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"full_name"` - Email string `json:"email"` - NickName string `json:"login"` - ID int `json:"id"` - AvatarURL string `json:"avatar_url"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.NickName = u.NickName - user.UserID = strconv.Itoa(u.ID) - user.AvatarURL = u.AvatarURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/gitea/gitea_test.go b/providers/gitea/gitea_test.go deleted file mode 100644 index c5f1d399c..000000000 --- a/providers/gitea/gitea_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package gitea_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/gitea" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("GITEA_KEY")) - a.Equal(p.Secret, os.Getenv("GITEA_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*gitea.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*gitea.Session) - a.NoError(err) - a.Contains(s.AuthURL, "gitea.com/login/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://gitea.com/login/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*gitea.Session) - a.Equal(s.AuthURL, "https://gitea.com/login/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *gitea.Provider { - return gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "/foo") -} - -func urlCustomisedURLProvider() *gitea.Provider { - return gitea.NewCustomisedURL(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") -} diff --git a/providers/gitea/session.go b/providers/gitea/session.go deleted file mode 100644 index 18c3fff7e..000000000 --- a/providers/gitea/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package gitea - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Gitea. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Gitea provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Gitea and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/gitea/session_test.go b/providers/gitea/session_test.go deleted file mode 100644 index 565b76653..000000000 --- a/providers/gitea/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package gitea_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/gitea" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitea.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitea.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitea.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitea.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/github/github.go b/providers/github/github.go deleted file mode 100644 index 37efff9de..000000000 --- a/providers/github/github.go +++ /dev/null @@ -1,244 +0,0 @@ -// Package github implements the OAuth2 protocol for authenticating users through Github. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package github - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the Authentication, Token, and API URLS for GitHub. If -// using GitHub enterprise you should change these values before calling New. -// -// Examples: -// -// github.AuthURL = "https://github.acme.com/login/oauth/authorize -// github.TokenURL = "https://github.acme.com/login/oauth/access_token -// github.ProfileURL = "https://github.acme.com/api/v3/user -// github.EmailURL = "https://github.acme.com/api/v3/user/emails -var ( - AuthURL = "https://github.com/login/oauth/authorize" - TokenURL = "https://github.com/login/oauth/access_token" - ProfileURL = "https://api.github.com/user" - EmailURL = "https://api.github.com/user/emails" -) - -var ( - // ErrNoVerifiedGitHubPrimaryEmail user doesn't have verified primary email on GitHub - ErrNoVerifiedGitHubPrimaryEmail = errors.New("The user does not have a verified, primary email address on GitHub") -) - -// New creates a new Github provider, and sets up important connection details. -// You should always call `github.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, EmailURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL, emailURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "github", - profileURL: profileURL, - emailURL: emailURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Github. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - profileURL string - emailURL string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the github package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Github for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Github and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - return user, err - } - - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("GitHub API responded with a %d trying to fetch user information", response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - if err != nil { - return user, err - } - - if user.Email == "" { - for _, scope := range p.config.Scopes { - if strings.TrimSpace(scope) == "user" || strings.TrimSpace(scope) == "user:email" { - user.Email, err = getPrivateMail(p, sess) - if err != nil { - return user, err - } - break - } - } - } - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID int `json:"id"` - Email string `json:"email"` - Bio string `json:"bio"` - Name string `json:"name"` - Login string `json:"login"` - Picture string `json:"avatar_url"` - Location string `json:"location"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Name = u.Name - user.NickName = u.Login - user.Email = u.Email - user.Description = u.Bio - user.AvatarURL = u.Picture - user.UserID = strconv.Itoa(u.ID) - user.Location = u.Location - - return err -} - -func getPrivateMail(p *Provider, sess *Session) (email string, err error) { - req, err := http.NewRequest("GET", p.emailURL, nil) - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - if response != nil { - response.Body.Close() - } - return email, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return email, fmt.Errorf("GitHub API responded with a %d trying to fetch user email", response.StatusCode) - } - - var mailList []struct { - Email string `json:"email"` - Primary bool `json:"primary"` - Verified bool `json:"verified"` - } - err = json.NewDecoder(response.Body).Decode(&mailList) - if err != nil { - return email, err - } - for _, v := range mailList { - if v.Primary && v.Verified { - return v.Email, nil - } - } - return email, ErrNoVerifiedGitHubPrimaryEmail -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - - return c -} - -// RefreshToken refresh token is not provided by github -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by github") -} - -// RefreshTokenAvailable refresh token is not provided by github -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/github/github_test.go b/providers/github/github_test.go deleted file mode 100644 index 3bf9289b1..000000000 --- a/providers/github/github_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package github_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/github" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := githubProvider() - a.Equal(provider.ClientKey, os.Getenv("GITHUB_KEY")) - a.Equal(provider.Secret, os.Getenv("GITHUB_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*github.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), githubProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := githubProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*github.Session) - a.NoError(err) - a.Contains(s.AuthURL, "github.com/login/oauth/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GITHUB_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=user") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := githubProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://github.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*github.Session) - a.Equal(session.AuthURL, "http://github.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func githubProvider() *github.Provider { - return github.New(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "/foo", "user") -} - -func urlCustomisedURLProvider() *github.Provider { - return github.NewCustomisedURL(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL", "http://emailURL") -} diff --git a/providers/github/session.go b/providers/github/session.go deleted file mode 100644 index cd19e8705..000000000 --- a/providers/github/session.go +++ /dev/null @@ -1,56 +0,0 @@ -package github - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with GitHub. -type Session struct { - AuthURL string - AccessToken string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the GitHub provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with GitHub and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/github/session_test.go b/providers/github/session_test.go deleted file mode 100644 index 5241f9d43..000000000 --- a/providers/github/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package github_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/github" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &github.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &github.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &github.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &github.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/gitlab/gitlab.go b/providers/gitlab/gitlab.go deleted file mode 100644 index e3561eb8b..000000000 --- a/providers/gitlab/gitlab.go +++ /dev/null @@ -1,187 +0,0 @@ -// Package gitlab implements the OAuth2 protocol for authenticating users through gitlab. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package gitlab - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the Authentication, Token, and Profile URLS for Gitlab. If -// using Gitlab CE or EE, you should change these values before calling New. -// -// Examples: -// -// gitlab.AuthURL = "https://gitlab.acme.com/oauth/authorize -// gitlab.TokenURL = "https://gitlab.acme.com/oauth/token -// gitlab.ProfileURL = "https://gitlab.acme.com/api/v3/user -var ( - AuthURL = "https://gitlab.com/oauth/authorize" - TokenURL = "https://gitlab.com/oauth/token" - ProfileURL = "https://gitlab.com/api/v3/user" -) - -// Provider is the implementation of `goth.Provider` for accessing Gitlab. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - authURL string - tokenURL string - profileURL string -} - -// New creates a new Gitlab provider and sets up important connection details. -// You should always call `gitlab.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "gitlab", - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the gitlab package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Gitlab for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Gitlab and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(p.profileURL + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Email string `json:"email"` - NickName string `json:"username"` - ID int `json:"id"` - AvatarURL string `json:"avatar_url"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.NickName = u.NickName - user.UserID = strconv.Itoa(u.ID) - user.AvatarURL = u.AvatarURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/gitlab/gitlab_test.go b/providers/gitlab/gitlab_test.go deleted file mode 100644 index 9d0cebf69..000000000 --- a/providers/gitlab/gitlab_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package gitlab_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/gitlab" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("GITLAB_KEY")) - a.Equal(p.Secret, os.Getenv("GITLAB_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*gitlab.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*gitlab.Session) - a.NoError(err) - a.Contains(s.AuthURL, "gitlab.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://gitlab.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*gitlab.Session) - a.Equal(s.AuthURL, "https://gitlab.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *gitlab.Provider { - return gitlab.New(os.Getenv("GITLAB_KEY"), os.Getenv("GITLAB_SECRET"), "/foo") -} - -func urlCustomisedURLProvider() *gitlab.Provider { - return gitlab.NewCustomisedURL(os.Getenv("GITLAB_KEY"), os.Getenv("GITLAB_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") -} diff --git a/providers/gitlab/session.go b/providers/gitlab/session.go deleted file mode 100644 index a2f90647c..000000000 --- a/providers/gitlab/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package gitlab - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Gitlab. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Gitlab provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Gitlab and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/gitlab/session_test.go b/providers/gitlab/session_test.go deleted file mode 100644 index 23682d2e2..000000000 --- a/providers/gitlab/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package gitlab_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/gitlab" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitlab.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitlab.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitlab.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &gitlab.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/google/endpoint.go b/providers/google/endpoint.go deleted file mode 100644 index 9e3a7e353..000000000 --- a/providers/google/endpoint.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build go1.9 -// +build go1.9 - -package google - -import ( - goog "golang.org/x/oauth2/google" -) - -// Endpoint is Google's OAuth 2.0 endpoint. -var Endpoint = goog.Endpoint diff --git a/providers/google/endpoint_legacy.go b/providers/google/endpoint_legacy.go deleted file mode 100644 index 9dcc4360e..000000000 --- a/providers/google/endpoint_legacy.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !go1.9 -// +build !go1.9 - -package google - -import ( - "golang.org/x/oauth2" -) - -// Endpoint is Google's OAuth 2.0 endpoint. -var Endpoint = oauth2.Endpoint{ - AuthURL: "https://accounts.google.com/o/oauth2/auth", - TokenURL: "https://accounts.google.com/o/oauth2/token", -} diff --git a/providers/google/google.go b/providers/google/google.go deleted file mode 100644 index ca0695463..000000000 --- a/providers/google/google.go +++ /dev/null @@ -1,223 +0,0 @@ -// Package google implements the OAuth2 protocol for authenticating users -// through Google. -package google - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const endpointProfile string = "https://openidconnect.googleapis.com/v1/userinfo" - -// New creates a new Google provider, and sets up important connection details. -// You should always call `google.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "google", - - // We can get a refresh token from Google by this option. - // See https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param - authCodeOptions: []oauth2.AuthCodeOption{ - oauth2.AccessTypeOffline, - }, - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Google. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - authCodeOptions []oauth2.AuthCodeOption - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns an HTTP client to be used in all fetch operations. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the google package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Google for an authentication endpoint. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state, p.authCodeOptions...) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -type googleUser struct { - ID string `json:"id"` - Sub string `json:"sub"` - Email string `json:"email"` - Name string `json:"name"` - FirstName string `json:"given_name"` - LastName string `json:"family_name"` - Link string `json:"link"` - Picture string `json:"picture"` -} - -// FetchUser will go to Google and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - IDToken: sess.IDToken, - } - - if user.AccessToken == "" { - // Data is not yet retrieved, since accessToken is still empty. - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+sess.AccessToken) - - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - responseBytes, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - var u googleUser - if err := json.Unmarshal(responseBytes, &u); err != nil { - return user, err - } - - // Extract the user data we got from Google into our goth.User. - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.Name - user.Email = u.Email - user.AvatarURL = u.Picture - - if u.ID != "" { - user.UserID = u.ID - } else { - user.UserID = u.Sub - } - - // Google provides other useful fields such as 'hd'; get them from RawData - if err := json.Unmarshal(responseBytes, &user.RawData); err != nil { - return user, err - } - - return user, nil -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: Endpoint, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = append(c.Scopes, scopes...) - } else { - c.Scopes = []string{"openid", "email", "profile"} - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -// SetPrompt sets the prompt values for the google OAuth call. Use this to -// force users to choose and account every time by passing "select_account", -// for example. -// See https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters -func (p *Provider) SetPrompt(prompt ...string) { - if len(prompt) == 0 { - return - } - p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("prompt", strings.Join(prompt, " "))) -} - -// SetHostedDomain sets the hd parameter for google OAuth call. -// Use this to force user to pick user from specific hosted domain. -// See https://developers.google.com/identity/protocols/oauth2/openid-connect#hd-param -func (p *Provider) SetHostedDomain(hd string) { - if hd == "" { - return - } - p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("hd", hd)) -} - -// SetLoginHint sets the login_hint parameter for the Google OAuth call. -// Use this to prompt the user to log in with a specific account. -// See https://developers.google.com/identity/protocols/oauth2/openid-connect#login-hint -func (p *Provider) SetLoginHint(loginHint string) { - if loginHint == "" { - return - } - p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("login_hint", loginHint)) -} - -// SetAccessType sets the access_type parameter for the Google OAuth call. -// If an access token is being requested, the client does not receive a refresh token unless a value of offline is specified. -// See https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param -func (p *Provider) SetAccessType(at string) { - if at == "" { - return - } - p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("access_type", at)) -} diff --git a/providers/google/google_test.go b/providers/google/google_test.go deleted file mode 100644 index 20aa1081c..000000000 --- a/providers/google/google_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package google_test - -import ( - "encoding/json" - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/google" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := googleProvider() - a.Equal(provider.ClientKey, os.Getenv("GOOGLE_KEY")) - a.Equal(provider.Secret, os.Getenv("GOOGLE_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := googleProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*google.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=openid+email+profile") - a.Contains(s.AuthURL, "access_type=offline") -} - -func Test_BeginAuthWithPrompt(t *testing.T) { - // This exists because there was a panic caused by the oauth2 package when - // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does - // not, to ensure both cases are covered. - t.Parallel() - a := assert.New(t) - - provider := googleProvider() - provider.SetPrompt("test", "prompts") - session, err := provider.BeginAuth("test_state") - s := session.(*google.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=openid+email+profile") - a.Contains(s.AuthURL, "access_type=offline") - a.Contains(s.AuthURL, "prompt=test+prompts") -} - -func Test_BeginAuthWithHostedDomain(t *testing.T) { - // This exists because there was a panic caused by the oauth2 package when - // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does - // not, to ensure both cases are covered. - t.Parallel() - a := assert.New(t) - - provider := googleProvider() - provider.SetHostedDomain("example.com") - session, err := provider.BeginAuth("test_state") - s := session.(*google.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=openid+email+profile") - a.Contains(s.AuthURL, "access_type=offline") - a.Contains(s.AuthURL, "hd=example.com") -} - -func Test_BeginAuthWithLoginHint(t *testing.T) { - // This exists because there was a panic caused by the oauth2 package when - // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does - // not, to ensure both cases are covered. - t.Parallel() - a := assert.New(t) - - provider := googleProvider() - provider.SetLoginHint("john@example.com") - session, err := provider.BeginAuth("test_state") - s := session.(*google.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=openid+email+profile") - a.Contains(s.AuthURL, "access_type=offline") - a.Contains(s.AuthURL, "login_hint=john%40example.com") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), googleProvider()) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := googleProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://accounts.google.com/o/oauth2/auth","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*google.Session) - a.Equal(session.AuthURL, "https://accounts.google.com/o/oauth2/auth") - a.Equal(session.AccessToken, "1234567890") -} - -func Test_UserIDHandling(t *testing.T) { - t.Parallel() - a := assert.New(t) - - // Test v2 endpoint response format (uses 'id' field) - v2Response := `{"id":"123456789","email":"test@example.com","name":"Test User"}` - var userV2 struct { - ID string `json:"id"` - Sub string `json:"sub"` - Email string `json:"email"` - Name string `json:"name"` - } - err := json.Unmarshal([]byte(v2Response), &userV2) - a.NoError(err) - a.Equal("123456789", userV2.ID) - a.Equal("", userV2.Sub) - - // Test OpenID Connect response format (uses 'sub' field) - oidcResponse := `{"sub":"123456789","email":"test@example.com","name":"Test User"}` - var userOIDC struct { - ID string `json:"id"` - Sub string `json:"sub"` - Email string `json:"email"` - Name string `json:"name"` - } - err = json.Unmarshal([]byte(oidcResponse), &userOIDC) - a.NoError(err) - a.Equal("", userOIDC.ID) - a.Equal("123456789", userOIDC.Sub) -} - -func googleProvider() *google.Provider { - return google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGEL_SECRET"), "/foo") -} diff --git a/providers/google/session.go b/providers/google/session.go deleted file mode 100644 index 0206dfa5a..000000000 --- a/providers/google/session.go +++ /dev/null @@ -1,65 +0,0 @@ -package google - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Google. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - IDToken string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Google and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - if idToken := token.Extra("id_token"); idToken != nil { - s.IDToken = idToken.(string) - } - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/google/session_test.go b/providers/google/session_test.go deleted file mode 100644 index 30cef9565..000000000 --- a/providers/google/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package google_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/google" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &google.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &google.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &google.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &google.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/heroku/heroku.go b/providers/heroku/heroku.go deleted file mode 100644 index 3df805463..000000000 --- a/providers/heroku/heroku.go +++ /dev/null @@ -1,157 +0,0 @@ -// Package heroku implements the OAuth2 protocol for authenticating users through heroku. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package heroku - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://id.heroku.com/oauth/authorize" - tokenURL string = "https://id.heroku.com/oauth/token" - endpointProfile string = "https://api.heroku.com/account" -) - -// Provider is the implementation of `goth.Provider` for accessing Heroku. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Heroku provider and sets up important connection details. -// You should always call `heroku.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "heroku", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the heroku package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Heroku for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Heroku and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - req.Header.Set("Accept", "application/vnd.heroku+json; version=3") - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Email string `json:"email"` - ID string `json:"id"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.UserID = u.ID - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/heroku/heroku_test.go b/providers/heroku/heroku_test.go deleted file mode 100644 index e50b846fd..000000000 --- a/providers/heroku/heroku_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package heroku_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/heroku" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("HEROKU_KEY")) - a.Equal(p.Secret, os.Getenv("HEROKU_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*heroku.Session) - a.NoError(err) - a.Contains(s.AuthURL, "id.heroku.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://id.heroku.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*heroku.Session) - a.Equal(s.AuthURL, "https://id.heroku.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *heroku.Provider { - return heroku.New(os.Getenv("HEROKU_KEY"), os.Getenv("HEROKU_SECRET"), "/foo") -} diff --git a/providers/heroku/session.go b/providers/heroku/session.go deleted file mode 100644 index 2cd9ec9bc..000000000 --- a/providers/heroku/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package heroku - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Heroku. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Heroku provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Heroku and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/heroku/session_test.go b/providers/heroku/session_test.go deleted file mode 100644 index abaf50e97..000000000 --- a/providers/heroku/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package heroku_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/heroku" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &heroku.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &heroku.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &heroku.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &heroku.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/hubspot/hubspot.go b/providers/hubspot/hubspot.go deleted file mode 100644 index 36b5dd843..000000000 --- a/providers/hubspot/hubspot.go +++ /dev/null @@ -1,174 +0,0 @@ -package hubspot - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the Authentication and Token URLS for Hubspot. -var ( - AuthURL = "https://app.hubspot.com/oauth/authorize" - TokenURL = "https://api.hubapi.com/oauth/v1/token" -) - -const ( - userEndpoint = "https://api.hubapi.com/oauth/v1/access-tokens/" -) - -type hubspotUser struct { - Token string `json:"token"` - User string `json:"user"` - HubDomain string `json:"hub_domain"` - Scopes []string `json:"scopes"` - ScopeToScopeGroupPKs []int `json:"scope_to_scope_group_pks"` - TrialScopes []string `json:"trial_scopes"` - TrialScopeToScopeGroupPKs []int `json:"trial_scope_to_scope_group_pks"` - HubID int `json:"hub_id"` - AppID int `json:"app_id"` - ExpiresIn int `json:"expires_in"` - UserID int `json:"user_id"` - TokenType string `json:"token_type"` -} - -// Provider is the implementation of `goth.Provider` for accessing Hubspot. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Hubspot provider and sets up important connection details. -// You should always call `hubspot.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "hubspot", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the hubspot package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Hubspot for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Hubspot and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(userEndpoint + url.QueryEscape(user.AccessToken)) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - responseBytes, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - var u hubspotUser - if err := json.Unmarshal(responseBytes, &u); err != nil { - return user, err - } - - // Extract the user data we got from Google into our goth.User. - user.Email = u.User - user.UserID = strconv.Itoa(u.UserID) - accessTokenExpiration := time.Now() - if u.ExpiresIn > 0 { - accessTokenExpiration = accessTokenExpiration.Add(time.Duration(u.ExpiresIn) * time.Second) - } else { - accessTokenExpiration = accessTokenExpiration.Add(30 * time.Minute) - } - user.ExpiresAt = accessTokenExpiration - // Google provides other useful fields such as 'hd'; get them from RawData - if err := json.Unmarshal(responseBytes, &user.RawData); err != nil { - return user, err - } - - return user, nil -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: AuthURL, - TokenURL: TokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = append(c.Scopes, scopes...) - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/hubspot/hubspot_test.go b/providers/hubspot/hubspot_test.go deleted file mode 100644 index c8ecb6ae5..000000000 --- a/providers/hubspot/hubspot_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package hubspot_test - -import ( - "github.com/markbates/goth/providers/hubspot" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("HUBSPOT_KEY")) - a.Equal(p.Secret, os.Getenv("HUBSPOT_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*hubspot.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://app.hubspot.com/oauth/authoriz") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://app.hubspot.com/oauth/authoriz","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*hubspot.Session) - a.Equal(s.AuthURL, "https://app.hubspot.com/oauth/authoriz") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *hubspot.Provider { - return hubspot.New(os.Getenv("HUBSPOT_KEY"), os.Getenv("HUBSPOT_SECRET"), "/foo") -} diff --git a/providers/hubspot/session.go b/providers/hubspot/session.go deleted file mode 100644 index 8cb3361f4..000000000 --- a/providers/hubspot/session.go +++ /dev/null @@ -1,60 +0,0 @@ -package hubspot - -import ( - "encoding/json" - "errors" - "github.com/markbates/goth" - "strings" -) - -// Session stores data during the auth process with Hubspot. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Hubspot provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Hubspot and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/hubspot/session_test.go b/providers/hubspot/session_test.go deleted file mode 100644 index bdc6f08d0..000000000 --- a/providers/hubspot/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package hubspot_test - -import ( - "github.com/markbates/goth/providers/hubspot" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &hubspot.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &hubspot.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &hubspot.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &hubspot.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/influxcloud/influxcloud.go b/providers/influxcloud/influxcloud.go deleted file mode 100644 index 220cb103c..000000000 --- a/providers/influxcloud/influxcloud.go +++ /dev/null @@ -1,180 +0,0 @@ -// Package influxdata implements the OAuth2 protocol for authenticating users through InfluxCloud. -// It is based off of the github implementation. -package influxcloud - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - // The hard coded domain is difficult here because influx cloud has an acceptance - // domain that is different, and we will need that for enterprise development. - defaultDomain string = "cloud.influxdata.com" - userAPIPath string = "/api/v1/user" - domainEnvKey string = "INFLUXCLOUD_OAUTH_DOMAIN" - authPath string = "/oauth/authorize" - tokenPath string = "/oauth/token" -) - -// New creates a new influx provider, and sets up important connection details. -// You should always call `influxcloud.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - domain := os.Getenv(domainEnvKey) - if domain == "" { - domain = defaultDomain - } - tokenURL := fmt.Sprintf("https://%s%s", domain, tokenPath) - authURL := fmt.Sprintf("https://%s%s", domain, authPath) - userAPIEndpoint := fmt.Sprintf("https://%s%s", domain, userAPIPath) - - return NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, userAPIEndpoint, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, userAPIEndpoint string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - UserAPIEndpoint: userAPIEndpoint, - Config: &oauth2.Config{ - ClientID: clientKey, - ClientSecret: secret, - RedirectURL: callbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: scopes, - }, - providerName: "influxcloud", - } - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Influx. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - UserAPIEndpoint string - HTTPClient *http.Client - Config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the influxcloud package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Influx for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.Config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Influx and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(p.UserAPIEndpoint + "?access_token=" + url.QueryEscape(sess.AccessToken)) - - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID int `json:"id"` - Email string `json:"email"` - Bio string `json:"bio"` - Name string `json:"name"` - Login string `json:"login"` - Picture string `json:"avatar_url"` - Location string `json:"location"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Name = u.Name - user.NickName = u.Login - user.Email = u.Email - user.Description = u.Bio - user.AvatarURL = u.Picture - user.UserID = strconv.Itoa(u.ID) - user.Location = u.Location - - return err -} - -// RefreshToken refresh token is not provided by influxcloud -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by influxcloud") -} - -// RefreshTokenAvailable refresh token is not provided by influxcloud -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/influxcloud/influxcloud_test.go b/providers/influxcloud/influxcloud_test.go deleted file mode 100644 index e50d00aa6..000000000 --- a/providers/influxcloud/influxcloud_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package influxcloud - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := influxcloudProvider() - a.Equal(provider.ClientKey, "testkey") - a.Equal(provider.Secret, "testsecret") - a.Equal(provider.CallbackURL, "/callback") - a.Equal(provider.UserAPIEndpoint, "https://cloud.influxdata.com/api/v1/user") -} - -func TestNewConfigDefaults(t *testing.T) { - t.Parallel() - a := assert.New(t) - config := influxcloudProvider().Config - a.NotNil(config) - a.Equal("testkey", config.ClientID) - a.Equal("testsecret", config.ClientSecret) - a.Equal("https://cloud.influxdata.com/oauth/authorize", config.Endpoint.AuthURL) - a.Equal("https://cloud.influxdata.com/oauth/token", config.Endpoint.TokenURL) - a.Equal("/callback", config.RedirectURL) - a.Equal("userscope", config.Scopes[0]) - a.Equal("adminscope", config.Scopes[1]) - a.Equal(2, len(config.Scopes)) -} - -func TestUrlsConfigurableWithEnvVars(t *testing.T) { - oldEnvVar := os.Getenv(domainEnvKey) - defer os.Setenv(domainEnvKey, oldEnvVar) - - a := assert.New(t) - os.Setenv(domainEnvKey, "example.com") - p := influxcloudProvider() - a.Equal("https://example.com/api/v1/user", p.UserAPIEndpoint) - c := p.Config - a.Equal("https://example.com/oauth/authorize", c.Endpoint.AuthURL) - a.Equal("https://example.com/oauth/token", c.Endpoint.TokenURL) -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), influxcloudProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := influxcloudProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - // FIXME: we really need to be able to run this against the acceptance server, too. - // How should we do this? Maybe a test envvar switch? - a.Contains(s.AuthURL, "cloud.influxdata.com/oauth/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("INFLUXCLOUD_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=user") -} -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := influxcloudProvider() - - // FIXME: What is this testing exactly? - s, err := provider.UnmarshalSession(`{"AuthURL":"http://github.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*Session) - a.Equal(session.AuthURL, "http://github.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func influxcloudProvider() *Provider { - return New("testkey", "testsecret", "/callback", "userscope", "adminscope") -} diff --git a/providers/influxcloud/session.go b/providers/influxcloud/session.go deleted file mode 100644 index ce1420650..000000000 --- a/providers/influxcloud/session.go +++ /dev/null @@ -1,58 +0,0 @@ -package influxcloud - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Influxcloud. -type Session struct { - AuthURL string - AccessToken string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Influxcloud provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Influxcloud and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - - token, err := p.Config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/influxcloud/session_test.go b/providers/influxcloud/session_test.go deleted file mode 100644 index bf7eed84c..000000000 --- a/providers/influxcloud/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package influxcloud_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/influxcloud" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &influxcloud.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &influxcloud.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &influxcloud.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &influxcloud.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/instagram/instagram.go b/providers/instagram/instagram.go deleted file mode 100644 index 0d1c9cc79..000000000 --- a/providers/instagram/instagram.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package instagram implements the OAuth2 protocol for authenticating users through Instagram. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package instagram - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -var ( - authURL = "https://api.instagram.com/oauth/authorize/" - tokenURL = "https://api.instagram.com/oauth/access_token" - endPointProfile = "https://api.instagram.com/v1/users/self/" -) - -// New creates a new Instagram provider, and sets up important connection details. -// You should always call `instagram.New` to get a new Provider. Never try to craete -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "instagram", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Instagram -type Provider struct { - ClientKey string - Secret string - CallbackURL string - UserAgent string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug TODO -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Instagram for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Instagram and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(endPointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - Data struct { - ID string `json:"id"` - UserName string `json:"username"` - FullName string `json:"full_name"` - ProfilePicture string `json:"profile_picture"` - Bio string `json:"bio"` - Website string `json:"website"` - Counts struct { - Media int `json:"media"` - Follows int `json:"follows"` - FollowedBy int `json:"followed_by"` - } `json:"counts"` - } `json:"data"` - }{} - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - user.UserID = u.Data.ID - user.Name = u.Data.FullName - user.NickName = u.Data.UserName - user.AvatarURL = u.Data.ProfilePicture - user.Description = u.Data.Bio - return err -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ - "basic", - }, - } - defaultScopes := map[string]struct{}{ - "basic": {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshToken refresh token is not provided by instagram -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by instagram") -} - -// RefreshTokenAvailable refresh token is not provided by instagram -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/instagram/instagram_test.go b/providers/instagram/instagram_test.go deleted file mode 100644 index c95b62ddd..000000000 --- a/providers/instagram/instagram_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package instagram_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/instagram" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := instagramProvider() - a.Equal(provider.ClientKey, os.Getenv("INSTAGRAM_KEY")) - a.Equal(provider.Secret, os.Getenv("INSTAGRAM_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), instagramProvider()) -} -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := instagramProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*instagram.Session) - a.NoError(err) - a.Contains(s.AuthURL, "api.instagram.com/oauth/authorize/") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("INSTAGRAM_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=basic") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := instagramProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://api.instagram.com/oauth/authorize/","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*instagram.Session) - a.Equal(session.AuthURL, "https://api.instagram.com/oauth/authorize/") - a.Equal(session.AccessToken, "1234567890") -} - -func instagramProvider() *instagram.Provider { - return instagram.New(os.Getenv("INSTAGRAM_KEY"), os.Getenv("INSTAGRAM_SECRET"), "/foo", "basic") -} diff --git a/providers/instagram/session.go b/providers/instagram/session.go deleted file mode 100644 index f6cfffe9f..000000000 --- a/providers/instagram/session.go +++ /dev/null @@ -1,56 +0,0 @@ -package instagram - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Instagram -type Session struct { - AuthURL string - AccessToken string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Instagram provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Instagram and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/instagram/session_test.go b/providers/instagram/session_test.go deleted file mode 100644 index 174e7c703..000000000 --- a/providers/instagram/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package instagram_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/instagram" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &instagram.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &instagram.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &instagram.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &instagram.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/intercom/intercom.go b/providers/intercom/intercom.go deleted file mode 100644 index 4d2e27834..000000000 --- a/providers/intercom/intercom.go +++ /dev/null @@ -1,181 +0,0 @@ -// Package intercom implements the OAuth protocol for authenticating users through Intercom. -package intercom - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -var ( - authURL = "https://app.intercom.io/oauth" - tokenURL = "https://api.intercom.io/auth/eagle/token?client_secret=%s" - UserURL = "https://api.intercom.io/me" -) - -// New creates the new Intercom provider -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "intercom", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Intercom -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the intercom package -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Intercom for an authentication end-point -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will fetch basic information about Intercom admin -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - request, err := http.NewRequest("GET", UserURL, nil) - if err != nil { - return user, err - } - request.Header.Add("Accept", "application/json") - request.Header.Add("User-Agent", "goth-intercom") - request.SetBasicAuth(sess.AccessToken, "") - - response, err := p.Client().Do(request) - - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Link string `json:"link"` - EmailVerified bool `json:"email_verified"` - Avatar struct { - URL string `json:"image_url"` - } `json:"avatar"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.Name = u.Name - user.FirstName, user.LastName = splitName(u.Name) - user.Email = u.Email - user.AvatarURL = u.Avatar.URL - user.UserID = u.ID - - return err -} - -func splitName(name string) (string, string) { - nameSplit := strings.SplitN(name, " ", 2) - firstName := nameSplit[0] - - var lastName string - if len(nameSplit) == 2 { - lastName = nameSplit[1] - } - - return firstName, lastName -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: fmt.Sprintf(tokenURL, provider.Secret), - }, - } - - return c -} - -// RefreshToken refresh token is not provided by Intercom -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by Intercom") -} - -// RefreshTokenAvailable refresh token is not provided by Intercom -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/intercom/intercom_test.go b/providers/intercom/intercom_test.go deleted file mode 100644 index 2fa3e98e7..000000000 --- a/providers/intercom/intercom_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package intercom_test - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gorilla/pat" - "github.com/markbates/goth" - "github.com/markbates/goth/providers/intercom" - "github.com/stretchr/testify/assert" -) - -type fetchUserPayload struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Link string `json:"link"` - EmailVerified bool `json:"email_verified"` - Avatar struct { - URL string `json:"image_url"` - } `json:"avatar"` -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := intercomProvider() - a.Equal(provider.ClientKey, os.Getenv("INTERCOM_KEY")) - a.Equal(provider.Secret, os.Getenv("INTERCOM_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), intercomProvider()) -} -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := intercomProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*intercom.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://app.intercom.io/oauth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("INTERCOM_KEY"))) - a.Contains(s.AuthURL, "state=test_state") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := intercomProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://app.intercom.io/oauth","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*intercom.Session) - a.Equal(session.AuthURL, "https://app.intercom.io/oauth") - a.Equal(session.AccessToken, "1234567890") -} - -func intercomProvider() *intercom.Provider { - return intercom.New(os.Getenv("INTERCOM_KEY"), os.Getenv("INTERCOM_SECRET"), "/foo", "basic") -} - -func Test_FetchUser(t *testing.T) { - a := assert.New(t) - - u := fetchUserPayload{} - u.ID = "1" - u.Email = "wash@serenity.now" - u.Name = "Hoban Washburne" - u.EmailVerified = true - u.Avatar.URL = "http://avatarURL" - - mockIntercomFetchUser(&u, func(ts *httptest.Server) { - provider := intercomProvider() - session := &intercom.Session{AccessToken: "token"} - - user, err := provider.FetchUser(session) - a.NoError(err) - - a.Equal("1", user.UserID) - a.Equal("wash@serenity.now", user.Email) - a.Equal("Hoban Washburne", user.Name) - a.Equal("Hoban", user.FirstName) - a.Equal("Washburne", user.LastName) - a.Equal("http://avatarURL", user.AvatarURL) - a.Equal(true, user.RawData["email_verified"]) - a.Equal("token", user.AccessToken) - }) -} - -func Test_FetchUnverifiedUser(t *testing.T) { - a := assert.New(t) - - u := fetchUserPayload{} - u.ID = "1" - u.Email = "wash@serenity.now" - u.Name = "Hoban Washburne" - u.EmailVerified = false - u.Avatar.URL = "http://avatarURL" - - mockIntercomFetchUser(&u, func(ts *httptest.Server) { - provider := intercomProvider() - session := &intercom.Session{AccessToken: "token"} - - user, err := provider.FetchUser(session) - a.NoError(err) - - a.Equal("1", user.UserID) - a.Equal("wash@serenity.now", user.Email) - a.Equal("Hoban Washburne", user.Name) - a.Equal("Hoban", user.FirstName) - a.Equal("Washburne", user.LastName) - a.Equal("http://avatarURL", user.AvatarURL) - a.Equal(false, user.RawData["email_verified"]) - a.Equal("token", user.AccessToken) - }) -} - -func mockIntercomFetchUser(fetchUserPayload *fetchUserPayload, f func(*httptest.Server)) { - p := pat.New() - p.Get("/me", func(res http.ResponseWriter, req *http.Request) { - json.NewEncoder(res).Encode(fetchUserPayload) - }) - ts := httptest.NewServer(p) - defer ts.Close() - - originalUserURL := intercom.UserURL - - intercom.UserURL = ts.URL + "/me" - - f(ts) - - intercom.UserURL = originalUserURL -} diff --git a/providers/intercom/session.go b/providers/intercom/session.go deleted file mode 100644 index c7a954663..000000000 --- a/providers/intercom/session.go +++ /dev/null @@ -1,60 +0,0 @@ -package intercom - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with intercom. -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the intercom provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with intercom and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/intercom/session_test.go b/providers/intercom/session_test.go deleted file mode 100644 index 81c68d9b5..000000000 --- a/providers/intercom/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package intercom_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/intercom" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &intercom.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &intercom.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &intercom.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &intercom.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/kakao/kakao.go b/providers/kakao/kakao.go deleted file mode 100644 index 15d97c43f..000000000 --- a/providers/kakao/kakao.go +++ /dev/null @@ -1,162 +0,0 @@ -// Package kakao implements the OAuth2 protocol for authenticating users through kakao. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package kakao - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://kauth.kakao.com/oauth/authorize" - tokenURL string = "https://kauth.kakao.com/oauth/token" - endpointUser string = "https://kapi.kakao.com/v2/user/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Kakao. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Kakao provider and sets up important connection details. -// You should always call `kakao.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "kakao", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns a pointer to http.Client setting some client fallback. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the kakao package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks kakao for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to kakao and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // Get the userID, kakao needs userID in order to get user profile info - c := p.Client() - req, err := http.NewRequest("GET", endpointUser, nil) - if err != nil { - return user, err - } - - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - - response, err := c.Do(req) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - u := struct { - ID int `json:"id"` - Properties struct { - Nickname string `json:"nickname"` - ThumbnailImage string `json:"thumbnail_image"` - ProfileImage string `json:"profile_image"` - } `json:"properties"` - }{} - - if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { - return user, err - } - - id := strconv.Itoa(u.ID) - - user.NickName = u.Properties.Nickname - user.AvatarURL = u.Properties.ProfileImage - user.UserID = id - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} diff --git a/providers/kakao/kakao_test.go b/providers/kakao/kakao_test.go deleted file mode 100644 index 2221ab68f..000000000 --- a/providers/kakao/kakao_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package kakao_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/kakao" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("KAKAO_CLIENT_ID")) - a.Equal(p.Secret, os.Getenv("KAKAO_CLIENT_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*kakao.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://kauth.kakao.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://kauth.kakao.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*kakao.Session) - a.Equal(s.AuthURL, "https://kauth.kakao.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *kakao.Provider { - return kakao.New(os.Getenv("KAKAO_CLIENT_ID"), os.Getenv("KAKAO_CLIENT_SECRET"), "/foo") -} diff --git a/providers/kakao/session.go b/providers/kakao/session.go deleted file mode 100644 index 3eb401e77..000000000 --- a/providers/kakao/session.go +++ /dev/null @@ -1,65 +0,0 @@ -package kakao - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Kakao. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Kakao provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Kakao and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - oauth2.RegisterBrokenAuthHeaderProvider(tokenURL) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/kakao/session_test.go b/providers/kakao/session_test.go deleted file mode 100644 index 90d3f3cd6..000000000 --- a/providers/kakao/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package kakao_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/line" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - data := s.Marshal() - a.Equal(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`, data) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/lark/lark.go b/providers/lark/lark.go deleted file mode 100644 index d9900b9ce..000000000 --- a/providers/lark/lark.go +++ /dev/null @@ -1,307 +0,0 @@ -package lark - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - appAccessTokenURL string = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal/" // get app_access_token - - authURL string = "https://open.feishu.cn/open-apis/authen/v1/authorize" // obtain authorization code - tokenURL string = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token" // get user_access_token - refreshTokenURL string = "https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token" // refresh user_access_token - endpointProfile string = "https://open.feishu.cn/open-apis/authen/v1/user_info" // get user info -) - -// Lark is the implementation of `goth.Provider` for accessing Lark -type Lark interface { - GetAppAccessToken() error // get app access token -} - -// Provider is the implementation of `goth.Provider` for accessing Lark -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - - appAccessToken *appAccessToken -} - -// New creates a new Lark provider and sets up important connection details. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "lark", - appAccessToken: &appAccessToken{}, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = append(c.Scopes, scopes...) - } - return c -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -func (p *Provider) Name() string { - return p.providerName -} - -func (p *Provider) SetName(name string) { - p.providerName = name -} - -type appAccessToken struct { - Token string - ExpiresAt time.Time - rMutex sync.RWMutex -} - -type appAccessTokenReq struct { - AppID string `json:"app_id"` // 自建应用的 app_id - AppSecret string `json:"app_secret"` // 自建应用的 app_secret -} - -type appAccessTokenResp struct { - Code int `json:"code"` // 错误码 - Msg string `json:"msg"` // 错误信息 - AppAccessToken string `json:"app_access_token"` // 用于调用应用级接口的 app_access_token - Expire int64 `json:"expire"` // app_access_token 的过期时间 -} - -// GetAppAccessToken get lark app access token -func (p *Provider) GetAppAccessToken() error { - // get from cache app access token - p.appAccessToken.rMutex.RLock() - if time.Now().Before(p.appAccessToken.ExpiresAt) { - p.appAccessToken.rMutex.RUnlock() - return nil - } - p.appAccessToken.rMutex.RUnlock() - - reqBody, err := json.Marshal(&appAccessTokenReq{ - AppID: p.ClientKey, - AppSecret: p.Secret, - }) - if err != nil { - return fmt.Errorf("failed to marshal request body: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, appAccessTokenURL, bytes.NewBuffer(reqBody)) - if err != nil { - return fmt.Errorf("failed to create app access token request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := p.Client().Do(req) - if err != nil { - return fmt.Errorf("failed to send app access token request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code while fetching app access token: %d", resp.StatusCode) - } - - tokenResp := new(appAccessTokenResp) - if err = json.NewDecoder(resp.Body).Decode(tokenResp); err != nil { - return fmt.Errorf("failed to decode app access token response: %w", err) - } - - if tokenResp.Code != 0 { - return fmt.Errorf("failed to get app access token: code:%v msg: %s", tokenResp.Code, tokenResp.Msg) - } - - // update local cache - expirationDuration := time.Duration(tokenResp.Expire) * time.Second - p.appAccessToken.rMutex.Lock() - p.appAccessToken.Token = tokenResp.AppAccessToken - p.appAccessToken.ExpiresAt = time.Now().Add(expirationDuration) - p.appAccessToken.rMutex.Unlock() - - return nil -} - -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - // build lark auth url - u, err := url.Parse(p.config.AuthCodeURL(state)) - if err != nil { - panic(err) - } - query := u.Query() - query.Del("response_type") - query.Del("client_id") - query.Add("app_id", p.ClientKey) - u.RawQuery = query.Encode() - - return &Session{ - AuthURL: u.String(), - }, nil -} - -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} - -func (p *Provider) Debug(b bool) { -} - -type getUserAccessTokenResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - RefreshExpiresIn int `json:"refresh_expires_in"` - Scope string `json:"scope"` -} - -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - if err := p.GetAppAccessToken(); err != nil { - return nil, fmt.Errorf("failed to get app access token: %w", err) - } - reqBody := strings.NewReader(`{"grant_type":"refresh_token","refresh_token":"` + refreshToken + `"}`) - - req, err := http.NewRequest(http.MethodPost, refreshTokenURL, reqBody) - if err != nil { - return nil, fmt.Errorf("failed to create refresh token request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.appAccessToken.Token)) - - resp, err := p.Client().Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send refresh token request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code while refreshing token: %d", resp.StatusCode) - } - - var oauthResp commResponse[getUserAccessTokenResp] - err = json.NewDecoder(resp.Body).Decode(&oauthResp) - if err != nil { - return nil, fmt.Errorf("failed to decode refreshed token: %w", err) - } - if oauthResp.Code != 0 { - return nil, fmt.Errorf("failed to refresh token: code:%v msg: %s", oauthResp.Code, oauthResp.Msg) - } - - token := oauth2.Token{ - AccessToken: oauthResp.Data.AccessToken, - RefreshToken: oauthResp.Data.RefreshToken, - Expiry: time.Now().Add(time.Duration(oauthResp.Data.ExpiresIn) * time.Second), - } - - return &token, nil -} - -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -type commResponse[T any] struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data T `json:"data"` -} - -type larkUser struct { - OpenID string `json:"open_id"` - UnionID string `json:"union_id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Email string `json:"enterprise_email"` - AvatarURL string `json:"avatar_url"` - Mobile string `json:"mobile,omitempty"` -} - -// FetchUser will go to Lark and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, fmt.Errorf("%s failed to create request: %w", p.providerName, err) - } - req.Header.Set("Authorization", "Bearer "+user.AccessToken) - - resp, err := p.Client().Do(req) - if err != nil { - return user, fmt.Errorf("%s failed to get user information: %w", p.providerName, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - responseBytes, err := io.ReadAll(resp.Body) - if err != nil { - return user, fmt.Errorf("failed to read response body: %w", err) - } - - var oauthResp commResponse[larkUser] - if err = json.Unmarshal(responseBytes, &oauthResp); err != nil { - return user, fmt.Errorf("failed to decode user info: %w", err) - } - if oauthResp.Code != 0 { - return user, fmt.Errorf("failed to get user info: code:%v msg: %s", oauthResp.Code, oauthResp.Msg) - } - - u := oauthResp.Data - user.UserID = u.UserID - user.Name = u.Name - user.Email = u.Email - user.AvatarURL = u.AvatarURL - user.NickName = u.Name - - if err = json.Unmarshal(responseBytes, &user.RawData); err != nil { - return user, err - } - return user, nil -} diff --git a/providers/lark/lark_test.go b/providers/lark/lark_test.go deleted file mode 100644 index cda49e522..000000000 --- a/providers/lark/lark_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package lark_test - -import ( - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "os" - "strings" - "testing" - - "github.com/markbates/goth/providers/lark" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -type MockedHTTPClient struct { - mock.Mock -} - -func (m *MockedHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { - args := m.Mock.Called(req) - return args.Get(0).(*http.Response), args.Error(1) -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := larkProvider() - - a.Equal(p.ClientKey, os.Getenv("LARK_APP_ID")) - a.Equal(p.Secret, os.Getenv("LARK_APP_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := larkProvider() - session, err := p.BeginAuth("test_state") - s := session.(*lark.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://open.feishu.cn/open-apis/authen/v1/authorize") - a.Contains(s.AuthURL, "app_id="+os.Getenv("LARK_APP_ID")) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, fmt.Sprintf("redirect_uri=%s", url.QueryEscape("/foo"))) -} - -func Test_GetAppAccessToken(t *testing.T) { - t.Run("happy path", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","app_access_token":"test_token","expire":3600}`)), - }, nil) - - err := p.GetAppAccessToken() - assert.NoError(t, err) - }) - - t.Run("error on request", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{}, errors.New("request error")) - - err := p.GetAppAccessToken() - assert.Error(t, err) - }) - - t.Run("non-200 status code", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusForbidden, - Body: ioutil.NopCloser(strings.NewReader(``)), - }, nil) - - err := p.GetAppAccessToken() - assert.Error(t, err) - }) - - t.Run("error on response decode", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`not a json`)), - }, nil) - - err := p.GetAppAccessToken() - assert.Error(t, err) - }) - - t.Run("error code in response", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`{"code":1,"msg":"error message"}`)), - }, nil) - - err := p.GetAppAccessToken() - assert.Error(t, err) - }) -} - -func Test_FetchUser(t *testing.T) { - session := &lark.Session{ - AccessToken: "user_access_token", - } - - t.Run("happy path", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","data":{"user_id":"test_user_id","name":"test_name","avatar_url":"test_avatar_url","enterprise_email":"test_email"}}`)), - }, nil) - user, err := p.FetchUser(session) - require.NoError(t, err) - assert.Equal(t, user.UserID, "test_user_id") - assert.Equal(t, user.Name, "test_name") - assert.Equal(t, user.AvatarURL, "test_avatar_url") - assert.Equal(t, user.Email, "test_email") - }) - t.Run("error on request", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{}, errors.New("request error")) - _, err := p.FetchUser(session) - require.Error(t, err) - }) - t.Run("non-200 status code", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusForbidden, - Body: ioutil.NopCloser(strings.NewReader(``)), - }, nil) - _, err := p.FetchUser(session) - require.Error(t, err) - }) - t.Run("error on response decode", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`not a json`)), - }, nil) - _, err := p.FetchUser(session) - require.Error(t, err) - }) - t.Run("error code in response", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`{"code":1,"msg":"error message"}`)), - }, nil) - _, err := p.FetchUser(session) - require.Error(t, err) - }) -} - -func larkProvider() *lark.Provider { - return lark.New(os.Getenv("LARK_APP_ID"), os.Getenv("LARK_APP_SECRET"), "/foo") -} diff --git a/providers/lark/session.go b/providers/lark/session.go deleted file mode 100644 index 2fdf260ce..000000000 --- a/providers/lark/session.go +++ /dev/null @@ -1,71 +0,0 @@ -package lark - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "github.com/markbates/goth" -) - -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - RefreshTokenExpiresAt time.Time -} - -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New("lark: missing AuthURL") - } - return s.AuthURL, nil -} - -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - reqBody := strings.NewReader(`{"grant_type":"authorization_code","code":"` + params.Get("code") + `"}`) - req, err := http.NewRequest(http.MethodPost, tokenURL, reqBody) - if err != nil { - return "", fmt.Errorf("failed to create refresh token request: %w", err) - } - if err = p.GetAppAccessToken(); err != nil { - return "", fmt.Errorf("failed to get app access token: %w", err) - } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", p.appAccessToken.Token)) - req.Header.Add("Content-Type", "application/json; charset=utf-8") - - resp, err := p.Client().Do(req) - if err != nil { - return "", fmt.Errorf("failed to send refresh token request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected status code while authorizing: %d", resp.StatusCode) - } - - var larkCommResp commResponse[getUserAccessTokenResp] - err = json.NewDecoder(resp.Body).Decode(&larkCommResp) - if err != nil { - return "", fmt.Errorf("failed to decode commResponse: %w", err) - } - if larkCommResp.Code != 0 { - return "", fmt.Errorf("failed to get accessToken: code:%v msg: %s", larkCommResp.Code, larkCommResp.Msg) - } - - s.AccessToken = larkCommResp.Data.AccessToken - s.RefreshToken = larkCommResp.Data.RefreshToken - s.ExpiresAt = time.Now().Add(time.Duration(larkCommResp.Data.ExpiresIn) * time.Second) - s.RefreshTokenExpiresAt = time.Now().Add(time.Duration(larkCommResp.Data.RefreshExpiresIn) * time.Second) - return s.AccessToken, nil -} diff --git a/providers/lark/session_test.go b/providers/lark/session_test.go deleted file mode 100644 index 59dc53f2b..000000000 --- a/providers/lark/session_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package lark_test - -import ( - "errors" - "io/ioutil" - "net/http" - "strings" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/lark" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -type MockParams struct { - params map[string]string -} - -func (m *MockParams) Get(key string) string { - return m.params[key] -} - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &lark.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Run("happy path", func(t *testing.T) { - session := &lark.Session{ - AuthURL: "https://auth.url", - } - url, err := session.GetAuthURL() - assert.NoError(t, err) - assert.Equal(t, "https://auth.url", url) - }) - - t.Run("missing AuthURL", func(t *testing.T) { - session := &lark.Session{} - _, err := session.GetAuthURL() - assert.Error(t, err) - }) -} - -func Test_Marshal(t *testing.T) { - session := &lark.Session{ - AuthURL: "https://auth.url", - AccessToken: "access_token", - } - marshaled := session.Marshal() - assert.Contains(t, marshaled, "https://auth.url") - assert.Contains(t, marshaled, "access_token") -} - -func Test_Authorize(t *testing.T) { - session := &lark.Session{} - params := &MockParams{ - params: map[string]string{ - "code": "authorization_code", - }, - } - - t.Run("error on request", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{}, errors.New("request error")) - _, err := session.Authorize(p, params) - require.Error(t, err) - }) - - t.Run("non-200 status code", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusForbidden, - Body: ioutil.NopCloser(strings.NewReader(``)), - }, nil) - _, err := session.Authorize(p, params) - require.Error(t, err) - }) - - t.Run("error on response decode", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`not a json`)), - }, nil) - _, err := session.Authorize(p, params) - require.Error(t, err) - }) - - t.Run("error code in response", func(t *testing.T) { - mockClient := new(MockedHTTPClient) - p := larkProvider() - p.HTTPClient = &http.Client{Transport: mockClient} - mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ - StatusCode: http.StatusOK, - Body: ioutil.NopCloser(strings.NewReader(`{"code":1,"msg":"error message"}`)), - }, nil) - _, err := session.Authorize(p, params) - require.Error(t, err) - }) -} diff --git a/providers/lastfm/lastfm.go b/providers/lastfm/lastfm.go deleted file mode 100644 index 3d844455a..000000000 --- a/providers/lastfm/lastfm.go +++ /dev/null @@ -1,230 +0,0 @@ -// Package lastfm implements the OAuth protocol for authenticating users through LastFM. -// This package can be used as a reference impleentation of an OAuth provider for Goth. -package lastfm - -import ( - "crypto/md5" - "encoding/hex" - "encoding/xml" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "sort" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -var ( - authURL = "http://www.lastfm.com/api/auth" - endpointProfile = "http://ws.audioscrobbler.com/2.0/" -) - -// New creates a new LastFM provider, and sets up important connection details. -// You should always call `lastfm.New` to get a new Provider. Never try to craete -// one manullay. -func New(clientKey string, secret string, callbackURL string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "lastfm", - } - return p -} - -// Provider is the implementation of `goth.Provider` for accessing LastFM -type Provider struct { - ClientKey string - Secret string - CallbackURL string - UserAgent string - HTTPClient *http.Client - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the lastfm package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks LastFm for an authentication end-point -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - urlParams := url.Values{} - urlParams.Add("api_key", p.ClientKey) - urlParams.Add("cb", p.CallbackURL) - - session := &Session{ - AuthURL: authURL + "?" + urlParams.Encode(), - } - - return session, nil -} - -// FetchUser will go to LastFM and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s has no user information available (yet)", p.providerName) - } - - u := struct { - XMLName xml.Name `xml:"user"` - ID string `xml:"id"` - Name string `xml:"name"` - RealName string `xml:"realname"` - URL string `xml:"url"` - Country string `xml:"country"` - Age string `xml:"age"` - Gender string `xml:"gender"` - Subscriber string `xml:"subscriber"` - PlayCount string `xml:"playcount"` - Playlists string `xml:"playlists"` - Bootstrap string `xml:"bootstrap"` - Registered struct { - Unixtime string `xml:"unixtime,attr"` - Time string `xml:",chardata"` - } `xml:"registered"` - Images []struct { - Size string `xml:"size,attr"` - URL string `xml:",chardata"` - } `xml:"image"` - }{} - - login := session.(*Session).Login - err := p.request(false, map[string]string{"method": "user.getinfo", "user": login}, &u) - - if err == nil { - user.Name = u.RealName - user.NickName = u.Name - user.AvatarURL = u.Images[3].URL - user.UserID = u.ID - user.Location = u.Country - } - - return user, err -} - -// GetSession token from LastFM -func (p *Provider) GetSession(token string) (map[string]string, error) { - sess := struct { - Name string `xml:"name"` - Key string `xml:"key"` - Subscriber bool `xml:"subscriber"` - }{} - - err := p.request(true, map[string]string{"method": "auth.getSession", "token": token}, &sess) - return map[string]string{"login": sess.Name, "token": sess.Key}, err -} - -func (p *Provider) request(sign bool, params map[string]string, result interface{}) error { - urlParams := url.Values{} - urlParams.Add("method", params["method"]) - - params["api_key"] = p.ClientKey - for k, v := range params { - urlParams.Add(k, v) - } - - if sign { - urlParams.Add("api_sig", signRequest(p.Secret, params)) - } - - uri := endpointProfile + "?" + urlParams.Encode() - - req, err := http.NewRequest("GET", uri, nil) - if err != nil { - return err - } - req.Header.Set("User-Agent", p.UserAgent) - - res, err := p.Client().Do(req) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode/100 == 5 { // only 5xx class errros - err = errors.New(fmt.Errorf("Request error(%v) %v", res.StatusCode, res.Status).Error()) - return err - } - body, err := io.ReadAll(res.Body) - if err != nil { - return err - } - - base := struct { - XMLName xml.Name `xml:"lfm"` - Status string `xml:"status,attr"` - Inner []byte `xml:",innerxml"` - }{} - - err = xml.Unmarshal(body, &base) - if err != nil { - return err - } - - if base.Status != "ok" { - errorDetail := struct { - Code int `xml:"code,attr"` - Message string `xml:",chardata"` - }{} - - err = xml.Unmarshal(base.Inner, &errorDetail) - if err != nil { - return err - } - - return errors.New(fmt.Errorf("Request Error(%v): %v", errorDetail.Code, errorDetail.Message).Error()) - } - - return xml.Unmarshal(base.Inner, result) -} - -func signRequest(secret string, params map[string]string) string { - var keys []string - for k := range params { - keys = append(keys, k) - } - sort.Strings(keys) - - var sigPlain string - for _, k := range keys { - sigPlain += k + params[k] - } - sigPlain += secret - - hasher := md5.New() - hasher.Write([]byte(sigPlain)) - return hex.EncodeToString(hasher.Sum(nil)) -} - -// RefreshToken refresh token is not provided by lastfm -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by lastfm") -} - -// RefreshTokenAvailable refresh token is not provided by lastfm -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/lastfm/lastfm_test.go b/providers/lastfm/lastfm_test.go deleted file mode 100644 index 5af851d6f..000000000 --- a/providers/lastfm/lastfm_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package lastfm - -import ( - "fmt" - "net/url" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := lastfmProvider() - a.Equal(provider.ClientKey, os.Getenv("LASTFM_KEY")) - a.Equal(provider.Secret, os.Getenv("LASTFM_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), lastfmProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := lastfmProvider() - session, err := provider.BeginAuth("") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.lastfm.com/api/auth") - a.Contains(s.AuthURL, fmt.Sprintf("api_key=%s", os.Getenv("LASTFM_KEY"))) - a.Contains(s.AuthURL, fmt.Sprintf("cb=%s", url.QueryEscape("/foo"))) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := lastfmProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":"123456", "Login":"Quin"}`) - a.NoError(err) - session := s.(*Session) - a.Equal(session.AuthURL, "http://com/auth_url") - a.Equal(session.AccessToken, "123456") - a.Equal(session.Login, "Quin") -} - -func lastfmProvider() *Provider { - return New(os.Getenv("LASTFM_KEY"), os.Getenv("LASTFM_SECRET"), "/foo") -} diff --git a/providers/lastfm/session.go b/providers/lastfm/session.go deleted file mode 100644 index 43cb70b43..000000000 --- a/providers/lastfm/session.go +++ /dev/null @@ -1,54 +0,0 @@ -package lastfm - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Lastfm. -type Session struct { - AuthURL string - AccessToken string - Login string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the LastFM provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with LastFM and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - sess, err := p.GetSession(params.Get("token")) - if err != nil { - return "", err - } - - s.AccessToken = sess["token"] - s.Login = sess["login"] - return sess["token"], err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/lastfm/session_test.go b/providers/lastfm/session_test.go deleted file mode 100644 index f90ec61c3..000000000 --- a/providers/lastfm/session_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package lastfm - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","Login":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/line/line.go b/providers/line/line.go deleted file mode 100644 index 00f689a30..000000000 --- a/providers/line/line.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package line implements the OAuth2 protocol for authenticating users through line. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package line - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/golang-jwt/jwt/v5" - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://access.line.me/oauth2/v2.1/authorize" - tokenURL string = "https://api.line.me/oauth2/v2.1/token" - endpointUser string = "https://api.line.me/v2/profile" - issuerURL string = "https://access.line.me" -) - -type IDTokenClaims struct { - jwt.RegisteredClaims - Email string `json:"email"` -} - -// Provider is the implementation of `goth.Provider` for accessing Line.me. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - authCodeOptions []oauth2.AuthCodeOption - providerName string -} - -// New creates a new Line provider and sets up important connection details. -// You should always call `line.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "line", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns a pointer to http.Client setting some client fallback. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the line package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks line.me for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state, p.authCodeOptions...), - }, nil -} - -// FetchUser will go to line.me and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // Get the userID, line needs userID in order to get user profile info - c := p.Client() - req, err := http.NewRequest("GET", endpointUser, nil) - if err != nil { - return user, err - } - - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - - response, err := c.Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - u := struct { - Name string `json:"name"` - UserID string `json:"userId"` - DisplayName string `json:"displayName"` - PictureURL string `json:"pictureUrl"` - StatusMessage string `json:"statusMessage"` - }{} - - if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { - return user, err - } - - user.NickName = u.DisplayName - user.AvatarURL = u.PictureURL - user.UserID = u.UserID - - if sess.IDToken != "" { - if err = p.addDataFromIdToken(sess.IDToken, &user); err != nil { - return user, err - } - } - - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = append(c.Scopes, scopes...) - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} - -// SetBotPrompt sets the bot_prompt parameter for the line OAuth call. -// Use this to display the option to add your LINE Official Account as a friend. -// See https://developers.line.biz/en/docs/line-login/link-a-bot/#redirect-users -func (p *Provider) SetBotPrompt(botPrompt string) { - if botPrompt == "" { - return - } - p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("bot_prompt", botPrompt)) -} - -func (p *Provider) addDataFromIdToken(idToken string, user *goth.User) error { - token, err := jwt.ParseWithClaims(idToken, &IDTokenClaims{}, func(t *jwt.Token) (interface{}, error) { - return []byte(p.Secret), nil - }, - jwt.WithAudience(p.ClientKey), - jwt.WithIssuer(issuerURL), - jwt.WithExpirationRequired(), - ) - if err != nil { - return err - } - - user.Email = token.Claims.(*IDTokenClaims).Email - - return nil -} diff --git a/providers/line/line_test.go b/providers/line/line_test.go deleted file mode 100644 index 832e2afd4..000000000 --- a/providers/line/line_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package line_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/line" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("LINE_CLIENT_ID")) - a.Equal(p.Secret, os.Getenv("LINE_CLIENT_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*line.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://access.line.me/oauth2/v2.1/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://access.line.me/oauth2/v2.1/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*line.Session) - a.Equal(s.AuthURL, "https://access.line.me/oauth2/v2.1/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func Test_SetBotPrompt(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - p.SetBotPrompt("normal") - session, err := p.BeginAuth("test_state") - s := session.(*line.Session) - a.NoError(err) - a.Contains(s.AuthURL, "bot_prompt=normal") -} - -func provider() *line.Provider { - return line.New(os.Getenv("LINE_CLIENT_ID"), os.Getenv("LINE_CLIENT_SECRET"), "/foo") -} diff --git a/providers/line/session.go b/providers/line/session.go deleted file mode 100644 index 0213bc15f..000000000 --- a/providers/line/session.go +++ /dev/null @@ -1,69 +0,0 @@ -package line - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Line. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - IDToken string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Line provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Line and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - - if idToken := token.Extra("id_token"); idToken != nil { - s.IDToken = idToken.(string) - } - - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/line/session_test.go b/providers/line/session_test.go deleted file mode 100644 index 827565d42..000000000 --- a/providers/line/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package line_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/line" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &line.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/linkedin/linkedin.go b/providers/linkedin/linkedin.go deleted file mode 100644 index 5719911d7..000000000 --- a/providers/linkedin/linkedin.go +++ /dev/null @@ -1,278 +0,0 @@ -// Package linkedin implements the OAuth2 protocol for authenticating users through Linkedin. -package linkedin - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// more details about linkedin fields: -// User Profile and Email Address - https://docs.microsoft.com/en-gb/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin -// User Avatar - https://docs.microsoft.com/en-gb/linkedin/shared/references/v2/digital-media-asset - -const ( - authURL string = "https://www.linkedin.com/oauth/v2/authorization" - tokenURL string = "https://www.linkedin.com/oauth/v2/accessToken" - - // userEndpoint requires scope "r_liteprofile" - userEndpoint string = "//api.linkedin.com/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~:playableStreams))" - // emailEndpoint requires scope "r_emailaddress" - emailEndpoint string = "//api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))" -) - -// New creates a new linkedin provider, and sets up important connection details. -// You should always call `linkedin.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "linkedin", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Linkedin. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns an HTTPClientWithFallback -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the linkedin package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Linkedin for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Linkedin and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // create request for user r_liteprofile - req, err := http.NewRequest("GET", "", nil) - if err != nil { - return user, err - } - - // add url as opaque to avoid escaping of "(" - req.URL = &url.URL{ - Scheme: "https", - Host: "api.linkedin.com", - Opaque: userEndpoint, - } - - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user profile", p.providerName, resp.StatusCode) - } - - // read r_liteprofile information - err = userFromReader(resp.Body, &user) - if err != nil { - return user, err - } - - // create request for user r_emailaddress - reqEmail, err := http.NewRequest("GET", "", nil) - if err != nil { - return user, err - } - - // add url as opaque to avoid escaping of "(" - reqEmail.URL = &url.URL{ - Scheme: "https", - Host: "api.linkedin.com", - Opaque: emailEndpoint, - } - - reqEmail.Header.Set("Authorization", "Bearer "+s.AccessToken) - respEmail, err := p.Client().Do(reqEmail) - if err != nil { - return user, err - } - defer respEmail.Body.Close() - - if respEmail.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user email", p.providerName, respEmail.StatusCode) - } - - // read r_emailaddress information - err = emailFromReader(respEmail.Body, &user) - - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID string `json:"id"` - FirstName struct { - PreferredLocale struct { - Country string `json:"country"` - Language string `json:"language"` - } `json:"preferredLocale"` - Localized map[string]string `json:"localized"` - } `json:"firstName"` - LastName struct { - Localized map[string]string - PreferredLocale struct { - Country string `json:"country"` - Language string `json:"language"` - } `json:"preferredLocale"` - } `json:"lastName"` - ProfilePicture struct { - DisplayImage struct { - Elements []struct { - AuthorizationMethod string `json:"authorizationMethod"` - Identifiers []struct { - Identifier string `json:"identifier"` - IdentifierType string `json:"identifierType"` - } `json:"identifiers"` - } `json:"elements"` - } `json:"displayImage~"` - } `json:"profilePicture"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.FirstName = u.FirstName.Localized[u.FirstName.PreferredLocale.Language+"_"+u.FirstName.PreferredLocale.Country] - user.LastName = u.LastName.Localized[u.LastName.PreferredLocale.Language+"_"+u.LastName.PreferredLocale.Country] - user.Name = user.FirstName + " " + user.LastName - user.NickName = user.FirstName - user.UserID = u.ID - - avatarURL := "" - // loop all displayimage elements - for _, element := range u.ProfilePicture.DisplayImage.Elements { - // only retrieve data where the authorization method allows public (unauthorized) access - if element.AuthorizationMethod == "PUBLIC" { - for _, identifier := range element.Identifiers { - // check to ensure the identifier type is a url linking to the image - if identifier.IdentifierType == "EXTERNAL_URL" { - avatarURL = identifier.Identifier - // we only need the first image url - break - } - } - } - // if we have a valid image, exit the loop as we only support a single avatar image - if len(avatarURL) > 0 { - break - } - } - - user.AvatarURL = avatarURL - - return err -} - -func emailFromReader(reader io.Reader, user *goth.User) error { - e := struct { - Elements []struct { - Handle struct { - EmailAddress string `json:"emailAddress"` - } `json:"handle~"` - } `json:"elements"` - }{} - - err := json.NewDecoder(reader).Decode(&e) - if err != nil { - return err - } - - if len(e.Elements) > 0 { - user.Email = e.Elements[0].Handle.EmailAddress - } - - if len(user.Email) == 0 { - return errors.New("Unable to retrieve email address") - } - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) == 0 { - // add helper as new API requires the scope to be specified and these are the minimum to retrieve profile information and user's email address - scopes = append(scopes, "r_liteprofile", "r_emailaddress") - } - - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - - return c -} - -// RefreshToken refresh token is not provided by linkedin -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by linkedin") -} - -// RefreshTokenAvailable refresh token is not provided by linkedin -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/linkedin/linkedin_test.go b/providers/linkedin/linkedin_test.go deleted file mode 100644 index a67644eca..000000000 --- a/providers/linkedin/linkedin_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package linkedin_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/linkedin" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := linkedinProvider() - a.Equal(provider.ClientKey, os.Getenv("LINKEDIN_KEY")) - a.Equal(provider.Secret, os.Getenv("LINKEDIN_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), linkedinProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := linkedinProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*linkedin.Session) - a.NoError(err) - a.Contains(s.AuthURL, "linkedin.com/oauth/v2/authorization") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("LINKEDIN_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=r_liteprofile+r_emailaddress&state") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := linkedinProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://linkedin.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*linkedin.Session) - a.Equal(session.AuthURL, "http://linkedin.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func linkedinProvider() *linkedin.Provider { - return linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "/foo", "r_liteprofile", "r_emailaddress") -} diff --git a/providers/linkedin/session.go b/providers/linkedin/session.go deleted file mode 100644 index 51dee95d1..000000000 --- a/providers/linkedin/session.go +++ /dev/null @@ -1,58 +0,0 @@ -package linkedin - -import ( - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with LinkedIn. -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the LinkedIn provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with LinkedIn and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := Session{} - err := json.Unmarshal([]byte(data), &s) - return &s, err -} diff --git a/providers/linkedin/session_test.go b/providers/linkedin/session_test.go deleted file mode 100644 index 4cd49c22a..000000000 --- a/providers/linkedin/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package linkedin_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/linkedin" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &linkedin.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &linkedin.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &linkedin.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &linkedin.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/mailru/mailru.go b/providers/mailru/mailru.go deleted file mode 100644 index f1d15ea0d..000000000 --- a/providers/mailru/mailru.go +++ /dev/null @@ -1,138 +0,0 @@ -// Package mailru implements the OAuth2 protocol for authenticating users through mailru.com. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package mailru - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL = "https://oauth.mail.ru/login" - tokenURL = "https://oauth.mail.ru/token" - endpointUser = "https://oauth.mail.ru/userinfo" -) - -// New creates a new MAILRU provider and sets up important connection details. -// You should always call `mailru.New` to get a new provider. Never try to -// create one manually. -func New(clientID, clientSecret, redirectURL string, scopes ...string) *Provider { - var c = &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: redirectURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - c.Scopes = append(c.Scopes, scopes...) - - return &Provider{ - name: "mailru", - oauthConfig: c, - } -} - -// Provider is the implementation of `goth.Provider` for accessing MAILRU. -type Provider struct { - name string - httpClient *http.Client - oauthConfig *oauth2.Config -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.name -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.name = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.httpClient) -} - -// BeginAuth asks MAILRU for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.oauthConfig.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to MAILRU and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (_ goth.User, err error) { - var ( - sess = session.(*Session) - user = goth.User{ - AccessToken: sess.AccessToken, - RefreshToken: sess.RefreshToken, - Provider: p.Name(), - ExpiresAt: sess.ExpiresAt, - } - ) - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without access token", p.name) - } - - var reqURL = fmt.Sprintf( - "%s?access_token=%s", - endpointUser, sess.AccessToken, - ) - - res, err := p.Client().Get(reqURL) - if err != nil { - return user, err - } - - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.name, res.StatusCode) - } - - buf, err := io.ReadAll(res.Body) - if err != nil { - return user, err - } - - if err = json.Unmarshal(buf, &user.RawData); err != nil { - return user, err - } - - // extract and ignore all errors - user.UserID, _ = user.RawData["id"].(string) - user.FirstName, _ = user.RawData["first_name"].(string) - user.LastName, _ = user.RawData["last_name"].(string) - user.NickName, _ = user.RawData["nickname"].(string) - user.Email, _ = user.RawData["email"].(string) - user.AvatarURL, _ = user.RawData["image"].(string) - - return user, err -} - -// Debug is a no-op for the mailru package. -func (p *Provider) Debug(debug bool) {} - -// RefreshToken refresh token is not provided by mailru. -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - t := &oauth2.Token{RefreshToken: refreshToken} - ts := p.oauthConfig.TokenSource(goth.ContextForClient(p.Client()), t) - - return ts.Token() -} - -// RefreshTokenAvailable refresh token is not provided by mailru -func (p *Provider) RefreshTokenAvailable() bool { - return true -} diff --git a/providers/mailru/mailru_test.go b/providers/mailru/mailru_test.go deleted file mode 100644 index e7b6d8838..000000000 --- a/providers/mailru/mailru_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package mailru_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/mailru" - "github.com/stretchr/testify/assert" -) - -func Test_Name(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := mailruProvider() - a.Equal(provider.Name(), "mailru") -} - -func Test_SetName(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := mailruProvider() - provider.SetName("foo") - a.Equal(provider.Name(), "foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), mailruProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := mailruProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*mailru.Session) - a.NoError(err) - a.Contains(s.AuthURL, "oauth.mail.ru/login") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("MAILRU_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=photos") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := mailruProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://mailru.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*mailru.Session) - a.Equal(session.AuthURL, "http://mailru.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func mailruProvider() *mailru.Provider { - return mailru.New(os.Getenv("MAILRU_KEY"), os.Getenv("MAILRU_SECRET"), "/foo", "photos") -} diff --git a/providers/mailru/session.go b/providers/mailru/session.go deleted file mode 100644 index 0487d3efc..000000000 --- a/providers/mailru/session.go +++ /dev/null @@ -1,59 +0,0 @@ -package mailru - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with MAILRU. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL returns the URL for the authentication end-point for the provider. -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - - return s.AuthURL, nil -} - -// Marshal the session into a string -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -// Authorize the session with MAILRU and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.oauthConfig.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - - return s.AccessToken, err -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := new(Session) - err := json.NewDecoder(strings.NewReader(data)).Decode(&sess) - return sess, err -} diff --git a/providers/mailru/session_test.go b/providers/mailru/session_test.go deleted file mode 100644 index 5d8bff63d..000000000 --- a/providers/mailru/session_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package mailru_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/mailru" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mailru.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mailru.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mailru.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} diff --git a/providers/mastodon/mastodon.go b/providers/mastodon/mastodon.go deleted file mode 100644 index 58c018cec..000000000 --- a/providers/mastodon/mastodon.go +++ /dev/null @@ -1,184 +0,0 @@ -// Package mastodon implements the OAuth2 protocol for authenticating users through Mastodon. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package mastodon - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Mastodon.social is the flagship instance of mastodon -var ( - InstanceURL = "https://mastodon.social/" -) - -// Provider is the implementation of `goth.Provider` for accessing Mastodon. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - authURL string - tokenURL string - profileURL string -} - -// New creates a new Mastodon provider and sets up important connection details. -// You should always call `mastodon.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, InstanceURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, instanceURL string, scopes ...string) *Provider { - instanceURL = fmt.Sprintf("%s/", strings.TrimSuffix(instanceURL, "/")) - profileURL := fmt.Sprintf("%sapi/v1/accounts/verify_credentials", instanceURL) - authURL := fmt.Sprintf("%soauth/authorize", instanceURL) - tokenURL := fmt.Sprintf("%soauth/token", instanceURL) - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "mastodon", - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the Mastodon package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Mastodon for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Mastodon and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - return user, err - } - - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"display_name"` - NickName string `json:"username"` - ID string `json:"id"` - AvatarURL string `json:"avatar"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Name = u.Name - if len(user.Name) == 0 { - user.Name = u.NickName - } - user.NickName = u.NickName - user.UserID = u.ID - user.AvatarURL = u.AvatarURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/mastodon/mastodon_test.go b/providers/mastodon/mastodon_test.go deleted file mode 100644 index 508ca7286..000000000 --- a/providers/mastodon/mastodon_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package mastodon_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/mastodon" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("MASTODON_KEY")) - a.Equal(p.Secret, os.Getenv("MASTODON_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*mastodon.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*mastodon.Session) - a.NoError(err) - a.Contains(s.AuthURL, "mastodon.social/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://mastodon.social/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*mastodon.Session) - a.Equal(s.AuthURL, "https://mastodon.social/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *mastodon.Provider { - return mastodon.New(os.Getenv("MASTODON_KEY"), os.Getenv("MASTODON_SECRET"), "/foo") -} - -func urlCustomisedURLProvider() *mastodon.Provider { - return mastodon.NewCustomisedURL(os.Getenv("MASTODON_KEY"), os.Getenv("MASTODON_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") -} diff --git a/providers/mastodon/session.go b/providers/mastodon/session.go deleted file mode 100644 index b975c0805..000000000 --- a/providers/mastodon/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package mastodon - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Gitea. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Gitea provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Gitea and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/mastodon/session_test.go b/providers/mastodon/session_test.go deleted file mode 100644 index 04caf36b3..000000000 --- a/providers/mastodon/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package mastodon_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/mastodon" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mastodon.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mastodon.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mastodon.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &mastodon.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/meetup/meetup.go b/providers/meetup/meetup.go deleted file mode 100644 index 94aa053b5..000000000 --- a/providers/meetup/meetup.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package meetup implements the OAuth2 protocol for authenticating users through meetup.com . -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package meetup - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://secure.meetup.com/oauth2/authorize" - tokenURL string = "https://secure.meetup.com/oauth2/access" - endpointProfile string = "https://api.meetup.com/2/member/self" -) - -// New creates a new Meetup provider, and sets up important connection details. -// You should always call `meetup.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "meetup", - } - // register this meetup.com provider as broken for oauth2 RetrieveToken - oauth2.RegisterBrokenAuthHeaderProvider(tokenURL) - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing meetup.com . -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the meetup package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks meetup.com for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to meetup.com and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - request, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - - request.Header.Set("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(request) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - ID uint64 `json:"id"` - Name string `json:"name"` - Picture string `json:"photo_url"` - Country string `json:"country"` - City string `json:"city"` - State string `json:"state"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.UserID = strconv.FormatUint(u.ID, 10) - user.Name = u.Name - user.NickName = u.Name - - var location string - if len(u.City) > 0 { - location = u.City - } - if len(u.State) > 0 { - if len(location) > 0 { - location = location + ", " + u.State - } else { - location = u.State - } - } - if len(u.Country) > 0 { - if len(location) > 0 { - location = location + ", " + u.Country - } else { - location = u.Country - } - } - - user.Location = location - user.AvatarURL = u.Picture - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/meetup/meetup_test.go b/providers/meetup/meetup_test.go deleted file mode 100644 index c3874ba3c..000000000 --- a/providers/meetup/meetup_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package meetup_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/meetup" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("MEETUP_KEY")) - a.Equal(p.Secret, os.Getenv("MEETUP_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*meetup.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://secure.meetup.com/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"ttps://secure.meetup.com/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*meetup.Session) - a.Equal(s.AuthURL, "ttps://secure.meetup.com/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *meetup.Provider { - return meetup.New(os.Getenv("MEETUP_KEY"), os.Getenv("MEETUP_SECRET"), "/foo") -} diff --git a/providers/meetup/session.go b/providers/meetup/session.go deleted file mode 100644 index 611339592..000000000 --- a/providers/meetup/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package meetup - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with meetup.com . -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the meetup.com provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with meetup.com and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/meetup/session_test.go b/providers/meetup/session_test.go deleted file mode 100644 index af12d412f..000000000 --- a/providers/meetup/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package meetup_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/meetup" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &meetup.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &meetup.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &meetup.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &meetup.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/microsoftonline/microsoftonline.go b/providers/microsoftonline/microsoftonline.go deleted file mode 100644 index abf5db21c..000000000 --- a/providers/microsoftonline/microsoftonline.go +++ /dev/null @@ -1,190 +0,0 @@ -// Package microsoftonline implements the OAuth2 protocol for authenticating users through microsoftonline. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -// To use this package, your application need to be registered in [Application Registration Portal](https://apps.dev.microsoft.com/) -package microsoftonline - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/going/defaults" - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" - tokenURL string = "https://login.microsoftonline.com/common/oauth2/v2.0/token" - endpointProfile string = "https://graph.microsoft.com/v1.0/me" -) - -var defaultScopes = []string{"openid", "offline_access", "user.read"} - -// New creates a new microsoftonline provider, and sets up important connection details. -// You should always call `microsoftonline.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "microsoftonline", - } - - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing microsoftonline. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - tenant string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client is HTTP client to be used in all fetch operations. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the facebook package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks MicrosoftOnline for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - authURL := p.config.AuthCodeURL(state) - return &Session{ - AuthURL: authURL, - }, nil -} - -// FetchUser will go to MicrosoftOnline and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - msSession := session.(*Session) - user := goth.User{ - AccessToken: msSession.AccessToken, - Provider: p.Name(), - ExpiresAt: msSession.ExpiresAt, - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - - req.Header.Set(authorizationHeader(msSession)) - - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - user.AccessToken = msSession.AccessToken - - err = userFromReader(response.Body, &user) - return user, err -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -// available for microsoft online as session size hit the limit of max cookie size -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - if refreshToken == "" { - return nil, fmt.Errorf("No refresh token provided") - } - - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - c.Scopes = append(c.Scopes, scopes...) - if len(scopes) == 0 { - c.Scopes = append(c.Scopes, defaultScopes...) - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - buf := &bytes.Buffer{} - tee := io.TeeReader(r, buf) - - u := struct { - ID string `json:"id"` - Name string `json:"displayName"` - Email string `json:"mail"` - FirstName string `json:"givenName"` - LastName string `json:"surname"` - UserPrincipalName string `json:"userPrincipalName"` - }{} - - if err := json.NewDecoder(tee).Decode(&u); err != nil { - return err - } - - raw := map[string]interface{}{} - if err := json.NewDecoder(buf).Decode(&raw); err != nil { - return err - } - - user.UserID = u.ID - user.Email = defaults.String(u.Email, u.UserPrincipalName) - user.Name = u.Name - user.NickName = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - user.RawData = raw - - return nil -} - -func authorizationHeader(session *Session) (string, string) { - return "Authorization", fmt.Sprintf("Bearer %s", session.AccessToken) -} diff --git a/providers/microsoftonline/microsoftonline_test.go b/providers/microsoftonline/microsoftonline_test.go deleted file mode 100644 index 366080cfa..000000000 --- a/providers/microsoftonline/microsoftonline_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package microsoftonline_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/microsoftonline" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := microsoftonlineProvider() - - a.Equal(provider.ClientKey, os.Getenv("MICROSOFTONLINE_KEY")) - a.Equal(provider.Secret, os.Getenv("MICROSOFTONLINE_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := microsoftonlineProvider() - a.Implements((*goth.Provider)(nil), p) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := microsoftonlineProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*microsoftonline.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.microsoftonline.com/common/oauth2/v2.0/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := microsoftonlineProvider() - session, err := provider.UnmarshalSession(`{"AuthURL":"https://login.microsoftonline.com/common/oauth2/v2.0/authorize","AccessToken":"1234567890","ExpiresAt":"0001-01-01T00:00:00Z"}`) - a.NoError(err) - - s := session.(*microsoftonline.Session) - a.Equal(s.AuthURL, "https://login.microsoftonline.com/common/oauth2/v2.0/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func microsoftonlineProvider() *microsoftonline.Provider { - return microsoftonline.New(os.Getenv("MICROSOFTONLINE_KEY"), os.Getenv("MICROSOFTONLINE_SECRET"), "/foo") -} diff --git a/providers/microsoftonline/session.go b/providers/microsoftonline/session.go deleted file mode 100644 index 0747ab523..000000000 --- a/providers/microsoftonline/session.go +++ /dev/null @@ -1,62 +0,0 @@ -package microsoftonline - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session is the implementation of `goth.Session` for accessing microsoftonline. -// Refresh token not available for microsoft online: session size hit the limit of max cookie size -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - - return s.AuthURL, nil -} - -// Authorize the session with Facebook and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.ExpiresAt = token.Expiry - - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - session := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(session) - return session, err -} diff --git a/providers/microsoftonline/session_test.go b/providers/microsoftonline/session_test.go deleted file mode 100644 index 0db5816ca..000000000 --- a/providers/microsoftonline/session_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package microsoftonline_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/microsoftonline" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := µsoftonline.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := µsoftonline.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := µsoftonline.Session{ - AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - AccessToken: "1234567890", - } - - data := s.Marshal() - a.Equal(`{"AuthURL":"https://login.microsoftonline.com/common/oauth2/v2.0/authorize","AccessToken":"1234567890","ExpiresAt":"0001-01-01T00:00:00Z"}`, data) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := µsoftonline.Session{ - AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - AccessToken: "1234567890", - } - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/naver/naver.go b/providers/naver/naver.go deleted file mode 100644 index 2ebce639a..000000000 --- a/providers/naver/naver.go +++ /dev/null @@ -1,172 +0,0 @@ -package naver - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL = "https://nid.naver.com/oauth2.0/authorize" - tokenURL = "https://nid.naver.com/oauth2.0/token" - profileURL = "https://openapi.naver.com/v1/nid/me" -) - -// Provider is the implementation of `goth.Provider` for accessing naver.com. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// FetchUser will go to navercom and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - request, err := http.NewRequest("GET", profileURL, nil) - if err != nil { - return user, err - } - - request.Header.Set("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(request) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -// Debug is a no-op for the naver package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks naver.com for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// RefreshTokenAvailable refresh token is provided by naver -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -// New creates a New provider and sets up important connection details. -// You should always call `naver.New` to get a new Provider. Never try to craete -// one manually. -// Currently Naver only supports pre-defined scopes. -// You should visit Naver Developer page in order to define your application's oauth scope. -func New(clientKey, secret, callbackURL string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "naver", - } - p.config = newConfig(p) - return p -} - -func newConfig(p *Provider) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - return c -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - Response struct { - ID string - Nickname string - Name string - Email string - Gender string - Age string - Birthday string - ProfileImage string `json:"profile_image"` - } - }{} - - if err := json.NewDecoder(reader).Decode(&u); err != nil { - return err - } - r := u.Response - user.Email = r.Email - user.Name = r.Name - user.NickName = r.Nickname - user.AvatarURL = r.ProfileImage - user.UserID = r.ID - user.Description = fmt.Sprintf(`{"gender":"%s","age":"%s","birthday":"%s"}`, r.Gender, r.Age, r.Birthday) - - return nil -} diff --git a/providers/naver/naver_test.go b/providers/naver/naver_test.go deleted file mode 100644 index ee5385503..000000000 --- a/providers/naver/naver_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package naver_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/naver" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("NAVER_KEY")) - a.Equal(p.Secret, os.Getenv("NAVER_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*naver.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://nid.naver.com/oauth2.0/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("NAVER_KEY"))) - a.Contains(s.AuthURL, "state=test_state") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"ttps://nid.naver.com/oauth2.0/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*naver.Session) - a.Equal(s.AuthURL, "ttps://nid.naver.com/oauth2.0/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *naver.Provider { - return naver.New(os.Getenv("NAVER_KEY"), os.Getenv("NAVER_SECRET"), "/foo") -} diff --git a/providers/naver/session.go b/providers/naver/session.go deleted file mode 100644 index 01ad71359..000000000 --- a/providers/naver/session.go +++ /dev/null @@ -1,61 +0,0 @@ -package naver - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with naver.com. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the meetup.com provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with naver.com and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/naver/session_test.go b/providers/naver/session_test.go deleted file mode 100644 index 194878a8f..000000000 --- a/providers/naver/session_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package naver_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/naver" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &naver.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &naver.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &naver.Session{ - AuthURL: "https://nid.naver.com/oauth2.0/authorize", - AccessToken: "1234567890", - } - data := s.Marshal() - a.Equal(`{"AuthURL":"https://nid.naver.com/oauth2.0/authorize","AccessToken":"1234567890","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`, data) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &naver.Session{ - AuthURL: "https://nid.naver.com/oauth2.0/authorize", - AccessToken: "1234567890", - } - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/nextcloud/README.md b/providers/nextcloud/README.md deleted file mode 100644 index ae7d5668d..000000000 --- a/providers/nextcloud/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# Nextcloud OAuth2 - -For this backend, you need to have an OAuth2 enabled Nextcloud Instance, e.g. -on your own private server. - -## Setting up Nextcloud Test Environment - -To test, you only need a working Docker image of Nextcloud running on a public -URL, e.g. through [traefik](https://traefik.io) - -```docker-compose.yml -version: '2' - -networks: - traefik-web: - external: true - -services: - app: - image: nextcloud - restart: always - networks: - - traefik-web - labels: - - traefik.enable=true - - traefik.frontend.rule=Host:${NEXTCLOUD_DNS} - - traefik.docker.network=traefik-web - environment: - SQLITE_DATABASE: "database.sqlite3" - NEXTCLOUD_ADMIN_USER: admin - NEXTCLOUD_ADMIN_PASSWORD: admin - NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_DNS} -``` - -and start it up via - -``` -NEXTCLOUD_DNS=goth.my.server.name docker-compose up -d -``` - -afterwards, you will have a running Nextcloud instance with credentials - -``` -admin / admin -``` - -Then add a new OAuth 2.0 Client by going to - -``` -Settings -> Security -> OAuth 2.0 client -``` - -![Nextcloud Setup](nextcloud_setup.png) - -and add a new client with the name `goth` and redirection uri `http://localhost:3000/auth/nextcloud/callback`. The imporant part here the -two cryptic entries `Client Identifier` and `Secret`, which needs to be -used in your application. - -## Running Login Example - -If you want to run the default example in `/examples`, you have to -retrieve the keys described in the previous section and run the example -as follows: - -``` -NEXTCLOUD_URL=https://goth.my.server.name \ -NEXTCLOUD_KEY= \ -NEXTCLOUD_SECRET= \ -SESSION_SECRET=1 \ -./examples -``` - -Afterwards, you should be able to log in via Nextcloud in the examples app. - -## Running the Provider Test - -The test has the same arguments as the login example test, but starts the test itself - -``` -NEXTCLOUD_URL=https://goth.my.server.name \ -NEXTCLOUD_KEY= \ -NEXTCLOUD_SECRET= \ -SESSION_SECRET=1 \ -go test -v -``` diff --git a/providers/nextcloud/nextcloud.go b/providers/nextcloud/nextcloud.go deleted file mode 100644 index 43bc85976..000000000 --- a/providers/nextcloud/nextcloud.go +++ /dev/null @@ -1,205 +0,0 @@ -// Package nextcloud implements the OAuth2 protocol for authenticating users through nextcloud. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package nextcloud - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the Authentication, Token, and Profile URLS for Nextcloud. -// You have to set these values to something useful, because nextcloud is always -// hosted somewhere. -var ( - AuthURL = "https:///apps/oauth2/authorize" - TokenURL = "https:///apps/oauth2/api/v1/token" - ProfileURL = "https:///ocs/v2.php/cloud/user?format=json" -) - -// Provider is the implementation of `goth.Provider` for accessing Nextcloud. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - authURL string - tokenURL string - profileURL string -} - -// New is only here to fulfill the interface requirements and does not work properly without -// setting your own Nextcloud connect parameters, more precisely AuthURL, TokenURL and ProfileURL. -// Please use NewCustomisedDNS with the beginning of your URL or NewCustomiseURL. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...) -} - -// NewCustomisedURL create a working connection to your Nextcloud server given by the values -// authURL, tokenURL and profileURL. -// If you want to use a simpler method, please have a look at NewCustomisedDNS, which gets only -// on parameter instead of three. -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "nextcloud", - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// NewCustomisedDNS is the simplest method to create a provider based only on your key/secret -// and the beginning of the URL to your server, e.g. https://my.server.name/ -func NewCustomisedDNS(clientKey, secret, callbackURL, nextcloudURL string, scopes ...string) *Provider { - return NewCustomisedURL( - clientKey, - secret, - callbackURL, - nextcloudURL+"/apps/oauth2/authorize", - nextcloudURL+"/apps/oauth2/api/v1/token", - nextcloudURL+"/ocs/v2.php/cloud/user?format=json", - scopes..., - ) -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the nextcloud package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Nextcloud for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Nextcloud and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Ocs struct { - Data struct { - EMail string `json:"email"` - DisplayName string `json:"display-name"` - ID string `json:"id"` - Address string `json:"address"` - } - } `json:"ocs"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Ocs.Data.EMail - user.Name = u.Ocs.Data.DisplayName - user.UserID = u.Ocs.Data.ID - user.Location = u.Ocs.Data.Address - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/nextcloud/nextcloud_setup.png b/providers/nextcloud/nextcloud_setup.png deleted file mode 100644 index 8f457dd2a31650fd33e44126db684aaf847c92c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85944 zcmcG!bzD?mw>XT2N=tVs-QCU5Dcue;L&MM=Dgx3XCEeZKB@GhNjdXYSZ?L}4eLv50 z?|p0kHRqhQ*IIk^T6_GJ6eN)m@DbqP;E<)I#9za~J*tF*dszPLA?!^x6{j^E974XS zn3$5dshzb2&;<@oskz+Yb|EPR}pd`2R9&8kC+`RFaJ=;GytVGGh8KvU}@=Ewa5 zJcYQGUbcAF-qv}$)R!J;iEwa=MkOCi;XXBz+PD()A%7<`C%0%_+dqFcaJ(5+Owr_kLXbIER-A{D`dUDc}tU z^^=u17!&Yj&BVndgm8W>%7YIS-r4;?uZFjJ|6m_Zss-2i0cEor^Aiy?lvH6=DdeGd zNMv?hD7L=*q(an>13sW9gQ<}h#n7{#X(L98MJgeeqnf?<^i~jFOQus2vPZ>#*O9_C zXpH+L90<`w?1m(KT&#x ziJTEwD$bMUt^~{$SEZHtsO?WHrk1j)#E{LX%CyNT6X5E9`Jp2<$AotZea9C?yfb-n zUH4eV{z)yo2i$B(51H6QVsgMf3O z5>5Ou+^rADZIo}=ihE12CiEuMEx0O4_yQ8z7Oc&Nxy;{OJaKFZP$GRoGMY_elrSS|%7G{1`jsEo+q38qP%ap`HK(z?&OSut+~gG!7rTh`IP) zqhE+#&|ZKJxt0xR(J+HB-7z&0=&;PN6sVDCq0Z{=1kE%<+Ni=>Bz7@VjFl%rc{uu{Qo~=)+ZzcAMqsUyr%+&me z$@6kYlZ0{C9QonXq5i?STn`C1UBBF=l-A+NTs!qO9zCD_WaIC78#wD2wXwDDYSCvs zYCURITsvG@T+NTQPlz`$P7+R3PO3J~))jHraX;W=;;|FVaJ|CiCCDVO;@al8egn3^ zG%oMYZe5Ql$z>U?9;}{pp7>z6X!_awwXtm{run8ttZ8lUaVDO{>YFGlb#up7LQW_4 zwU|BDy{cx-YR!FyO?qGY?kJ>Q{$8Wr7j&^|A4l=?Q1Ud!;K%&O;tJdfnkKm?z>_1> z{Kaae$fbIqX~wb~KiT2m!g)!NivWz&|h!!rlXwZWzR(eK^L z>l2IIyWk%Y+vUTiU0RboGc{i?N)bESry-HOG|`HMA%%wHX@y=jTD6c`B73*WX6FY^ zA~)}qodlest^}QOp3c6U1Qq#>sEmiNXsumL$j%2Yjs7@^0QPjfAvCN%tUSPWt#a|& zw>i;T7~TmyMBZmO+Bs4=3_Qp?a9b`~OIn@Z7JBgmt(ZTV4-EsCu$ssEV@^AmE$$13 zh=K^+d&bA%-f8d6-;?-#@p&dzE4nb4JjjsJiinJJh~xO-9kbCyRI%Se$3o2l)yv`+ z3oj~OD3V*g3?z3;%T|_6Ly>3*Jq?+|y&>&*C4oMSK}QmYIgNQm*3VVQs(=qAY9hbY ze((B_DTVDtG;Z9)C+?aq==VwhNE$^tL)tqq7>7y69pZoaIiDgcx`zqEAP^NK9U?3E z<=O|UBNrr}$*kg-DOc<F~;!Q_l5NZrCCOBqT%SNkU@_c)F8^fK#lk!e9#Ie6L-t%2evMOnxL*Hx^9~lxh z=SR}-hRRu*czCXbvU0D4q$Ljd56zAQS36fpbTX@hnzmivX|-Iw&+@tTX=0RQGG^MN z8Dh}ZV=sMQVmL2j0_o{VRJ+WRQ4hv?ib?u`NGr7gYbS{1vs%LEcf8^_tHor*e0=vd;RK+@Ua>D(+%aZy8mcA=S&qu1Om z)k(=oLlf`a)JXE2(%gPU*gUlWn#&K-a21tdkv?yNxCJBWqw|z>F)q|A;$4dpi^*{r za|WS@AyOgycMKO*Eom*1ZXrA*<~&x;?#2Wa1^Gl~OeRU|QhT2qDeX^_p7DMkUKty8 z9NGjg(afq;WT`1Y`NjBEY>h507ACu`M9zBcOxGmbqE7j_NxW|ReLf<3;CM6XH!gc^ zxHF}lZ9~p_vkI&Ad2B{6%sM$b&3DF$3af5*_uX`oKn7RHSp*4OdRC4nOzT2+y+>m; z(~VV3v%t9;&SL9iFJ`0tU8SK<$Bb*cWo}w$`tt*ILMO@2xNN$oN(;czmwl2BUSDp% z(>g>X5+4xN+;VRocq^Z-2MPhaJn`sUL|tB=sIHj~v(*`Ab=-791r3hfw|#!JkMs_C zQQv~r=)yHR?+!1wF2%3gCd?PMHn^~$qjQ{%WnSZKc!C$}9;a71s1TA-LE`h}>zp&Y znYApH3j=F|+@@I1olER%OJ`m;SM6r>(#Ft-)NKnRs&I4c4~3=S-nwGIEv8Otxma~H z)wzY-5)AYY&-cA}E5QV3_yUy?J{jA_+e#eAB@xd*wE?a^O=9(qRe96BPK$w+Rm4zG zL9uD{A>8rnx9sP||sU_z^#w%VALxR!7 zrHj8}z{=O>)>2w_aBzsQ--s9w;9}!(Veg7fRWh!mP)kN27-|S%bhd<5xv<4A z^36b22cq@G^7rva*o=UgR)pwqPUP*WwbtX~CWZ z$W83+t$CT4oSd8(o!A(mw#G~>JUl#1%&bhTtPC&+20Irkd!RFemEFrf7{noVAX`&w zdsCo@uOnDm|3zzM_ghRbfiXD)t(jODneUJE6A%pg3uoT3Y^vwzCH~!Z`YUwtow4r|M!2VR{X*gF4uP zAOJ@g#g~5=v$KB<`A0PWi|sJ*zniU1p!QHZ6X<^s!r#w-MPbb=W(xt@Lv2-|P>VlW zR{Dd5R7~t%tK6j2@<5QO)jevOdp>{ff`|j{Ap+zu2p*Wm8JM|Mnb~-mnRz)_=%#y%Bd*HtXhJkVX7hovZ)X3$31OpVZ{#d8MuF z?15Gwh_tu>IgC|CQ&TW6haodFkc9=pz`?=I$-r&|2zyFszfT185j=#d=W#{7L;Rdlm7?{C4Tnx+{?A#1MW-uEA8-&@Ag&W9Y z1mpt$!KP?y3iC*y#lK>`=L!tN$O2|%0dm83bMk;V7}$AOfebug2qyz8h!e~Odt?E# zL&!=0p(_TpfZEDK!7!P#{p)*aSeI)HH8Qn;efZt&-n+|Bv*ndCwS%eG<+lT>Kx}^h zvM?q6X{o$G(7iJWkb~|G0Rm(E*K5;%BgX$7!@u@BnLuDj{~O!*1!e~|vUdX7LPU&V zBLCkRAk%+3fgRBCzvmy!1Lomig~b+jHXfM3AzW+>hCCcR4D2irFb^{u7ZAwG_2>Nm zmj8b>*Z;@-{}wRF1ZZUpfyHPh^8a`u5Y);MV*4*zu?E@#VWAmfYbQW%WDB(<1zKBM zn1X=!Dr9oB0{@P9e{F`;9!mO;hx|u6V2G{hzsT-isr+Su^#4P8|D&e>Hq3uzn;Z^iBsZUK|kX;@4xe=|E)E`0z5A)1pN(4|M$YjFZ$5`w_)hs zbpBcuuPVgZUJmF2GsEA$C<-+G$1DGV`w61}vseLgmVb>{Ki}UYxqtsJR{3vtvXcIX zt*{UO(_Xg!v={c_e(z6R{$if#AL9Szct1h@rR?~#3s%eA|NL7-!5;pWVGt{rU)#cp ztal4zgm4ddPo>30RGp``Z`PbJwG#J&PxFozFCOjI4rn{|X=Sb255%+CCFUl^Bqs70 z8}05SZdmmwW!BZk<;HTV`8WX<#DNG{U!S2#NC``mK7WTqDlFPX>Wy?bM*#j*^=$c* z^t<${zH6E)(J%>`z_)wGJE@{!e2&`7YM$+y7`A_rUv^>M^%13Na^@& za=$s;chQgL0oHe709Rvg!!Z^*_CA&RA_e$U{KR8|*Z&+rzodH!X`ozPc`l`qQL31` z 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 Q;ea1`X=SN$38R4j17;<;Z2$lO diff --git a/providers/nextcloud/nextcloud_test.go b/providers/nextcloud/nextcloud_test.go deleted file mode 100644 index e17266b7e..000000000 --- a/providers/nextcloud/nextcloud_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package nextcloud_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/nextcloud" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("NEXTCLOUD_KEY")) - a.Equal(p.Secret, os.Getenv("NEXTCLOUD_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*nextcloud.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*nextcloud.Session) - a.NoError(err) - a.Contains(s.AuthURL, "/apps/oauth2/authorize?client_id=") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://nextcloud.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*nextcloud.Session) - a.Equal(s.AuthURL, "https://nextcloud.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *nextcloud.Provider { - return nextcloud.NewCustomisedDNS( - os.Getenv("NEXTCLOUD_KEY"), - os.Getenv("NEXTCLOUD_SECRET"), - "/foo", - os.Getenv("NEXTCLOUD_DNS"), - ) -} - -func urlCustomisedURLProvider() *nextcloud.Provider { - return nextcloud.NewCustomisedURL(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") -} diff --git a/providers/nextcloud/session.go b/providers/nextcloud/session.go deleted file mode 100644 index 568f3d6d4..000000000 --- a/providers/nextcloud/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package nextcloud - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Nextcloud. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Nextcloud provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Nextcloud and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/nextcloud/session_test.go b/providers/nextcloud/session_test.go deleted file mode 100644 index c53294ce8..000000000 --- a/providers/nextcloud/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package nextcloud_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/nextcloud" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &nextcloud.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &nextcloud.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &nextcloud.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &nextcloud.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/okta/okta.go b/providers/okta/okta.go deleted file mode 100644 index b871b3d38..000000000 --- a/providers/okta/okta.go +++ /dev/null @@ -1,197 +0,0 @@ -// Package okta implements the OAuth2 protocol for authenticating users through okta. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package okta - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Provider is the implementation of `goth.Provider` for accessing okta. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - issuerURL string - profileURL string -} - -// New creates a new Okta provider and sets up important connection details. -// You should always call `okta.New` to get a new provider. Never try to -// create one manually. -func New(clientID, secret, orgURL, callbackURL string, scopes ...string) *Provider { - issuerURL := orgURL + "/oauth2/default" - authURL := issuerURL + "/v1/authorize" - tokenURL := issuerURL + "/v1/token" - profileURL := issuerURL + "/v1/userinfo" - return NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientID, - Secret: secret, - CallbackURL: callbackURL, - providerName: "okta", - issuerURL: issuerURL, - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the okta package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks okta for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to okta and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - UserID: sess.UserID, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Email string `json:"email"` - FirstName string `json:"given_name"` - LastName string `json:"family_name"` - NickName string `json:"nickname"` - ID string `json:"sub"` - Locale string `json:"locale"` - ProfileURL string `json:"profile"` - Username string `json:"preferred_username"` - Zoneinfo string `json:"zoneinfo"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - rd := make(map[string]interface{}) - rd["ProfileURL"] = u.ProfileURL - rd["Locale"] = u.Locale - rd["Username"] = u.Username - rd["Zoneinfo"] = u.Zoneinfo - - user.UserID = u.ID - user.Email = u.Email - user.Name = u.Name - user.NickName = u.NickName - user.FirstName = u.FirstName - user.LastName = u.LastName - - user.RawData = rd - - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/okta/okta_test.go b/providers/okta/okta_test.go deleted file mode 100644 index 0dc430830..000000000 --- a/providers/okta/okta_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package okta_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/okta" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("OKTA_ID")) - a.Equal(p.Secret, os.Getenv("OKTA_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*okta.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*okta.Session) - a.NoError(err) - a.Contains(s.AuthURL, os.Getenv("OKTA_ORG_URL")) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"` + os.Getenv("OKTA_ORG_URL") + `/oauth2/v1/authorize", "AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*okta.Session) - a.Equal(s.AuthURL, os.Getenv("OKTA_ORG_URL")+"/oauth2/v1/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *okta.Provider { - return okta.New(os.Getenv("OKTA_ID"), os.Getenv("OKTA_SECRET"), os.Getenv("OKTA_ORG_URL"), "/foo") -} - -func urlCustomisedURLProvider() *okta.Provider { - return okta.NewCustomisedURL(os.Getenv("CLIENT_ID"), os.Getenv("CLIENT_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://issuerURL", "http://profileURL") -} diff --git a/providers/okta/session.go b/providers/okta/session.go deleted file mode 100644 index e0951fe0f..000000000 --- a/providers/okta/session.go +++ /dev/null @@ -1,64 +0,0 @@ -package okta - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Okta. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - UserID string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Okta provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Okta and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/okta/session_test.go b/providers/okta/session_test.go deleted file mode 100644 index 36f1add33..000000000 --- a/providers/okta/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package okta_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/okta" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &okta.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &okta.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &okta.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &okta.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/onedrive/onedrive.go b/providers/onedrive/onedrive.go deleted file mode 100644 index 877894f83..000000000 --- a/providers/onedrive/onedrive.go +++ /dev/null @@ -1,163 +0,0 @@ -// Package onedrive implements the OAuth2 protocol for authenticating users through onedrive. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package onedrive - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://login.live.com/oauth20_authorize.srf" - tokenURL string = "https://login.live.com/oauth20_token.srf" - endpointProfile string = "https://apis.live.net/v5.0/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Onedrive. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Onedrive provider and sets up important connection details. -// You should always call `onedrive.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "onedrive", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the onedrive package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Onedrive for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Onedrive and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "wl.signin", "wl.emails", "wl.offline_access") - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Email map[string]string `json:"emails"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email["account"] - user.Name = u.Name - user.NickName = u.Name - user.UserID = u.Email["account"] // onedrive doesn't provide separate user_id - - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/onedrive/onedrive_test.go b/providers/onedrive/onedrive_test.go deleted file mode 100644 index 68c992e06..000000000 --- a/providers/onedrive/onedrive_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package onedrive_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/onedrive" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("ONEDRIVE_KEY")) - a.Equal(p.Secret, os.Getenv("ONEDRIVE_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*onedrive.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.live.com/oauth20_authorize.srf") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://login.live.com/oauth20_authorize.srf","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*onedrive.Session) - a.Equal(s.AuthURL, "https://login.live.com/oauth20_authorize.srf") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *onedrive.Provider { - return onedrive.New(os.Getenv("ONEDRIVE_KEY"), os.Getenv("ONEDRIVE_SECRET"), "/foo") -} diff --git a/providers/onedrive/session.go b/providers/onedrive/session.go deleted file mode 100644 index fec401d4c..000000000 --- a/providers/onedrive/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package onedrive - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Onedrive. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Onedrive provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Onedrive and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/onedrive/session_test.go b/providers/onedrive/session_test.go deleted file mode 100644 index 344377a25..000000000 --- a/providers/onedrive/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package onedrive_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/onedrive" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &onedrive.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &onedrive.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &onedrive.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &onedrive.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/openidConnect/openidConnect.go b/providers/openidConnect/openidConnect.go deleted file mode 100644 index dea40d2dc..000000000 --- a/providers/openidConnect/openidConnect.go +++ /dev/null @@ -1,529 +0,0 @@ -package openidConnect - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - // Standard Claims http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - // fixed, cannot be changed - subjectClaim = "sub" - expiryClaim = "exp" - audienceClaim = "aud" - issuerClaim = "iss" - - PreferredUsernameClaim = "preferred_username" - EmailClaim = "email" - NameClaim = "name" - NicknameClaim = "nickname" - PictureClaim = "picture" - GivenNameClaim = "given_name" - FamilyNameClaim = "family_name" - AddressClaim = "address" - - // Unused but available to set in Provider claims - MiddleNameClaim = "middle_name" - ProfileClaim = "profile" - WebsiteClaim = "website" - EmailVerifiedClaim = "email_verified" - GenderClaim = "gender" - BirthdateClaim = "birthdate" - ZoneinfoClaim = "zoneinfo" - LocaleClaim = "locale" - PhoneNumberClaim = "phone_number" - PhoneNumberVerifiedClaim = "phone_number_verified" - UpdatedAtClaim = "updated_at" - - clockSkew = 10 * time.Second -) - -// Provider is the implementation of `goth.Provider` for accessing OpenID Connect provider -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - OpenIDConfig *OpenIDConfig - config *oauth2.Config - authCodeOptions []oauth2.AuthCodeOption - providerName string - - UserIdClaims []string - NameClaims []string - NickNameClaims []string - EmailClaims []string - AvatarURLClaims []string - FirstNameClaims []string - LastNameClaims []string - LocationClaims []string - - SkipUserInfoRequest bool -} - -type OpenIDConfig struct { - AuthEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"userinfo_endpoint"` - - // If OpenID discovery is enabled, the end_session_endpoint field can optionally be provided - // in the discovery endpoint response according to OpenID spec. See: - // https://openid.net/specs/openid-connect-session-1_0-17.html#OPMetadata - EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` - Issuer string `json:"issuer"` -} - -type RefreshTokenResponse struct { - AccessToken string `json:"access_token"` - - // The OpenID spec defines the ID token as an optional response field in the - // refresh token flow. As a result, a new ID token may not be returned in a successful - // response. - // See more: https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken - IdToken string `json:"id_token,omitempty"` - - // The OAuth spec defines the refresh token as an optional response field in the - // refresh token flow. As a result, a new refresh token may not be returned in a successful - // response. - // See more: https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/ - RefreshToken string `json:"refresh_token,omitempty"` -} - -// New creates a new OpenID Connect provider, and sets up important connection details. -// You should always call `openidConnect.New` to get a new Provider. Never try to create -// one manually. -// New returns an implementation of an OpenID Connect Authorization Code Flow -// See http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth -// ID Token decryption is not (yet) supported -// UserInfo decryption is not (yet) supported -func New(clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes ...string) (*Provider, error) { - return NewNamed("", clientKey, secret, callbackURL, openIDAutoDiscoveryURL, scopes...) -} - -// NewNamed is similar to New(...) but can be used to set a custom name for the -// provider in order to use multiple OIDC providers -func NewNamed(name, clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes ...string) (*Provider, error) { - switch len(name) { - case 0: - name = "openid-connect" - default: - name = fmt.Sprintf("%s-oidc", strings.ToLower(name)) - } - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - - UserIdClaims: []string{subjectClaim}, - NameClaims: []string{NameClaim}, - NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim}, - EmailClaims: []string{EmailClaim}, - AvatarURLClaims: []string{PictureClaim}, - FirstNameClaims: []string{GivenNameClaim}, - LastNameClaims: []string{FamilyNameClaim}, - LocationClaims: []string{AddressClaim}, - - providerName: name, - } - - openIDConfig, err := getOpenIDConfig(p, openIDAutoDiscoveryURL) - if err != nil { - return nil, err - } - p.OpenIDConfig = openIDConfig - - p.config = newConfig(p, scopes, openIDConfig) - return p, nil -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs hence omit the auto-discovery step -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, issuerURL, userInfoURL, endSessionEndpointURL string, scopes ...string) (*Provider, error) { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - OpenIDConfig: &OpenIDConfig{ - AuthEndpoint: authURL, - TokenEndpoint: tokenURL, - Issuer: issuerURL, - UserInfoEndpoint: userInfoURL, - EndSessionEndpoint: endSessionEndpointURL, - }, - - UserIdClaims: []string{subjectClaim}, - NameClaims: []string{NameClaim}, - NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim}, - EmailClaims: []string{EmailClaim}, - AvatarURLClaims: []string{PictureClaim}, - FirstNameClaims: []string{GivenNameClaim}, - LastNameClaims: []string{FamilyNameClaim}, - LocationClaims: []string{AddressClaim}, - - providerName: "openid-connect", - } - - p.config = newConfig(p, scopes, p.OpenIDConfig) - return p, nil -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// SetAuthCodeOptions sets additional parameters for the authentication URL. -// It takes a map of string key-value pairs and appends them to the provider's authCodeOptions. -func (p *Provider) SetAuthCodeOptions(params map[string]string) { - for k, v := range params { - p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam(k, v)) - } -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the openidConnect package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks the OpenID Connect provider for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state, p.authCodeOptions...) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will use the id_token and access requested information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - - expiresAt := sess.ExpiresAt - - if sess.IDToken == "" { - return goth.User{}, fmt.Errorf("%s cannot get user information without id_token", p.providerName) - } - - // decode returned id token to get expiry - claims, err := decodeJWT(sess.IDToken) - - if err != nil { - return goth.User{}, fmt.Errorf("oauth2: error decoding JWT token: %v", err) - } - - expiry, err := p.validateClaims(claims) - if err != nil { - return goth.User{}, fmt.Errorf("oauth2: error validating JWT token: %v", err) - } - - if expiry.Before(expiresAt) { - expiresAt = expiry - } - - if err := p.getUserInfo(sess.AccessToken, claims); err != nil { - return goth.User{}, err - } - - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: expiresAt, - RawData: claims, - IDToken: sess.IDToken, - } - - p.userFromClaims(claims, &user) - return user, err -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(oauth2.NoContext, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -// The ID token is a fundamental part of the OpenID connect refresh token flow but is not part of the OAuth flow. -// The existing RefreshToken function leverages the OAuth library's refresh token mechanism, ignoring the refreshed -// ID token. As a result, a new function needs to be exposed (rather than changing the existing function, for backwards -// compatibility purposes) that also returns the id_token in the OpenID refresh token flow API response -// Learn more about ID tokens: https://openid.net/specs/openid-connect-core-1_0.html#IDToken -func (p *Provider) RefreshTokenWithIDToken(refreshToken string) (*RefreshTokenResponse, error) { - urlValues := url.Values{ - "grant_type": {"refresh_token"}, - "refresh_token": {refreshToken}, - "client_id": {p.ClientKey}, - "client_secret": {p.Secret}, - } - req, err := http.NewRequest("POST", p.OpenIDConfig.TokenEndpoint, strings.NewReader(urlValues.Encode())) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := p.Client().Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Non-200 response from RefreshToken: %d, WWW-Authenticate=%s", resp.StatusCode, resp.Header.Get("WWW-Authenticate")) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body.Close() - - refreshTokenResponse := &RefreshTokenResponse{} - - err = json.Unmarshal(body, refreshTokenResponse) - if err != nil { - return nil, err - } - - return refreshTokenResponse, nil -} - -// validate according to standard, returns expiry -// http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation -func (p *Provider) validateClaims(claims map[string]interface{}) (time.Time, error) { - audience := getClaimValue(claims, []string{audienceClaim}) - if audience != p.ClientKey { - found := false - audiences := getClaimValues(claims, []string{audienceClaim}) - for _, aud := range audiences { - if aud == p.ClientKey { - found = true - break - } - } - if !found { - return time.Time{}, errors.New("audience in token does not match client key") - } - } - - issuer := getClaimValue(claims, []string{issuerClaim}) - if issuer != p.OpenIDConfig.Issuer { - return time.Time{}, errors.New("issuer in token does not match issuer in OpenIDConfig discovery") - } - - // expiry is required for JWT, not for UserInfoResponse - // is actually a int64, so force it in to that type - expiryClaim := int64(claims[expiryClaim].(float64)) - expiry := time.Unix(expiryClaim, 0) - if expiry.Add(clockSkew).Before(time.Now()) { - return time.Time{}, errors.New("user info JWT token is expired") - } - return expiry, nil -} - -func (p *Provider) userFromClaims(claims map[string]interface{}, user *goth.User) { - // required - user.UserID = getClaimValue(claims, p.UserIdClaims) - - user.Name = getClaimValue(claims, p.NameClaims) - user.NickName = getClaimValue(claims, p.NickNameClaims) - user.Email = getClaimValue(claims, p.EmailClaims) - user.AvatarURL = getClaimValue(claims, p.AvatarURLClaims) - user.FirstName = getClaimValue(claims, p.FirstNameClaims) - user.LastName = getClaimValue(claims, p.LastNameClaims) - user.Location = getClaimValue(claims, p.LocationClaims) -} - -func (p *Provider) getUserInfo(accessToken string, claims map[string]interface{}) error { - // skip if there is no UserInfoEndpoint or is explicitly disabled - if p.OpenIDConfig.UserInfoEndpoint == "" || p.SkipUserInfoRequest { - return nil - } - - userInfoClaims, err := p.fetchUserInfo(p.OpenIDConfig.UserInfoEndpoint, accessToken) - if err != nil { - return err - } - - // The sub (subject) Claim MUST always be returned in the UserInfo Response. - // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - userInfoSubject := getClaimValue(userInfoClaims, []string{subjectClaim}) - if userInfoSubject == "" { - return fmt.Errorf("userinfo response did not contain a 'sub' claim: %#v", userInfoClaims) - } - - // The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; - // if they do not match, the UserInfo Response values MUST NOT be used. - // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - subject := getClaimValue(claims, []string{subjectClaim}) - if userInfoSubject != subject { - return fmt.Errorf("userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)", userInfoSubject, subject) - } - - // Merge in userinfo claims in case id_token claims contained some that userinfo did not - for k, v := range userInfoClaims { - claims[k] = v - } - - return nil -} - -// fetch and decode JSON from the given UserInfo URL -func (p *Provider) fetchUserInfo(url, accessToken string) (map[string]interface{}, error) { - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - - resp, err := p.Client().Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Non-200 response from UserInfo: %d, WWW-Authenticate=%s", resp.StatusCode, resp.Header.Get("WWW-Authenticate")) - } - - // The UserInfo Claims MUST be returned as the members of a JSON object - // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - return unMarshal(data) -} - -func getOpenIDConfig(p *Provider, openIDAutoDiscoveryURL string) (*OpenIDConfig, error) { - res, err := p.Client().Get(openIDAutoDiscoveryURL) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - return nil, fmt.Errorf("Non-success code for Discovery URL: %d", res.StatusCode) - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - openIDConfig := &OpenIDConfig{} - err = json.Unmarshal(body, openIDConfig) - if err != nil { - return nil, err - } - - return openIDConfig, nil -} - -func newConfig(provider *Provider, scopes []string, openIDConfig *OpenIDConfig) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: openIDConfig.AuthEndpoint, - TokenURL: openIDConfig.TokenEndpoint, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - foundOpenIDScope := false - - for _, scope := range scopes { - if scope == "openid" { - foundOpenIDScope = true - } - c.Scopes = append(c.Scopes, scope) - } - - if !foundOpenIDScope { - c.Scopes = append(c.Scopes, "openid") - } - } else { - c.Scopes = []string{"openid"} - } - - return c -} - -func getClaimValue(data map[string]interface{}, claims []string) string { - for _, claim := range claims { - if value, ok := data[claim]; ok { - if stringValue, ok := value.(string); ok && len(stringValue) > 0 { - return stringValue - } - } - } - - return "" -} - -func getClaimValues(data map[string]interface{}, claims []string) []string { - var result []string - - for _, claim := range claims { - if value, ok := data[claim]; ok { - if stringValues, ok := value.([]interface{}); ok { - for _, stringValue := range stringValues { - if s, ok := stringValue.(string); ok && len(s) > 0 { - result = append(result, s) - } - } - } - } - } - - return result -} - -// decodeJWT decodes a JSON Web Token into a simple map -// http://openid.net/specs/draft-jones-json-web-token-07.html -func decodeJWT(jwt string) (map[string]interface{}, error) { - jwtParts := strings.Split(jwt, ".") - if len(jwtParts) != 3 { - return nil, errors.New("jws: invalid token received, not all parts available") - } - - decodedPayload, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[1]) - - if err != nil { - return nil, err - } - - return unMarshal(decodedPayload) -} - -func unMarshal(payload []byte) (map[string]interface{}, error) { - data := make(map[string]interface{}) - - return data, json.NewDecoder(bytes.NewBuffer(payload)).Decode(&data) -} diff --git a/providers/openidConnect/openidConnect_test.go b/providers/openidConnect/openidConnect_test.go deleted file mode 100644 index 7dd76e04f..000000000 --- a/providers/openidConnect/openidConnect_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package openidConnect - -import ( - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -var ( - server *httptest.Server -) - -func init() { - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // return the value of Google's setup at https://accounts.google.com/.well-known/openid-configuration - fmt.Fprintln(w, "{ \"issuer\": \"https://accounts.google.com\", \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\", \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\", \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\", \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\", \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\", \"response_types_supported\": [ \"code\", \"token\", \"id_token\", \"code token\", \"code id_token\", \"token id_token\", \"code token id_token\", \"none\" ], \"subject_types_supported\": [ \"public\" ], \"id_token_signing_alg_values_supported\": [ \"RS256\" ], \"scopes_supported\": [ \"openid\", \"email\", \"profile\" ], \"token_endpoint_auth_methods_supported\": [ \"client_secret_post\", \"client_secret_basic\" ], \"claims_supported\": [ \"aud\", \"email\", \"email_verified\", \"exp\", \"family_name\", \"given_name\", \"iat\", \"iss\", \"locale\", \"name\", \"picture\", \"sub\" ], \"code_challenge_methods_supported\": [ \"plain\", \"S256\" ] }") - })) -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := openidConnectProvider() - a.Equal(os.Getenv("OPENID_CONNECT_KEY"), provider.ClientKey) - a.Equal(os.Getenv("OPENID_CONNECT_SECRET"), provider.Secret) - a.Equal("http://localhost/foo", provider.CallbackURL) - - a.Equal("https://accounts.google.com", provider.OpenIDConfig.Issuer) - a.Equal("https://accounts.google.com/o/oauth2/v2/auth", provider.OpenIDConfig.AuthEndpoint) - a.Equal("https://www.googleapis.com/oauth2/v4/token", provider.OpenIDConfig.TokenEndpoint) - a.Equal("https://www.googleapis.com/oauth2/v3/userinfo", provider.OpenIDConfig.UserInfoEndpoint) -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider, _ := NewCustomisedURL( - os.Getenv("OPENID_CONNECT_KEY"), - os.Getenv("OPENID_CONNECT_SECRET"), - "http://localhost/foo", - "https://accounts.google.com/o/oauth2/v2/auth", - "https://www.googleapis.com/oauth2/v4/token", - "https://accounts.google.com", - "https://www.googleapis.com/oauth2/v3/userinfo", - "", - server.URL, - ) - a.Equal(os.Getenv("OPENID_CONNECT_KEY"), provider.ClientKey) - a.Equal(os.Getenv("OPENID_CONNECT_SECRET"), provider.Secret) - a.Equal("http://localhost/foo", provider.CallbackURL) - - a.Equal("https://accounts.google.com", provider.OpenIDConfig.Issuer) - a.Equal("https://accounts.google.com/o/oauth2/v2/auth", provider.OpenIDConfig.AuthEndpoint) - a.Equal("https://www.googleapis.com/oauth2/v4/token", provider.OpenIDConfig.TokenEndpoint) - a.Equal("https://www.googleapis.com/oauth2/v3/userinfo", provider.OpenIDConfig.UserInfoEndpoint) - a.Equal("", provider.OpenIDConfig.EndSessionEndpoint) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := openidConnectProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://accounts.google.com/o/oauth2/v2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("OPENID_CONNECT_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "redirect_uri=http%3A%2F%2Flocalhost%2Ffoo") - a.Contains(s.AuthURL, "scope=openid") -} - -func Test_BeginAuth_AuthCodeOptions(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := openidConnectProvider() - provider.SetAuthCodeOptions(map[string]string{"domain_hint": "test_domain.com", "prompt": "none"}) - session, err := provider.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://accounts.google.com/o/oauth2/v2/auth") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("OPENID_CONNECT_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "redirect_uri=http%3A%2F%2Flocalhost%2Ffoo") - a.Contains(s.AuthURL, "scope=openid") - a.Contains(s.AuthURL, "domain_hint=test_domain.com") - a.Contains(s.AuthURL, "prompt=none") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), openidConnectProvider()) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := openidConnectProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://accounts.google.com/o/oauth2/v2/auth","AccessToken":"1234567890","IDToken":"abc"}`) - a.NoError(err) - session := s.(*Session) - a.Equal("https://accounts.google.com/o/oauth2/v2/auth", session.AuthURL) - a.Equal("1234567890", session.AccessToken) - a.Equal("abc", session.IDToken) -} - -func openidConnectProvider() *Provider { - provider, _ := New(os.Getenv("OPENID_CONNECT_KEY"), os.Getenv("OPENID_CONNECT_SECRET"), "http://localhost/foo", server.URL) - return provider -} diff --git a/providers/openidConnect/session.go b/providers/openidConnect/session.go deleted file mode 100644 index 84b577c39..000000000 --- a/providers/openidConnect/session.go +++ /dev/null @@ -1,81 +0,0 @@ -package openidConnect - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with the OpenID Connect provider. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - IDToken string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the OpenID Connect provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New("an AuthURL has not be set") - } - return s.AuthURL, nil -} - -// Authorize the session with the OpenID Connect provider and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - - var authParams []oauth2.AuthCodeOption - - // override redirect_uri if passed as param - redirectURL := params.Get("redirect_uri") - if redirectURL != "" { - authParams = append(authParams, oauth2.SetAuthURLParam("redirect_uri", redirectURL)) - } - - // set code_verifier if passed as param - codeVerifier := params.Get("code_verifier") - if codeVerifier != "" { - authParams = append(authParams, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) - } - - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"), authParams...) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - if idToken := token.Extra("id_token"); idToken != nil { - s.IDToken = idToken.(string) - } - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/openidConnect/session_test.go b/providers/openidConnect/session_test.go deleted file mode 100644 index 29f6b54b1..000000000 --- a/providers/openidConnect/session_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package openidConnect - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/oura/errors.go b/providers/oura/errors.go deleted file mode 100644 index 596c33676..000000000 --- a/providers/oura/errors.go +++ /dev/null @@ -1,16 +0,0 @@ -package oura - -// APIError describes an error from the Oura API -type APIError struct { - Code int - Description string -} - -// NewAPIError initializes an Oura APIError -func NewAPIError(code int, description string) APIError { - return APIError{code, description} -} - -func (e APIError) Error() string { - return e.Description -} diff --git a/providers/oura/oura.go b/providers/oura/oura.go deleted file mode 100644 index a62e89470..000000000 --- a/providers/oura/oura.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package oura implements the OAuth protocol for authenticating users through Oura API (for OuraRing). -package oura - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://cloud.ouraring.com/oauth/authorize" - tokenURL string = "https://api.ouraring.com/oauth/token" - endpointProfile string = "https://api.ouraring.com/v1/userinfo" -) - -const ( - // ScopeEmail includes email address of the user - ScopeEmail = "email" - // ScopePersonal includes personal information (gender, age, height, weight) - ScopePersonal = "personal" - // ScopeDaily includes daily summaries of sleep, activity and readiness - ScopeDaily = "daily" -) - -// New creates a new Oura provider (for OuraRing), and sets up important connection details. -// You should always call `oura.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "oura", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Oura API. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client for making requests on the provider -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the oura package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Oura for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Oura and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - UserID: s.UserID, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, NewAPIError(resp.StatusCode, fmt.Sprintf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode)) - } - - // err = userFromReader(io.TeeReader(resp.Body, os.Stdout), &user) - err = userFromReader(resp.Body, &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - Age int `json:"age"` - Weight float32 `json:"weight"` // kg - Height int `json:"height"` // cm - Gender string `json:"gender"` - Email string `json:"email"` - UserID string `json:"user_id"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - rawData := make(map[string]interface{}) - - if u.Age != 0 { - rawData["age"] = u.Age - } - if u.Weight != 0 { - rawData["weight"] = u.Weight - } - if u.Height != 0 { - rawData["height"] = u.Height - } - if u.Gender != "" { - rawData["gender"] = u.Gender - } - - user.UserID = u.UserID - user.Email = u.Email - if len(rawData) > 0 { - user.RawData = rawData - } - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - - return c -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(oauth2.NoContext, token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -// RefreshTokenAvailable refresh token is not provided by oura -func (p *Provider) RefreshTokenAvailable() bool { - return true -} diff --git a/providers/oura/oura_test.go b/providers/oura/oura_test.go deleted file mode 100644 index 09b7b48a5..000000000 --- a/providers/oura/oura_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package oura_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/oura" - "github.com/stretchr/testify/assert" -) - -func provider() *oura.Provider { - return oura.New(os.Getenv("OURA_KEY"), os.Getenv("OURA_SECRET"), "/foo", "user") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("OURA_KEY")) - a.Equal(p.Secret, os.Getenv("OURA_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_ImplementsProvider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*oura.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://cloud.ouraring.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://cloud.ouraring.com/oauth/authorize","AccessToken":"1234567890","UserID":"abc"}`) - a.NoError(err) - - s := session.(*oura.Session) - a.Equal(s.AuthURL, "https://cloud.ouraring.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") - a.Equal(s.UserID, "abc") -} diff --git a/providers/oura/session.go b/providers/oura/session.go deleted file mode 100644 index b164293bf..000000000 --- a/providers/oura/session.go +++ /dev/null @@ -1,64 +0,0 @@ -package oura - -import ( - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Oura. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - UserID string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the -// Oura provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize completes the authorization with Oura and returns the access -// token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) - if err != nil { - return "", err - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - if userID, ok := token.Extra("user_id").(string); ok { - s.UserID = userID - } - return token.AccessToken, err -} - -// Marshal marshals a session into a JSON string. -func (s Session) Marshal() string { - j, _ := json.Marshal(s) - return string(j) -} - -// String is equivalent to Marshal. It returns a JSON representation of the session. -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := Session{} - err := json.Unmarshal([]byte(data), &s) - return &s, err -} diff --git a/providers/oura/session_test.go b/providers/oura/session_test.go deleted file mode 100644 index 2b986463e..000000000 --- a/providers/oura/session_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package oura_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/oura" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &oura.Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &oura.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &oura.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) -} diff --git a/providers/patreon/patreon.go b/providers/patreon/patreon.go deleted file mode 100644 index b960aa242..000000000 --- a/providers/patreon/patreon.go +++ /dev/null @@ -1,219 +0,0 @@ -package patreon - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - // AuthorizationURL specifies Patreon's OAuth2 authorization endpoint (see https://tools.ietf.org/html/rfc6749#section-3.1). - // See Example_refreshToken for examples. - authorizationURL = "https://www.patreon.com/oauth2/authorize" - - // AccessTokenURL specifies Patreon's OAuth2 token endpoint (see https://tools.ietf.org/html/rfc6749#section-3.2). - // See Example_refreshToken for examples. - tokenURL = "https://www.patreon.com/api/oauth2/token" - - profileURL = "https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=created,email,full_name,image_url,vanity" -) - -//goland:noinspection GoUnusedConst -const ( - // ScopeIdentity provides read access to data about the user. See the /identity endpoint documentation for details about what data is available. - ScopeIdentity = "identity" - - // ScopeIdentityEmail provides read access to the user’s email. - ScopeIdentityEmail = "identity[email]" - - // ScopeIdentityMemberships provides read access to the user’s memberships. - ScopeIdentityMemberships = "identity.memberships" - - // ScopeCampaigns provides read access to basic campaign data. See the /campaign endpoint documentation for details about what data is available. - ScopeCampaigns = "campaigns" - - // ScopeCampaignsWebhook provides read, write, update, and delete access to the campaign’s webhooks created by the client. - ScopeCampaignsWebhook = "w:campaigns.webhook" - - // ScopeCampaignsMembers provides read access to data about a campaign’s members. See the /members endpoint documentation for details about what data is available. Also allows the same information to be sent via webhooks created by your client. - ScopeCampaignsMembers = "campaigns.members" - - // ScopeCampaignsMembersEmail provides read access to the member’s email. Also allows the same information to be sent via webhooks created by your client. - ScopeCampaignsMembersEmail = "campaigns.members[email]" - - // ScopeCampaignsMembersAddress provides read access to the member’s address, if an address was collected in the pledge flow. Also allows the same information to be sent via webhooks created by your client. - ScopeCampaignsMembersAddress = "campaigns.members.address" - - // ScopeCampaignsPosts provides read access to the posts on a campaign. - ScopeCampaignsPosts = "campaigns.posts" -) - -// New creates a new Patreon provider and sets up important connection details. -// You should always call `patreon.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - return NewCustomisedURL(clientKey, secret, callbackURL, authorizationURL, tokenURL, profileURL, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "patreon", - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Patreon. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - authURL string - tokenURL string - profileURL string -} - -// Name gets the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the Patreon package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Patreon for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Patreon and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", p.profileURL, nil) - if err != nil { - return user, err - } - - req.Header.Add("authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Data struct { - Attributes struct { - Created time.Time `json:"created"` - Email string `json:"email"` - FullName string `json:"full_name"` - ImageURL string `json:"image_url"` - Vanity string `json:"vanity"` - } `json:"attributes"` - ID string `json:"id"` - } `json:"data"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Data.Attributes.Email - user.Name = u.Data.Attributes.FullName - user.NickName = u.Data.Attributes.Vanity - user.UserID = u.Data.ID - user.AvatarURL = u.Data.Attributes.ImageURL - return nil -} diff --git a/providers/patreon/patreon_test.go b/providers/patreon/patreon_test.go deleted file mode 100644 index a2ec13d3b..000000000 --- a/providers/patreon/patreon_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package patreon - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func provider() *Provider { - return New(os.Getenv("PATREON_KEY"), os.Getenv("PATREON_SECRET"), "/foo", "user") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("PATREON_KEY")) - a.Equal(p.Secret, os.Getenv("PATREON_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_ImplementsProvider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.patreon.com/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"http://www.patreon.com/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*Session) - a.Equal(s.AuthURL, "http://www.patreon.com/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} diff --git a/providers/patreon/session.go b/providers/patreon/session.go deleted file mode 100644 index 7e5f22f03..000000000 --- a/providers/patreon/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package patreon - -import ( - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Patreon. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the -// Patreon provider. -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize completes the authorization with Patreon and returns the access -// token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal marshals a session into a JSON string. -func (s *Session) Marshal() string { - j, _ := json.Marshal(s) - return string(j) -} - -// String is equivalent to Marshal. It returns a JSON representation of the session. -func (s *Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := Session{} - err := json.Unmarshal([]byte(data), &s) - return &s, err -} diff --git a/providers/patreon/session_test.go b/providers/patreon/session_test.go deleted file mode 100644 index 7b2e7a4e9..000000000 --- a/providers/patreon/session_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package patreon - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} diff --git a/providers/paypal/paypal.go b/providers/paypal/paypal.go deleted file mode 100644 index 64579f6dc..000000000 --- a/providers/paypal/paypal.go +++ /dev/null @@ -1,199 +0,0 @@ -// Package paypal implements the OAuth2 protocol for authenticating users through paypal. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package paypal - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - sandbox string = "sandbox" - envKey string = "PAYPAL_ENV" - - // Endpoints for paypal sandbox env - authURLSandbox string = "https://www.sandbox.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize" - tokenURLSandbox string = "https://www.sandbox.paypal.com/webapps/auth/protocol/openidconnect/v1/tokenservice" - endpointProfileSandbox string = "https://www.sandbox.paypal.com/webapps/auth/protocol/openidconnect/v1/userinfo" - - // Endpoints for paypal production env - authURLProduction string = "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize" - tokenURLProduction string = "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/tokenservice" - endpointProfileProduction string = "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/userinfo" -) - -// Provider is the implementation of `goth.Provider` for accessing Paypal. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - profileURL string -} - -// New creates a new Paypal provider and sets up important connection details. -// You should always call `paypal.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - paypalEnv := os.Getenv(envKey) - - authURL := authURLProduction - tokenURL := tokenURLProduction - profileEndPoint := endpointProfileProduction - - if paypalEnv == sandbox { - authURL = authURLSandbox - tokenURL = tokenURLSandbox - profileEndPoint = endpointProfileSandbox - } - - return NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileEndPoint, scopes...) -} - -// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to -func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "paypal", - profileURL: profileURL, - } - p.config = newConfig(p, authURL, tokenURL, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the paypal package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Paypal for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Paypal and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(p.profileURL + "?schema=openid&access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "profile", "email") - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"name"` - Address struct { - Locality string `json:"locality"` - } `json:"address"` - Email string `json:"email"` - ID string `json:"user_id"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.UserID = u.ID - user.Location = u.Address.Locality - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/paypal/paypal_test.go b/providers/paypal/paypal_test.go deleted file mode 100644 index f7e9f99be..000000000 --- a/providers/paypal/paypal_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package paypal_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/paypal" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("PAYPAL_KEY")) - a.Equal(p.Secret, os.Getenv("PAYPAL_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_NewCustomisedURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := urlCustomisedURLProvider() - session, err := p.BeginAuth("test_state") - s := session.(*paypal.Session) - a.NoError(err) - a.Contains(s.AuthURL, "http://authURL") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*paypal.Session) - a.NoError(err) - a.Contains(s.AuthURL, "paypal.com/webapps/auth/protocol/openidconnect/v1/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*paypal.Session) - a.Equal(s.AuthURL, "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *paypal.Provider { - return paypal.New(os.Getenv("PAYPAL_KEY"), os.Getenv("PAYPAL_SECRET"), "/foo") -} - -func urlCustomisedURLProvider() *paypal.Provider { - return paypal.NewCustomisedURL(os.Getenv("PAYPAL_KEY"), os.Getenv("PAYPAL_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") -} diff --git a/providers/paypal/session.go b/providers/paypal/session.go deleted file mode 100644 index 0e099b3f2..000000000 --- a/providers/paypal/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package paypal - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with PayPal. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the PayPal provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with PayPal and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/paypal/session_test.go b/providers/paypal/session_test.go deleted file mode 100644 index e8b597591..000000000 --- a/providers/paypal/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package paypal_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/paypal" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &paypal.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &paypal.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &paypal.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &paypal.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/reddit/reddit.go b/providers/reddit/reddit.go deleted file mode 100644 index f3d328599..000000000 --- a/providers/reddit/reddit.go +++ /dev/null @@ -1,137 +0,0 @@ -package reddit - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL = "https://www.reddit.com/api/v1/authorize" -) - -type Provider struct { - providerName string - duration string - config oauth2.Config - client http.Client - // TODO: userURL should be a constant - userURL string -} - -func New(clientID string, clientSecret string, redirectURI string, duration string, tokenEndpoint string, userURL string, scopes ...string) Provider { - return Provider{ - providerName: "reddit", - duration: duration, - config: oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenEndpoint, - AuthStyle: 0, - }, - RedirectURL: redirectURI, - Scopes: scopes, - }, - client: http.Client{}, - userURL: userURL, - } -} - -func (p *Provider) Name() string { - return p.providerName -} - -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) UnmarshalSession(s string) (goth.Session, error) { - session := &Session{} - err := json.Unmarshal([]byte(s), session) - if err != nil { - return nil, err - } - - return session, nil -} - -func (p *Provider) Debug(b bool) {} - -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} - -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - authCodeOption := oauth2.SetAuthURLParam("duration", p.duration) - return &Session{AuthURL: p.config.AuthCodeURL(state, authCodeOption)}, nil -} - -type redditResponse struct { - Id string `json:"id"` - Name string `json:"name"` -} - -func (p *Provider) FetchUser(s goth.Session) (goth.User, error) { - session := s.(*Session) - request, err := http.NewRequest("GET", p.userURL, nil) - if err != nil { - return goth.User{}, err - } - - bearer := "Bearer " + session.AccessToken - request.Header.Add("Authorization", bearer) - - res, err := p.client.Do(request) - if err != nil { - return goth.User{}, err - } - - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - if res.StatusCode == http.StatusForbidden { - return goth.User{}, fmt.Errorf("%s responded with a %s because you did not provide the identity scope which is required to fetch user profile", p.providerName, res.Status) - } - return goth.User{}, fmt.Errorf("%s responded with a %d trying to fetch user profile", p.providerName, res.StatusCode) - } - - bits, err := io.ReadAll(res.Body) - if err != nil { - return goth.User{}, err - } - - var r redditResponse - - err = json.Unmarshal(bits, &r) - if err != nil { - return goth.User{}, err - } - - gothUser := goth.User{ - RawData: nil, - Provider: p.Name(), - Name: r.Name, - UserID: r.Id, - AccessToken: session.AccessToken, - RefreshToken: session.RefreshToken, - ExpiresAt: time.Time{}, - } - - err = json.Unmarshal(bits, &gothUser.RawData) - if err != nil { - return goth.User{}, err - } - - return gothUser, nil -} diff --git a/providers/reddit/reddit_test.go b/providers/reddit/reddit_test.go deleted file mode 100644 index 5b57d5e41..000000000 --- a/providers/reddit/reddit_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package reddit - -import ( - "encoding/json" - "github.com/markbates/goth" - "golang.org/x/oauth2" - "net/http" - "net/http/httptest" - "reflect" - "testing" - "time" -) - -var response = redditResponse{ - Id: "invader21", - Name: "JohnDoe", -} - -func TestProvider(t *testing.T) { - t.Run("create a new provider", func(t *testing.T) { - got := New("client id", "client secret", "redirect uri", "duration", "example.com", "userURL", "scope1", "scope2", "scope 3") - want := Provider{ - providerName: "reddit", - duration: "duration", - config: oauth2.Config{ - ClientID: "client id", - ClientSecret: "client secret", - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: "example.com", - AuthStyle: 0, - }, - RedirectURL: "redirect uri", - Scopes: []string{"scope1", "scope2", "scope 3"}, - }, - userURL: "userURL", - } - - if !reflect.DeepEqual(got, want) { - t.Errorf("\033[31;1;4mgot\033[0m %+v, \n\t \033[31;1;4mwant\033[0m %+v", got, want) - } - }) - - t.Run("fetch reddit user that created the given session", func(t *testing.T) { - redditServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - b, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - writer.Header().Add("Content-Type", "application/json") - writer.Write(b) - })) - - defer redditServer.Close() - - userURL := redditServer.URL - p := New("client id", "client secret", "redirect uri", "duration", "example.com", userURL, "scope1", "scope2", "scope 3") - s := &Session{ - AuthURL: "", - AccessToken: "i am a token", - TokenType: "bearer", - RefreshToken: "your refresh token", - Expiry: time.Time{}, - } - - got, err := p.FetchUser(s) - if err != nil { - t.Errorf("did not expect an error: %s", err) - } - - want := goth.User{ - RawData: map[string]interface{}{ - "id": "invader21", - "name": "JohnDoe", - }, - Provider: "reddit", - Name: "JohnDoe", - UserID: "invader21", - AccessToken: "i am a token", - RefreshToken: "your refresh token", - ExpiresAt: time.Time{}, - } - - if !reflect.DeepEqual(got, want) { - t.Errorf("\033[31;1;4mgot\033[0m %+v, \n\t\t \033[31;1;4mwant\033[0m %+v", got, want) - } - }) -} diff --git a/providers/reddit/session.go b/providers/reddit/session.go deleted file mode 100644 index 1d646992f..000000000 --- a/providers/reddit/session.go +++ /dev/null @@ -1,46 +0,0 @@ -package reddit - -import ( - "context" - "encoding/json" - "errors" - "github.com/markbates/goth" - "golang.org/x/oauth2" - "time" -) - -type Session struct { - AuthURL string - AccessToken string `json:"access_token"` - TokenType string `json:"token_type,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - Expiry time.Time `json:"expiry,omitempty"` -} - -func (s *Session) GetAuthURL() (string, error) { - return s.AuthURL, nil -} - -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - t, err := p.config.Exchange(context.WithValue(context.Background(), oauth2.HTTPClient, p.client), params.Get("code")) - if err != nil { - return "", err - } - - if !t.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = t.AccessToken - s.TokenType = t.TokenType - s.RefreshToken = t.RefreshToken - s.Expiry = t.Expiry - - return s.AccessToken, nil -} diff --git a/providers/reddit/session_test.go b/providers/reddit/session_test.go deleted file mode 100644 index 72de35c7a..000000000 --- a/providers/reddit/session_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package reddit - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" -) - -var validAuthResponseTestData = struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - RefreshToken string `json:"refresh_token"` -}{ - AccessToken: "i am a token", - TokenType: "type", - ExpiresIn: 120, - Scope: "identity", - RefreshToken: "your refresh token", -} - -var invalidAuthResponseTestData = struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - RefreshToken string `json:"refresh_token"` -}{ - AccessToken: "", - TokenType: "type", - ExpiresIn: 120, - Scope: "identity", - RefreshToken: "Your refresh token", -} - -func TestSession(t *testing.T) { - t.Run("gets the URL for the authentication end-point for the provider", func(t *testing.T) { - s := Session{AuthURL: "example.com"} - got, err := s.GetAuthURL() - if err != nil { - t.Fatal("should return a url string") - } - - want := "example.com" - - if got != want { - t.Errorf("got %q want %q", got, want) - } - }) - - t.Run("generates a string representation of the session", func(t *testing.T) { - s := Session{ - AuthURL: "example", - } - got := s.Marshal() - want := `{"AuthURL":"example","access_token":"","expiry":"0001-01-01T00:00:00Z"}` - - if got != want { - t.Errorf("got %q want %q", got, want) - } - }) - - t.Run("return an access token", func(t *testing.T) { - - s := Session{AuthURL: "example.com"} - authServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - b, err := json.Marshal(validAuthResponseTestData) - if err != nil { - writer.WriteHeader(http.StatusInternalServerError) - return - } - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - writer.Write(b) - })) - - tokenURL := authServer.URL - - p := New("CLIENT_ID", "CLIENT_SECRET", "URI", "DURATION", tokenURL, "SCOPE_STRING1", "SCOPE_STRING2") - u := url.Values{} - u.Set("code", "12345678") - - got, err := s.Authorize(&p, u) - if err != nil { - t.Fatal("did not expect an error: ", err) - } - - want := validAuthResponseTestData.AccessToken - - if got != want { - t.Errorf("got %q want %q", got, want) - } - }) - - t.Run("validates access token", func(t *testing.T) { - s := Session{AuthURL: "example.com"} - authServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - b, err := json.Marshal(invalidAuthResponseTestData) - if err != nil { - writer.WriteHeader(http.StatusInternalServerError) - return - } - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - writer.Write(b) - })) - - tokenURL := authServer.URL - - p := New("CLIENT_ID", "CLIENT_SECRET", "URI", "DURATION", tokenURL, "SCOPE_STRING1", "SCOPE_STRING2") - u := url.Values{} - u.Set("code", "12345678") - - _, err := s.Authorize(&p, u) - if err == nil { - t.Errorf("expected an error but didn't get one") - } - }) -} diff --git a/providers/salesforce/salesforce.go b/providers/salesforce/salesforce.go deleted file mode 100644 index d4a1c3f50..000000000 --- a/providers/salesforce/salesforce.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package salesforce implements the OAuth2 protocol for authenticating users through salesforce. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package salesforce - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// These vars define the Authentication and Token URLS for Salesforce. If -// using Salesforce Community, you should change these values before calling New. -// -// Examples: -// -// salesforce.AuthURL = "https://salesforce.acme.com/services/oauth2/authorize -// salesforce.TokenURL = "https://salesforce.acme.com/services/oauth2/token -var ( - AuthURL = "https://login.salesforce.com/services/oauth2/authorize" - TokenURL = "https://login.salesforce.com/services/oauth2/token" - - // endpointProfile string = "https://api.salesforce.com/2.0/users/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Salesforce. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Salesforce provider and sets up important connection details. -// You should always call `salesforce.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "salesforce", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the salesforce package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Salesforce for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Salesforce and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - url, err := url.Parse(s.ID) - if err != nil { - return user, err - } - - // creating dynamic url to retrieve user information - userURL := url.Scheme + "://" + url.Host + "/" + url.Path - req, err := http.NewRequest("GET", userURL, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: AuthURL, - TokenURL: TokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - var rawData map[string]interface{} - - buf := new(bytes.Buffer) - _, err := buf.ReadFrom(r) - if err != nil { - return err - } - - err = json.Unmarshal(buf.Bytes(), &rawData) - if err != nil { - return err - } - - u := struct { - Name string `json:"display_name"` - NickName string `json:"nick_name"` - Location string `json:"addr_country"` - Email string `json:"email"` - AvatarURL string `json:"photos.picture"` - ID string `json:"user_id"` - }{} - - err = json.Unmarshal(buf.Bytes(), &u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.NickName = u.Name - user.UserID = u.ID - user.Location = u.Location - user.RawData = rawData - - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/salesforce/salesforce_test.go b/providers/salesforce/salesforce_test.go deleted file mode 100644 index e983bff5a..000000000 --- a/providers/salesforce/salesforce_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package salesforce_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/salesforce" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("SALESFORCE_KEY")) - a.Equal(p.Secret, os.Getenv("SALESFORCE_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*salesforce.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.salesforce.com/services/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://login.salesforce.com/services/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*salesforce.Session) - a.Equal(s.AuthURL, "https://login.salesforce.com/services/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *salesforce.Provider { - return salesforce.New(os.Getenv("SALESFORCE_KEY"), os.Getenv("SALESFORCE_SECRET"), "/foo") -} diff --git a/providers/salesforce/session.go b/providers/salesforce/session.go deleted file mode 100644 index 1d2ffd12e..000000000 --- a/providers/salesforce/session.go +++ /dev/null @@ -1,72 +0,0 @@ -package salesforce - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Salesforce. -// Expiry of access token is not provided by Salesforce, it is just controlled by timeout configured in auth2 settings -// by individual users -// Only way to check whether access token has expired or not is based on the response you receive if you try using -// access token and get some error -// Also, For salesforce refresh token to work follow these else remove scopes from here -// On salesforce.com, navigate to where you app is configured. (Setup > Create > Apps) -// Under Connected Apps, click on your application's name to view its settings, then click Edit. -// Under Selected OAuth Scopes, ensure that "Perform requests on your behalf at any time" is selected. You must include this even if you already chose "Full access". -// Save, then try your OAuth flow again. It takes a short while for the update to propagate. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ID string // Required to get the user info from sales force -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Salesforce provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Salesforce and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ID = token.Extra("id").(string) // Required to get the user info from sales force - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/salesforce/session_test.go b/providers/salesforce/session_test.go deleted file mode 100644 index b0a4d9b97..000000000 --- a/providers/salesforce/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package salesforce_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/salesforce" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &salesforce.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &salesforce.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &salesforce.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ID":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &salesforce.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/seatalk/seatalk.go b/providers/seatalk/seatalk.go deleted file mode 100644 index e399eb495..000000000 --- a/providers/seatalk/seatalk.go +++ /dev/null @@ -1,161 +0,0 @@ -package seatalk - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Endpoint is SeaTalk's OAuth 2.0 endpoint. -var Endpoint = oauth2.Endpoint{ - AuthURL: "https://seatalkweb.com/webapp/oauth2/authorize", - TokenURL: "https://seatalkweb.com/webapp/oauth2/token", -} - -const endpointProfile string = "https://seatalkweb.com/webapp/oauth2/profile" - -// Provider is the implementation of `goth.Provider` for accessing SeaTalk. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - config *oauth2.Config - providerName string -} - -// New creates a new SeaTalk provider and sets up important connection details. -// You should always call `seatalk.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "seatalk", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// BeginAuth asks SeaTalk for an authentication endpoint. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} - -type seatalkUser struct { - ID string `json:"user_id"` - Name string `json:"name"` - Email string `json:"email"` -} - -// FetchUser will go to SeaTalk and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // Data is not yet retrieved, since accessToken is still empty. - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := http.Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - responseBytes, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - var u seatalkUser - if err := json.Unmarshal(responseBytes, &u); err != nil { - return user, err - } - - // Extract the user data we got from Google into our goth.User. - user.Name = u.Name - user.NickName = u.Name - user.Email = u.Email - user.UserID = u.ID - - return user, nil -} - -// Debug is a no-op for the seatalk package. -func (p *Provider) Debug(bool) { - -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(context.Background(), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: Endpoint, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = []string{"email"} - } - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} diff --git a/providers/seatalk/seatalk_test.go b/providers/seatalk/seatalk_test.go deleted file mode 100644 index 07ede5768..000000000 --- a/providers/seatalk/seatalk_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package seatalk_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/seatalk" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("SEATALK_KEY")) - a.Equal(p.Secret, os.Getenv("SEATALK_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*seatalk.Session) - a.NoError(err) - a.Contains(s.AuthURL, "seatalkweb.com/webapp/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://seatalkweb.com/webapp/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*seatalk.Session) - a.Equal(s.AuthURL, "https://seatalkweb.com/webapp/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *seatalk.Provider { - return seatalk.New(os.Getenv("SEATALK_KEY"), os.Getenv("SEATALK_SECRET"), "/foo") -} diff --git a/providers/seatalk/session.go b/providers/seatalk/session.go deleted file mode 100644 index e0e474a93..000000000 --- a/providers/seatalk/session.go +++ /dev/null @@ -1,54 +0,0 @@ -package seatalk - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with SeaTalk. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google provider. -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with SeaTalk and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(context.Background(), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} diff --git a/providers/seatalk/session_test.go b/providers/seatalk/session_test.go deleted file mode 100644 index d6693208f..000000000 --- a/providers/seatalk/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package seatalk_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/seatalk" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &seatalk.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &seatalk.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &seatalk.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &seatalk.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/shopify/scopes.go b/providers/shopify/scopes.go deleted file mode 100644 index 52c8e52db..000000000 --- a/providers/shopify/scopes.go +++ /dev/null @@ -1,49 +0,0 @@ -package shopify - -// Define scopes supported by Shopify. -// See: https://help.shopify.com/en/api/getting-started/authentication/oauth/scopes#authenticated-access-scopes -const ( - ScopeReadContent = "read_content" - ScopeWriteContent = "write_content" - ScopeReadThemes = "read_themes" - ScopeWriteThemes = "write_themes" - ScopeReadProducts = "read_products" - ScopeWriteProducts = "write_products" - ScopeReadProductListings = "read_product_listings" - ScopeReadCustomers = "read_customers" - ScopeWriteCustomers = "write_customers" - ScopeReadOrders = "read_orders" - ScopeWriteOrders = "write_orders" - ScopeReadDrafOrders = "read_draft_orders" - ScopeWriteDrafOrders = "write_draft_orders" - ScopeReadInventory = "read_inventory" - ScopeWriteInventory = "write_inventory" - ScopeReadLocations = "read_locations" - ScopeReadScriptTags = "read_script_tags" - ScopeWriteScriptTags = "write_script_tags" - ScopeReadFulfillments = "read_fulfillments" - ScopeWriteFulfillments = "write_fulfillments" - ScopeReadShipping = "read_shipping" - ScopeWriteShipping = "write_shipping" - ScopeReadAnalytics = "read_analytics" - ScopeReadUsers = "read_users" - ScopeWriteUsers = "write_users" - ScopeReadCheckouts = "read_checkouts" - ScopeWriteCheckouts = "write_checkouts" - ScopeReadReports = "read_reports" - ScopeWriteReports = "write_reports" - ScopeReadPriceRules = "read_price_rules" - ScopeWritePriceRules = "write_price_rules" - ScopeMarketingEvents = "read_marketing_events" - ScopeWriteMarketingEvents = "write_marketing_events" - ScopeReadResourceFeedbacks = "read_resource_feedbacks" - ScopeWriteResourceFeedbacks = "write_resource_feedbacks" - ScopeReadShopifyPaymentsPayouts = "read_shopify_payments_payouts" - ScopeReadShopifyPaymentsDisputes = "read_shopify_payments_disputes" - - // Special: - // Grants access to all orders rather than the default window of 60 days worth of orders. - // This OAuth scope is used in conjunction with read_orders, or write_orders. You need to request - // this scope from your Partner Dashboard before adding it to your app. - ScopeReadAllOrders = "read_all_orders" -) diff --git a/providers/shopify/session.go b/providers/shopify/session.go deleted file mode 100755 index ba9e7e95a..000000000 --- a/providers/shopify/session.go +++ /dev/null @@ -1,103 +0,0 @@ -package shopify - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "regexp" - "strings" - "time" - - "github.com/markbates/goth" -) - -const ( - shopifyHostnameRegex = `^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$` -) - -// Session stores data during the auth process with Shopify. -type Session struct { - AuthURL string - AccessToken string - Hostname string - HMAC string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Shopify provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Shopify and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - // Validate the incoming HMAC is valid. - // See: https://help.shopify.com/en/api/getting-started/authentication/oauth#verification - digest := fmt.Sprintf( - "code=%s&host=%s&shop=%s&state=%s×tamp=%s", - params.Get("code"), - params.Get("host"), - params.Get("shop"), - params.Get("state"), - params.Get("timestamp"), - ) - h := hmac.New(sha256.New, []byte(os.Getenv("SHOPIFY_SECRET"))) - h.Write([]byte(digest)) - sha := hex.EncodeToString(h.Sum(nil)) - - // Ensure our HMAC hash's match. - if sha != params.Get("hmac") { - return "", errors.New("Invalid HMAC received") - } - - // Validate the hostname matches what we're expecting. - // See: https://help.shopify.com/en/api/getting-started/authentication/oauth#step-3-confirm-installation - re := regexp.MustCompile(shopifyHostnameRegex) - if !re.MatchString(params.Get("shop")) { - return "", errors.New("Invalid hostname received") - } - - // Make the exchange for an access token. - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - // Ensure it's valid. - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.Hostname = params.Get("hostname") - s.HMAC = params.Get("hmac") - - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/shopify/session_test.go b/providers/shopify/session_test.go deleted file mode 100755 index 85ea9adc0..000000000 --- a/providers/shopify/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package shopify_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/shopify" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &shopify.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &shopify.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &shopify.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","Hostname":"","HMAC":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &shopify.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/shopify/shopify.go b/providers/shopify/shopify.go deleted file mode 100755 index 9b1450680..000000000 --- a/providers/shopify/shopify.go +++ /dev/null @@ -1,192 +0,0 @@ -// Package shopify implements the OAuth2 protocol for authenticating users through Shopify. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package shopify - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - providerName = "shopify" - - // URL protocol and subdomain will be populated by newConfig(). - authURL = "myshopify.com/admin/oauth/authorize" - tokenURL = "myshopify.com/admin/oauth/access_token" - endpointProfile = "myshopify.com/admin/api/2019-04/shop.json" -) - -// Provider is the implementation of `goth.Provider` for accessing Shopify. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - shopName string - scopes []string -} - -// New creates a new Shopify provider and sets up important connection details. -// You should always call `shopify.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: providerName, - scopes: scopes, - } - p.config = newConfig(p, scopes) - return p -} - -// Client is HTTP client to be used in all fetch operations. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// SetShopName is to update the shopify shop name, needed when interfacing with different shops. -func (p *Provider) SetShopName(name string) { - p.shopName = name - - // Reparse config with the new shop name. - p.config = newConfig(p, p.scopes) -} - -// Debug is a no-op for the Shopify package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Shopify for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by Shopify") -} - -// FetchUser will go to Shopify and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - shop := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - } - - if shop.AccessToken == "" { - // Data is not yet retrieved since accessToken is still empty. - return shop, fmt.Errorf("%s cannot get shop information without accessToken", p.providerName) - } - - // Build the request. - req, err := http.NewRequest("GET", fmt.Sprintf("https://%s.%s", p.shopName, endpointProfile), nil) - if err != nil { - return shop, err - } - req.Header.Set("X-Shopify-Access-Token", s.AccessToken) - - // Execute the request. - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return shop, err - } - defer resp.Body.Close() - - // Check our response status. - if resp.StatusCode != http.StatusOK { - return shop, fmt.Errorf("%s responded with a %d trying to fetch shop information", p.providerName, resp.StatusCode) - } - - // Parse response. - return shop, shopFromReader(resp.Body, &shop) -} - -func shopFromReader(r io.Reader, shop *goth.User) error { - rsp := struct { - Shop struct { - ID int64 `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - City string `json:"city"` - Country string `json:"country"` - ShopOwner string `json:"shop_owner"` - MyShopifyDomain string `json:"myshopify_domain"` - PlanDisplayName string `json:"plan_display_name"` - } `json:"shop"` - }{} - - err := json.NewDecoder(r).Decode(&rsp) - if err != nil { - return err - } - - shop.UserID = strconv.Itoa(int(rsp.Shop.ID)) - shop.Name = rsp.Shop.Name - shop.Email = rsp.Shop.Email - shop.Description = fmt.Sprintf("%s (%s)", rsp.Shop.MyShopifyDomain, rsp.Shop.PlanDisplayName) - shop.Location = fmt.Sprintf("%s, %s", rsp.Shop.City, rsp.Shop.Country) - shop.AvatarURL = "Not provided by the Shopify API" - shop.NickName = "Not provided by the Shopify API" - - return nil -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("https://%s.%s", p.shopName, authURL), - TokenURL: fmt.Sprintf("https://%s.%s", p.shopName, tokenURL), - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for i, scope := range scopes { - // Shopify require comma separated scopes. - s := fmt.Sprintf("%s,", scope) - if i == len(scopes)+1 { - s = scope - } - c.Scopes = append(c.Scopes, s) - } - } else { - // Default to a read customers scope. - c.Scopes = append(c.Scopes, ScopeReadCustomers) - } - - return c -} diff --git a/providers/shopify/shopify_test.go b/providers/shopify/shopify_test.go deleted file mode 100755 index 393a887c3..000000000 --- a/providers/shopify/shopify_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package shopify_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/shopify" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("SHOPIFY_KEY")) - a.Equal(p.Secret, os.Getenv("SHOPIFY_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*shopify.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://test-shop.myshopify.com/admin/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://test-shop.myshopify.com/admin/oauth/authorize","AccessToken":"1234567890"}"`) - a.NoError(err) - - s := session.(*shopify.Session) - a.Equal(s.AuthURL, "https://test-shop.myshopify.com/admin/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *shopify.Provider { - p := shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "/foo") - p.SetShopName("test-shop") - return p -} diff --git a/providers/slack/session.go b/providers/slack/session.go deleted file mode 100644 index 83d66f9e9..000000000 --- a/providers/slack/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package slack - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Slack. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Slack provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Slack and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/slack/session_test.go b/providers/slack/session_test.go deleted file mode 100644 index 5364c1f76..000000000 --- a/providers/slack/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package slack_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/slack" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &slack.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &slack.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &slack.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &slack.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/slack/slack.go b/providers/slack/slack.go deleted file mode 100644 index daec6f422..000000000 --- a/providers/slack/slack.go +++ /dev/null @@ -1,236 +0,0 @@ -// Package slack implements the OAuth2 protocol for authenticating users through slack. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package slack - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Scopes -const ( - ScopeUserRead string = "users:read" -) - -// URLs and endpoints -const ( - authURL string = "https://slack.com/oauth/authorize" - tokenURL string = "https://slack.com/api/oauth.access" - endpointUser string = "https://slack.com/api/auth.test" - endpointProfile string = "https://slack.com/api/users.info" -) - -// Provider is the implementation of `goth.Provider` for accessing Slack. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Slack provider and sets up important connection details. -// You should always call `slack.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "slack", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns the http.Client used in the provider. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the slack package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Slack for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Slack and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // Get the userID, Slack needs userID in order to get user profile info - req, _ := http.NewRequest("GET", endpointUser, nil) - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = simpleUserFromReader(bytes.NewReader(bits), &user) - - if p.hasScope(ScopeUserRead) { - // Get user profile info - req, _ := http.NewRequest("GET", endpointProfile+"?user="+user.UserID, nil) - req.Header.Add("Authorization", "Bearer "+sess.AccessToken) - response, err = p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err = io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - } - - return user, err -} - -func (p *Provider) hasScope(scope string) bool { - hasScope := false - - for i := range p.config.Scopes { - if p.config.Scopes[i] == scope { - hasScope = true - break - } - } - - return hasScope -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, ScopeUserRead) - } - return c -} - -func simpleUserFromReader(r io.Reader, user *goth.User) error { - u := struct { - UserID string `json:"user_id"` - Name string `json:"user"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - user.UserID = u.UserID - user.NickName = u.Name - - return nil -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - User struct { - NickName string `json:"name"` - ID string `json:"id"` - Profile struct { - Email string `json:"email"` - Name string `json:"real_name"` - AvatarURL string `json:"image_32"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - } `json:"profile"` - } `json:"user"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.User.Profile.Email - user.Name = u.User.Profile.Name - user.NickName = u.User.NickName - user.UserID = u.User.ID - user.AvatarURL = u.User.Profile.AvatarURL - user.FirstName = u.User.Profile.FirstName - user.LastName = u.User.Profile.LastName - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} diff --git a/providers/slack/slack_test.go b/providers/slack/slack_test.go deleted file mode 100644 index dd6d59556..000000000 --- a/providers/slack/slack_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package slack_test - -import ( - "context" - "crypto/tls" - "encoding/json" - "net" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/slack" - "github.com/stretchr/testify/assert" -) - -var ( - testAuthTestResponseData = map[string]interface{}{ - "user": "testuser", - "user_id": "user1234", - } - - testUserInfoResponseData = map[string]interface{}{ - "user": map[string]interface{}{ - "id": testAuthTestResponseData["user_id"], - "name": testAuthTestResponseData["user"], - "profile": map[string]interface{}{ - "real_name": "Test User", - "first_name": "Test", - "last_name": "User", - "image_32": "http://example.org/avatar.png", - "email": "test@example.org", - }, - }, - } -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("SLACK_KEY")) - a.Equal(p.Secret, os.Getenv("SLACK_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*slack.Session) - a.NoError(err) - a.Contains(s.AuthURL, "slack.com/oauth/authorize") -} - -func Test_FetchUser(t *testing.T) { - t.Parallel() - - for _, testData := range []struct { - name string - provider *slack.Provider - session goth.Session - handler http.Handler - expectedUser goth.User - expectErr bool - }{ - { - name: "FetchesFullProfile", - provider: provider(), - session: &slack.Session{AccessToken: "TOKEN"}, - handler: http.HandlerFunc( - func(res http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case "/api/auth.test": - res.WriteHeader(http.StatusOK) - json.NewEncoder(res).Encode(testAuthTestResponseData) - case "/api/users.info": - res.WriteHeader(http.StatusOK) - json.NewEncoder(res).Encode(testUserInfoResponseData) - default: - res.WriteHeader(http.StatusNotFound) - } - }, - ), - expectedUser: goth.User{ - UserID: "user1234", - NickName: "testuser", - Name: "Test User", - FirstName: "Test", - LastName: "User", - AvatarURL: "http://example.org/avatar.png", - Email: "test@example.org", - AccessToken: "TOKEN", - }, - expectErr: false, - }, - { - name: "FetchesBasicProfileWhenLackingUserReadScope", - provider: slack.New(os.Getenv("SLACK_KEY"), os.Getenv("SLACK_SECRET"), "/foo", "commands"), - session: &slack.Session{AccessToken: "TOKEN"}, - handler: http.HandlerFunc( - func(res http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case "/api/auth.test": - res.WriteHeader(http.StatusOK) - json.NewEncoder(res).Encode(testAuthTestResponseData) - default: - res.WriteHeader(http.StatusNotFound) - } - }, - ), - expectedUser: goth.User{ - UserID: "user1234", - NickName: "testuser", - AccessToken: "TOKEN", - }, - expectErr: false, - }, - { - name: "FailsWithNoAccessToken", - provider: provider(), - session: &slack.Session{AccessToken: ""}, - handler: nil, - expectErr: true, - }, - { - name: "FailsWithBadAuthTestResponse", - provider: provider(), - session: &slack.Session{AccessToken: "TOKEN"}, - handler: http.HandlerFunc( - func(res http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case "/api/auth.test": - res.WriteHeader(http.StatusForbidden) - } - }, - ), - expectedUser: goth.User{ - AccessToken: "TOKEN", - }, - expectErr: true, - }, - { - name: "FailsWithBadUserInfoResponse", - provider: provider(), - session: &slack.Session{AccessToken: "TOKEN"}, - handler: http.HandlerFunc( - func(res http.ResponseWriter, req *http.Request) { - switch req.URL.Path { - case "/api/auth.test": - res.WriteHeader(http.StatusOK) - json.NewEncoder(res).Encode(testAuthTestResponseData) - case "/api/users.info": - res.WriteHeader(http.StatusForbidden) - } - }, - ), - expectedUser: goth.User{ - UserID: "user1234", - NickName: "testuser", - AccessToken: "TOKEN", - }, - expectErr: true, - }, - } { - t.Run(testData.name, func(t *testing.T) { - a := assert.New(t) - - withMockServer(testData.provider, testData.handler, func(p *slack.Provider) { - user, err := p.FetchUser(testData.session) - a.NotZero(user) - - if testData.expectErr { - a.Error(err) - } else { - a.NoError(err) - } - - a.Equal(testData.expectedUser.UserID, user.UserID) - a.Equal(testData.expectedUser.NickName, user.NickName) - a.Equal(testData.expectedUser.Name, user.Name) - a.Equal(testData.expectedUser.FirstName, user.FirstName) - a.Equal(testData.expectedUser.LastName, user.LastName) - a.Equal(testData.expectedUser.AvatarURL, user.AvatarURL) - a.Equal(testData.expectedUser.Email, user.Email) - a.Equal(testData.expectedUser.AccessToken, user.AccessToken) - }) - }) - } -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://slack.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*slack.Session) - a.Equal(s.AuthURL, "https://slack.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *slack.Provider { - return slack.New(os.Getenv("SLACK_KEY"), os.Getenv("SLACK_SECRET"), "/foo") -} - -func withMockServer(p *slack.Provider, handler http.Handler, fn func(p *slack.Provider)) { - server := httptest.NewTLSServer(handler) - defer server.Close() - - httpClient := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { - return net.Dial(network, server.Listener.Addr().String()) - }, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - } - - p.HTTPClient = httpClient - - fn(p) -} diff --git a/providers/soundcloud/session.go b/providers/soundcloud/session.go deleted file mode 100644 index f06bd0edd..000000000 --- a/providers/soundcloud/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package soundcloud - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Soundcloud. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Soundcloud provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New("an AuthURL has not be set") - } - return s.AuthURL, nil -} - -// Authorize the session with Soundcloud and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/soundcloud/session_test.go b/providers/soundcloud/session_test.go deleted file mode 100644 index 56e572af8..000000000 --- a/providers/soundcloud/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package soundcloud_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/soundcloud" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &soundcloud.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &soundcloud.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &soundcloud.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &soundcloud.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/soundcloud/soundcloud.go b/providers/soundcloud/soundcloud.go deleted file mode 100644 index 5e6dff719..000000000 --- a/providers/soundcloud/soundcloud.go +++ /dev/null @@ -1,169 +0,0 @@ -// Package soundcloud implements the OAuth2 protocol for authenticating users through soundcloud. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package soundcloud - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://soundcloud.com/connect" - tokenURL string = "https://api.soundcloud.com/oauth2/token" - endpointProfile string = "https://api.soundcloud.com/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Soundcloud. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Soundcloud provider and sets up important connection details. -// You should always call `soundcloud.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "soundcloud", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the soundcloud package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Soundcloud for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Soundcloud and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.Client().Get(endpointProfile + "?oauth_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"full_name"` - NickName string `json:"username"` - ID int `json:"id"` - AvatarURL string `json:"avatar_url"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - // Soundcloud does not provide the email_id - user.Name = u.Name - user.NickName = u.NickName - user.UserID = strconv.Itoa(u.ID) - user.AvatarURL = u.AvatarURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/soundcloud/soundcloud_test.go b/providers/soundcloud/soundcloud_test.go deleted file mode 100644 index 3249c21f9..000000000 --- a/providers/soundcloud/soundcloud_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package soundcloud_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/soundcloud" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("SOUNDCLOUD_KEY")) - a.Equal(p.Secret, os.Getenv("SOUNDCLOUD_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*soundcloud.Session) - a.NoError(err) - a.Contains(s.AuthURL, "soundcloud.com/connect") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://soundcloud.com/connect","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*soundcloud.Session) - a.Equal(s.AuthURL, "https://soundcloud.com/connect") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *soundcloud.Provider { - return soundcloud.New(os.Getenv("SOUNDCLOUD_KEY"), os.Getenv("SOUNDCLOUD_SECRET"), "/foo") -} diff --git a/providers/spotify/session.go b/providers/spotify/session.go deleted file mode 100644 index 3d106faf1..000000000 --- a/providers/spotify/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package spotify - -import ( - "encoding/json" - "errors" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Spotify. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the -// Spotify provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize completes the authorization with Spotify and returns the access -// token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal marshals a session into a JSON string. -func (s Session) Marshal() string { - j, _ := json.Marshal(s) - return string(j) -} - -// String is equivalent to Marshal. It returns a JSON representation of the session. -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := Session{} - err := json.Unmarshal([]byte(data), &s) - return &s, err -} diff --git a/providers/spotify/session_test.go b/providers/spotify/session_test.go deleted file mode 100644 index 42abfc453..000000000 --- a/providers/spotify/session_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package spotify_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/spotify" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &spotify.Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &spotify.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &spotify.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} diff --git a/providers/spotify/spotify.go b/providers/spotify/spotify.go deleted file mode 100644 index f36512c0c..000000000 --- a/providers/spotify/spotify.go +++ /dev/null @@ -1,224 +0,0 @@ -// Package spotify implements the OAuth protocol for authenticating users through Spotify. -// This package can be used as a reference implementation of an OAuth provider for Goth. -package spotify - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL = "https://accounts.spotify.com/authorize" - tokenURL = "https://accounts.spotify.com/api/token" - userEndpoint = "https://api.spotify.com/v1/me" -) - -const ( - // ScopePlaylistReadPrivate seeks permission to read - // a user's collaborative playlists. - ScopePlaylistReadCollaborative = "playlist-read-collaborative" - // ScopePlaylistReadPrivate seeks permission to read - // a user's private playlists. - ScopePlaylistReadPrivate = "playlist-read-private" - // ScopePlaylistModifyPublic seeks write access - // to a user's public playlists. - ScopePlaylistModifyPublic = "playlist-modify-public" - // ScopePlaylistModifyPrivate seeks write access to - // a user's private playlists. - ScopePlaylistModifyPrivate = "playlist-modify-private" - // ScopeUserFollowModify seeks write/delete access to - // the list of artists and other users that a user follows. - ScopeUserFollowModify = "user-follow-modify" - // ScopeUserFollowRead seeks read access to the list of - // artists and other users that a user follows. - ScopeUserFollowRead = "user-follow-read" - // ScopeUserLibraryModify seeks write/delete access to a - // user's "Your Music" library. - ScopeUserLibraryModify = "user-library-modify" - // ScopeUserLibraryRead seeks read access to a user's - // "Your Music" library. - ScopeUserLibraryRead = "user-library-read" - // ScopeUserReadPrivate seeks read access to a user's - // subsription details (type of user account) - ScopeUserReadPrivate = "user-read-private" - // ScopeUserReadEmail seeks read access to a user's - // email address. - ScopeUserReadEmail = "user-read-email" - // ScopeUGCImageUpload seeks write access to user-provided images. - ScopeUGCImageUpload = "ugc-image-upload" - // ScopeUserReadPlaybackState seeks read access to a user’s player state. - ScopeUserReadPlaybackState = "user-read-playback-state" - // ScopeUserModifyPlaybackState seeks write access to a user’s playback state - ScopeUserModifyPlaybackState = "user-modify-playback-state" - // ScopeUserReadCurrentlyPlaying seeks read access to a user’s currently playing track - ScopeUserReadCurrentlyPlaying = "user-read-currently-playing" - // ScopeStreaming seeks to control playback of a Spotify track. - // This scope is currently available to the Web Playback SDK. - // The user must have a Spotify Premium account. - ScopeStreaming = "streaming" - // ScopeAppRemoteControl seeks remote control playback of Spotify. - // This scope is currently available to Spotify iOS and Android SDKs. - ScopeAppRemoteControl = "app-remote-control" - // ScopeUserTopRead seeks read access to a user's top artists and tracks. - ScopeUserTopRead = "user-top-read" - // ScopeUserReadRecentlyPlayed seeks read access to a user’s recently played tracks. - ScopeUserReadRecentlyPlayed = "user-read-recently-played" -) - -// New creates a new Spotify provider and sets up important connection details. -// You should always call `spotify.New` to get a new Provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "spotify", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Spotify. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name gets the name used to retrieve this provider. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the spotify package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Spotify for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - return session, nil -} - -// FetchUser will go to Spotify and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", userEndpoint, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - // err = userFromReader(io.TeeReader(resp.Body, os.Stdout), &user) - err = userFromReader(resp.Body, &user) - return user, err -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Country string `json:"country"` - DisplayName string `json:"display_name"` - Email string `json:"email"` - ID string `json:"id"` - Images []struct { - URL string `json:"url"` - } `json:"images"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - user.Name = u.DisplayName - user.Email = u.Email - user.UserID = u.ID - user.Location = u.Country - if len(u.Images) > 0 { - user.AvatarURL = u.Images[0].URL - } - return nil -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ScopeUserReadEmail, ScopeUserReadPrivate}, - } - - defaultScopes := map[string]struct{}{ - ScopeUserReadEmail: {}, - ScopeUserReadPrivate: {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/spotify/spotify_test.go b/providers/spotify/spotify_test.go deleted file mode 100644 index 5d65e9ce9..000000000 --- a/providers/spotify/spotify_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package spotify_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/spotify" - "github.com/stretchr/testify/assert" -) - -func provider() *spotify.Provider { - return spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "/foo", "user") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("SPOTIFY_KEY")) - a.Equal(p.Secret, os.Getenv("SPOTIFY_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_ImplementsProvider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*spotify.Session) - a.NoError(err) - a.Contains(s.AuthURL, "accounts.spotify.com/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"http://accounts.spotify.com/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*spotify.Session) - a.Equal(s.AuthURL, "http://accounts.spotify.com/authorize") - a.Equal(s.AccessToken, "1234567890") -} diff --git a/providers/steam/session.go b/providers/steam/session.go deleted file mode 100644 index 7f06c8c98..000000000 --- a/providers/steam/session.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package steam implements the OpenID protocol for authenticating users through Steam. -package steam - -import ( - "encoding/json" - "errors" - "io" - "net/url" - "regexp" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Steam. -type Session struct { - AuthURL string - CallbackURL string - SteamID string - ResponseNonce string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Steam provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Steam and return the unique response_nonce by OpenID. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - if params.Get("openid.mode") != "id_res" { - return "", errors.New("Mode must equal to \"id_res\".") - } - - if params.Get("openid.return_to") != s.CallbackURL { - return "", errors.New("The \"return_to url\" must match the url of current request.") - } - - v := make(url.Values) - v.Set("openid.assoc_handle", params.Get("openid.assoc_handle")) - v.Set("openid.signed", params.Get("openid.signed")) - v.Set("openid.sig", params.Get("openid.sig")) - v.Set("openid.ns", params.Get("openid.ns")) - - split := strings.Split(params.Get("openid.signed"), ",") - for _, item := range split { - v.Set("openid."+item, params.Get("openid."+item)) - } - v.Set("openid.mode", "check_authentication") - - resp, err := p.Client().PostForm(apiLoginEndpoint, v) - if err != nil { - return "", err - } - defer resp.Body.Close() - content, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - response := strings.Split(string(content), "\n") - if response[0] != "ns:"+openIDNs { - return "", errors.New("Wrong ns in the response.") - } - - if response[1] == "is_valid:false" { - return "", errors.New("Unable validate openId.") - } - - openIDURL := params.Get("openid.claimed_id") - validationRegExp := regexp.MustCompile("^(http|https)://steamcommunity.com/openid/id/[0-9]{15,25}$") - if !validationRegExp.MatchString(openIDURL) { - return "", errors.New("Invalid Steam ID pattern.") - } - - s.SteamID = regexp.MustCompile("\\D+").ReplaceAllString(openIDURL, "") - s.ResponseNonce = params.Get("openid.response_nonce") - - return s.ResponseNonce, nil -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/steam/session_test.go b/providers/steam/session_test.go deleted file mode 100644 index b6e945565..000000000 --- a/providers/steam/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package steam_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/steam" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &steam.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &steam.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &steam.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","CallbackURL":"","SteamID":"","ResponseNonce":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &steam.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/steam/steam.go b/providers/steam/steam.go deleted file mode 100644 index 79679defe..000000000 --- a/providers/steam/steam.go +++ /dev/null @@ -1,199 +0,0 @@ -// Package steam implements the OpenID protocol for authenticating users through Steam. -package steam - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - // Steam API Endpoints - apiLoginEndpoint = "https://steamcommunity.com/openid/login" - apiUserSummaryEndpoint = "http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=%s&steamids=%s" - - // OpenID settings - openIDMode = "checkid_setup" - openIDNs = "http://specs.openid.net/auth/2.0" - openIDIdentifier = "http://specs.openid.net/auth/2.0/identifier_select" -) - -// New creates a new Steam provider, and sets up important connection details. -// You should always call `steam.New` to get a new Provider. Never try to create -// one manually. -func New(apiKey string, callbackURL string) *Provider { - p := &Provider{ - APIKey: apiKey, - CallbackURL: callbackURL, - providerName: "steam", - } - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Steam -type Provider struct { - APIKey string - CallbackURL string - HTTPClient *http.Client - providerName string -} - -// Name gets the name used to retrieve this provider. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is no-op for the Steam package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth will return the authentication end-point for Steam. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - u, err := p.getAuthURL() - s := &Session{ - AuthURL: u.String(), - CallbackURL: p.CallbackURL, - } - return s, err -} - -// getAuthURL is an internal function to build the correct -// authentication url to redirect the user to Steam. -func (p *Provider) getAuthURL() (*url.URL, error) { - callbackURL, err := url.Parse(p.CallbackURL) - if err != nil { - return nil, err - } - - urlValues := map[string]string{ - "openid.claimed_id": openIDIdentifier, - "openid.identity": openIDIdentifier, - "openid.mode": openIDMode, - "openid.ns": openIDNs, - "openid.realm": fmt.Sprintf("%s://%s", callbackURL.Scheme, callbackURL.Host), - "openid.return_to": callbackURL.String(), - } - - u, err := url.Parse(apiLoginEndpoint) - if err != nil { - return nil, err - } - - v := u.Query() - for key, value := range urlValues { - v.Set(key, value) - } - u.RawQuery = v.Encode() - - return u, nil -} - -// FetchUser will go to Steam and access basic info about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - u := goth.User{ - Provider: p.Name(), - AccessToken: s.ResponseNonce, - } - - if s.SteamID == "" { - // data is not yet retrieved since SteamID is still empty - return u, fmt.Errorf("%s cannot get user information without SteamID", p.providerName) - } - - apiURL := fmt.Sprintf(apiUserSummaryEndpoint, p.APIKey, s.SteamID) - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return u, err - } - req.Header.Add("Accept", "application/json") - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return u, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return u, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - u, err = buildUserObject(resp.Body, u) - - return u, err -} - -// buildUserObject is an internal function to build a goth.User object -// based in the data stored in r -func buildUserObject(r io.Reader, u goth.User) (goth.User, error) { - // Response object from Steam - apiResponse := struct { - Response struct { - Players []struct { - UserID string `json:"steamid"` - NickName string `json:"personaname"` - Name string `json:"realname"` - AvatarURL string `json:"avatarfull"` - LocationCountryCode string `json:"loccountrycode"` - LocationStateCode string `json:"locstatecode"` - } `json:"players"` - } `json:"response"` - }{} - - err := json.NewDecoder(r).Decode(&apiResponse) - if err != nil { - return u, err - } - - if l := len(apiResponse.Response.Players); l != 1 { - return u, fmt.Errorf("Expected one player in API response. Got %d.", l) - } - - player := apiResponse.Response.Players[0] - u.UserID = player.UserID - u.Name = player.Name - if len(player.Name) == 0 { - u.Name = "No name is provided by the Steam API" - } - u.NickName = player.NickName - u.AvatarURL = player.AvatarURL - u.Email = "No email is provided by the Steam API" - u.Description = "No description is provided by the Steam API" - - if len(player.LocationStateCode) > 0 && len(player.LocationCountryCode) > 0 { - u.Location = fmt.Sprintf("%s, %s", player.LocationStateCode, player.LocationCountryCode) - } else if len(player.LocationCountryCode) > 0 { - u.Location = player.LocationCountryCode - } else if len(player.LocationStateCode) > 0 { - u.Location = player.LocationStateCode - } else { - u.Location = "No location is provided by the Steam API" - } - - return u, nil -} - -// RefreshToken refresh token is not provided by Steam -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} - -// RefreshTokenAvailable refresh token is not provided by Steam -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/steam/steam_test.go b/providers/steam/steam_test.go deleted file mode 100644 index f800bd0a0..000000000 --- a/providers/steam/steam_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package steam_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/steam" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.APIKey, os.Getenv("STEAM_KEY")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*steam.Session) - a.NoError(err) - a.Contains(s.AuthURL, "steamcommunity.com/openid/login") - a.Contains(s.AuthURL, "foo") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://steamcommunity.com/openid/login?openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.mode=checkid_setup&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.realm=%3A%2F%2F&openid.return_to=%2Ffoo","SteamID":"1234567890","CallbackURL":"http://localhost:3030/","ResponseNonce":"2016-03-13T16:56:30ZJ8tlKVquwHi9ZSPV4ElU5PY2dmI="}`) - a.NoError(err) - - s := session.(*steam.Session) - a.Equal(s.AuthURL, "https://steamcommunity.com/openid/login?openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.mode=checkid_setup&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.realm=%3A%2F%2F&openid.return_to=%2Ffoo") - a.Equal(s.CallbackURL, "http://localhost:3030/") - a.Equal(s.SteamID, "1234567890") - a.Equal(s.ResponseNonce, "2016-03-13T16:56:30ZJ8tlKVquwHi9ZSPV4ElU5PY2dmI=") -} - -func provider() *steam.Provider { - return steam.New(os.Getenv("STEAM_KEY"), "/foo") -} diff --git a/providers/strava/session.go b/providers/strava/session.go deleted file mode 100644 index cc0cbd0d4..000000000 --- a/providers/strava/session.go +++ /dev/null @@ -1,61 +0,0 @@ -package strava - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Strava. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Strava provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Strava and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/strava/session_test.go b/providers/strava/session_test.go deleted file mode 100644 index f3175102b..000000000 --- a/providers/strava/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package strava_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/strava" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &strava.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &strava.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &strava.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &strava.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/strava/strava.go b/providers/strava/strava.go deleted file mode 100644 index 4527844fe..000000000 --- a/providers/strava/strava.go +++ /dev/null @@ -1,182 +0,0 @@ -// Package strava implements the OAuth2 protocol for authenticating users through Strava. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package strava - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.strava.com/oauth/authorize" - tokenURL string = "https://www.strava.com/oauth/token" - endpointProfile string = "https://www.strava.com/api/v3/athlete" -) - -// New creates a new Strava provider, and sets up important connection details. -// You should always call `strava.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "strava", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Strava. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns an HTTP client to be used in all fetch operations. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the strava package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Strava for an authentication endpoint. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - authUrl := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: authUrl, - } - return session, nil -} - -// FetchUser will go to Strava and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - reqUrl := fmt.Sprint(endpointProfile, - "?access_token=", url.QueryEscape(sess.AccessToken), - ) - response, err := p.Client().Get(reqUrl) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - ID int64 `json:"id"` - Username string `json:"username"` - FirstName string `json:"firstname"` - LastName string `json:"lastname"` - City string `json:"city"` - Region string `json:"state"` - Country string `json:"country"` - Gender string `json:"sex"` - Picture string `json:"profile"` - }{} - - err := json.NewDecoder(reader).Decode(&u) - if err != nil { - return err - } - - user.UserID = fmt.Sprintf("%d", u.ID) - user.Name = fmt.Sprintf("%s %s", u.FirstName, u.LastName) - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.Username - user.AvatarURL = u.Picture - user.Description = fmt.Sprintf(`{"gender":"%s"}`, u.Gender) - user.Location = fmt.Sprintf(`{"city":"%s","region":"%s","country":"%s"}`, u.City, u.Region, u.Country) - - return err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - c.Scopes = []string{strings.Join(scopes, ",")} - } else { - c.Scopes = []string{"read"} - } - - return c -} - -// RefreshTokenAvailable refresh token is not provided by Strava -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken refresh token is not provided by Strava -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/strava/strava_test.go b/providers/strava/strava_test.go deleted file mode 100644 index 19762d86a..000000000 --- a/providers/strava/strava_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package strava_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/strava" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := stravaProvider() - a.Equal(provider.ClientKey, os.Getenv("STRAVA_KEY")) - a.Equal(provider.Secret, os.Getenv("STRAVA_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), stravaProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := stravaProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*strava.Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.strava.com/oauth/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("STRAVA_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=read") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := stravaProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://www.strava.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*strava.Session) - a.Equal(session.AuthURL, "https://www.strava.com/oauth/authorize") - a.Equal(session.AccessToken, "1234567890") -} - -func stravaProvider() *strava.Provider { - return strava.New(os.Getenv("STRAVA_KEY"), os.Getenv("STRAVA_SECRET"), "/foo", "read") -} diff --git a/providers/stripe/session.go b/providers/stripe/session.go deleted file mode 100644 index 24f5581ed..000000000 --- a/providers/stripe/session.go +++ /dev/null @@ -1,65 +0,0 @@ -package stripe - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Stripe. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - ID string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Stripe provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Stripe and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - s.ID = token.Extra("stripe_user_id").(string) // Required to get the user info from sales force - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/stripe/session_test.go b/providers/stripe/session_test.go deleted file mode 100644 index b043f11c8..000000000 --- a/providers/stripe/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package stripe_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/stripe" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &stripe.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &stripe.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &stripe.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","ID":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &stripe.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/stripe/stripe.go b/providers/stripe/stripe.go deleted file mode 100644 index b2c2257ba..000000000 --- a/providers/stripe/stripe.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package stripe implements the OAuth2 protocol for authenticating users through stripe. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package stripe - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://connect.stripe.com/oauth/authorize" - tokenURL string = "https://connect.stripe.com/oauth/token" - endPointAccount string = "https://api.stripe.com/v1/accounts/" -) - -// Provider is the implementation of `goth.Provider` for accessing Stripe. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Stripe provider and sets up important connection details. -// You should always call `stripe.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "stripe", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the stripe package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Stripe for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Stripe and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endPointAccount+s.ID, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Email string `json:"email"` - Name string `json:"display_name"` - AvatarURL string `json:"business_logo"` - ID string `json:"id"` - Address struct { - Location string `json:"city"` - } `json:"support_address"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email // email is not provided by yahoo - user.Name = u.Name - user.NickName = u.Name - user.UserID = u.ID - user.Location = u.Address.Location - user.AvatarURL = u.AvatarURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/stripe/stripe_test.go b/providers/stripe/stripe_test.go deleted file mode 100644 index 8b7e7327f..000000000 --- a/providers/stripe/stripe_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package stripe_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/stripe" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("STRIPE_KEY")) - a.Equal(p.Secret, os.Getenv("STRIPE_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*stripe.Session) - a.NoError(err) - a.Contains(s.AuthURL, "connect.stripe.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://connect.stripe.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*stripe.Session) - a.Equal(s.AuthURL, "https://connect.stripe.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *stripe.Provider { - return stripe.New(os.Getenv("STRIPE_KEY"), os.Getenv("STRIPE_SECRET"), "/foo") -} diff --git a/providers/tiktok/session.go b/providers/tiktok/session.go deleted file mode 100644 index ff917c820..000000000 --- a/providers/tiktok/session.go +++ /dev/null @@ -1,104 +0,0 @@ -package tiktok - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "net/url" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with TikTok -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time - OpenID string - RefreshToken string - RefreshExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the TikTok provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with TikTok and return the access token to be stored for future use. Note that -// we call the endpoints directly vs calling *oauth2.Config.Exchange() due to inconsistent TikTok param names. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - - // Set up the url params to post to get a new access token from a code - v := url.Values{ - "grant_type": {"authorization_code"}, - "code": {params.Get("code")}, - } - if p.config.RedirectURL != "" { - v.Set("redirect_uri", p.config.RedirectURL) - } - - req, err := http.NewRequest(http.MethodPost, endpointToken, nil) - if err != nil { - return "", err - } - v.Add("client_key", p.config.ClientID) - v.Add("client_secret", p.config.ClientSecret) - - req.URL.RawQuery = v.Encode() - response, err := p.GetClient().Do(req) - if err != nil { - return "", err - } - - tokenResp := struct { - Data struct { - OpenID string `json:"open_id"` - Scope string `json:"scope"` - AccessToken string `json:"access_token"` - ExpiresIn int64 `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - RefreshExpiresIn int64 `json:"refresh_expires_in"` - } `json:"data"` - }{} - - // Get the body bytes in case we have to parse an error response - bodyBytes, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - defer response.Body.Close() - - err = json.Unmarshal(bodyBytes, &tokenResp) - if err != nil { - return "", err - } - - // If we do not have an access token we assume we have an error response payload - if tokenResp.Data.AccessToken == "" { - return "", handleErrorResponse(bodyBytes) - } - - // Create and Bind the Access Token - s.AccessToken = tokenResp.Data.AccessToken - s.ExpiresAt = time.Now().UTC().Add(time.Second * time.Duration(tokenResp.Data.ExpiresIn)) - s.OpenID = tokenResp.Data.OpenID - s.RefreshToken = tokenResp.Data.RefreshToken - s.RefreshExpiresAt = time.Now().UTC().Add(time.Second * time.Duration(tokenResp.Data.RefreshExpiresIn)) - return s.AccessToken, nil -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} diff --git a/providers/tiktok/session_test.go b/providers/tiktok/session_test.go deleted file mode 100644 index 99cc69093..000000000 --- a/providers/tiktok/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package tiktok_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/tiktok" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &tiktok.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &tiktok.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &tiktok.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z","OpenID":"","RefreshToken":"","RefreshExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &tiktok.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/tiktok/tiktok.go b/providers/tiktok/tiktok.go deleted file mode 100644 index 01066b789..000000000 --- a/providers/tiktok/tiktok.go +++ /dev/null @@ -1,278 +0,0 @@ -// Package tiktok implements the OAuth2 protocol for authenticating users through TikTok. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package tiktok - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - endpointAuth = "https://open-api.tiktok.com/platform/oauth/connect/" - endpointToken = "https://open-api.tiktok.com/oauth/access_token/" - endpointRefresh = "https://open-api.tiktok.com/oauth/refresh_token/" - endpointUserInfo = "https://open-api.tiktok.com/oauth/userinfo/" - - ScopeUserInfoBasic = "user.info.basic" - ScopeVideoList = "video.list" - ScopeVideoUpload = "video.upload" - ScopeShareSoundCreate = "share.sound.create" -) - -// Provider is the implementation of `goth.Provider` for accessing TikTok -type Provider struct { - CallbackURL string - Client *http.Client - ClientKey string - ClientSecret string - config *oauth2.Config - providerName string -} - -// New creates a new TikTok provider, and sets up connection details. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - ClientSecret: secret, - CallbackURL: callbackURL, - providerName: "tiktok", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) GetClient() *http.Client { - return goth.HTTPClientWithFallBack(p.Client) -} - -// Debug TODO -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks TikTok for an authentication end-point. Note that we create our own URL string instead -// of calling oauth2.AuthCodeURL() due to TikTok param name requirements. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - var buf bytes.Buffer - buf.WriteString(p.config.Endpoint.AuthURL) - v := url.Values{ - "response_type": {"code"}, - "client_key": {p.config.ClientID}, - "state": {state}, - } - - if p.config.RedirectURL != "" { - v.Set("redirect_uri", p.config.RedirectURL) - } - - // Note scopes are CSVs - if len(p.config.Scopes) > 0 { - v.Set("scope", strings.Join(p.config.Scopes, ",")) - } - - if strings.Contains(p.config.Endpoint.AuthURL, "?") { - buf.WriteByte('&') - } else { - buf.WriteByte('?') - } - buf.WriteString(v.Encode()) - return &Session{ - AuthURL: buf.String(), - }, nil -} - -// FetchUser will go to TikTok and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - ExpiresAt: sess.ExpiresAt, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - UserID: sess.OpenID, - } - - // data is not yet retrieved since accessToken is still empty - if user.AccessToken == "" || user.UserID == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken and userID", p.providerName) - } - - // Set up the url params to post to get a new access token from a code - v := url.Values{ - "access_token": {user.AccessToken}, - "open_id": {user.UserID}, - } - response, err := p.GetClient().Get(endpointUserInfo + "?" + v.Encode()) - if err != nil { - return user, err - } - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - err = userFromReader(response.Body, &user) - response.Body.Close() - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - u := struct { - Data struct { - OpenID string `json:"open_id"` - Avatar string `json:"avatar"` - DisplayName string `json:"display_name"` - } `json:"data"` - }{} - - bodyBytes, err := io.ReadAll(reader) - if err != nil { - return err - } - - err = json.Unmarshal(bodyBytes, &u) - if err != nil { - return err - } - user.AvatarURL = u.Data.Avatar - user.Name = u.Data.DisplayName - user.NickName = u.Data.DisplayName - - // On no display name, we assume an error response. TikTok returns error codes and descriptions inside - // the same struct/body. Sigh...refer https://developers.tiktok.com/doc/login-kit-user-info-basic - if user.Name == "" { - return handleErrorResponse(bodyBytes) - } - - // Bind the all the bytes to the raw data returning err - return json.Unmarshal(bodyBytes, &user.RawData) -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.ClientSecret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: endpointAuth, - }, - Scopes: []string{ScopeUserInfoBasic}, - } - - // Note that the "user.info.basic" scope is always bound so don't dupe - for _, scope := range scopes { - if scope != ScopeUserInfoBasic { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -// RefreshToken will refresh a TikTok access token. -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - req, err := http.NewRequest(http.MethodPost, endpointRefresh, nil) - if err != nil { - return nil, err - } - - // Set up the url params to post to get a new access token from a code - v := url.Values{ - "client_key": {p.config.ClientID}, - "grant_type": {"refresh_token"}, - "refresh_token": {refreshToken}, - } - req.URL.RawQuery = v.Encode() - refreshResponse, err := p.GetClient().Do(req) - if err != nil { - return nil, err - } - - // We get the body bytes in case we need to parse an error response - bodyBytes, err := io.ReadAll(refreshResponse.Body) - if err != nil { - return nil, err - } - defer refreshResponse.Body.Close() - - refresh := struct { - Data struct { - OpenID string `json:"open_id"` - Scope string `json:"scope"` - AccessToken string `json:"access_token"` - ExpiresIn int64 `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - RefreshExpiresIn int64 `json:"refresh_expires_in"` - } `json:"data"` - }{} - err = json.Unmarshal(bodyBytes, &refresh) - if err != nil { - return nil, err - } - - // If we do not have an access token we assume we have an error response payload - if refresh.Data.AccessToken == "" { - return nil, handleErrorResponse(bodyBytes) - } - - token := &oauth2.Token{ - AccessToken: refresh.Data.AccessToken, - TokenType: "Bearer", - RefreshToken: refresh.Data.RefreshToken, - Expiry: time.Now().Add(time.Second * time.Duration(refresh.Data.ExpiresIn)), - } - - tokenExtra := map[string]interface{}{ - "open_id": refresh.Data.OpenID, - "scope": refresh.Data.Scope, - "refresh_expires_in": refresh.Data.RefreshExpiresIn, - } - - return token.WithExtra(tokenExtra), nil -} - -// RefreshTokenAvailable refresh token -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} - -func handleErrorResponse(data []byte) error { - errResp := struct { - Data struct { - Captcha string `json:"captcha"` - DescURL string `json:"desc_url"` - Description string `json:"description"` - ErrorCode int `json:"error_code"` - } `json:"data"` - Message string `json:"message"` - }{} - if err := json.Unmarshal(data, &errResp); err != nil { - return err - } - - return fmt.Errorf("%s [%d]", errResp.Data.Description, errResp.Data.ErrorCode) -} diff --git a/providers/tiktok/tiktok_test.go b/providers/tiktok/tiktok_test.go deleted file mode 100644 index 9ab7295b3..000000000 --- a/providers/tiktok/tiktok_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package tiktok_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/tiktok" - "github.com/stretchr/testify/assert" -) - -const callbackURL = "/tests/for/the/win" - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("tiktok_KEY")) - a.Equal(p.ClientSecret, os.Getenv("tiktok_SECRET")) - a.Nil(p.Client) - a.Equal(p.CallbackURL, callbackURL) -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*tiktok.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://open-api.tiktok.com/platform/oauth/connect") - a.Contains(s.AuthURL, fmt.Sprintf("%s%%2C%s", tiktok.ScopeUserInfoBasic, tiktok.ScopeVideoList)) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://open-api.tiktok.com/platform/oauth/connect","AccessToken":"1234567890"}"`) - a.NoError(err) - - s := session.(*tiktok.Session) - a.Equal(s.AuthURL, "https://open-api.tiktok.com/platform/oauth/connect") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *tiktok.Provider { - p := tiktok.New(os.Getenv("TIKTOK_KEY"), os.Getenv("TIKTOK_SECRET"), callbackURL, tiktok.ScopeVideoList) - return p -} diff --git a/providers/tumblr/session.go b/providers/tumblr/session.go deleted file mode 100644 index 10b5de8c0..000000000 --- a/providers/tumblr/session.go +++ /dev/null @@ -1,54 +0,0 @@ -package tumblr - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" - "github.com/mrjones/oauth" -) - -// Session stores data during the auth process with Tumblr. -type Session struct { - AuthURL string - AccessToken *oauth.AccessToken - RequestToken *oauth.RequestToken -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Tumblr provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Tumblr and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) - if err != nil { - return "", err - } - - s.AccessToken = accessToken - return accessToken.Token, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/tumblr/tumblr.go b/providers/tumblr/tumblr.go deleted file mode 100644 index 07028c227..000000000 --- a/providers/tumblr/tumblr.go +++ /dev/null @@ -1,152 +0,0 @@ -// Package tumblr implements the OAuth protocol for authenticating users through Tumblr. -// This package can be used as a reference implementation of an OAuth provider for Goth. -package tumblr - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - - "github.com/markbates/goth" - "github.com/mrjones/oauth" - "golang.org/x/oauth2" -) - -var ( - requestURL = "https://www.tumblr.com/oauth/request_token" - authorizeURL = "https://www.tumblr.com/oauth/authorize" - tokenURL = "https://www.tumblr.com/oauth/access_token" - endpointProfile = "https://api.tumblr.com/v2/user/info" -) - -// user/update_token - -// New creates a new Tumblr provider, and sets up important connection details. -// You should always call `tumblr.New` to get a new Provider. Never try to create -// one manually. -// -// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead. -func New(clientKey, secret, callbackURL string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "tumblr", - } - p.consumer = newConsumer(p, authorizeURL) - return p -} - -// NewAuthenticate is the almost same as New. -// NewAuthenticate uses the authenticate URL instead of the authorize URL. -func NewAuthenticate(clientKey, secret, callbackURL string) *Provider { - return New(clientKey, secret, callbackURL) -} - -// Provider is the implementation of `goth.Provider` for accessing Tumblr. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - debug bool - consumer *oauth.Consumer - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug sets the logging of the OAuth client to verbose. -func (p *Provider) Debug(debug bool) { - p.debug = debug -} - -// BeginAuth asks Tumblr for an authentication end-point and a request token for a session. -// Tumblr does not support the "state" variable. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) - session := &Session{ - AuthURL: url, - RequestToken: requestToken, - } - return session, err -} - -// FetchUser will go to Tumblr and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - Provider: p.Name(), - } - - if sess.AccessToken == nil { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.consumer.Get(endpointProfile, map[string]string{}, sess.AccessToken) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - if err = json.NewDecoder(response.Body).Decode(&user.RawData); err != nil { - return user, err - } - - res, ok := user.RawData["response"].(map[string]interface{}) - if !ok { - return user, errors.New("could not decode response") - } - resUser, ok := res["user"].(map[string]interface{}) - if !ok { - return user, errors.New("could not decode user") - } - - user.Name = resUser["name"].(string) - user.NickName = resUser["name"].(string) - user.AccessToken = sess.AccessToken.Token - user.AccessTokenSecret = sess.AccessToken.Secret - return user, err -} - -func newConsumer(provider *Provider, authURL string) *oauth.Consumer { - c := oauth.NewConsumer( - provider.ClientKey, - provider.Secret, - oauth.ServiceProvider{ - RequestTokenUrl: requestURL, - AuthorizeTokenUrl: authURL, - AccessTokenUrl: tokenURL, - }) - - c.Debug(provider.debug) - return c -} - -// RefreshToken refresh token is not provided by Tumblr -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by Tumblr") -} - -// RefreshTokenAvailable refresh token is not provided by Tumblr -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/twitch/session.go b/providers/twitch/session.go deleted file mode 100644 index 109962d91..000000000 --- a/providers/twitch/session.go +++ /dev/null @@ -1,65 +0,0 @@ -package twitch - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Twitch -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on -// the Twitch provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize completes the authorization with Twitch and returns the access -// token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal marshals a session into a JSON string. -func (s Session) Marshal() string { - j, _ := json.Marshal(s) - return string(j) -} - -// String is equivalent to Marshal. It returns a JSON representation of the -// session. -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/twitch/session_test.go b/providers/twitch/session_test.go deleted file mode 100644 index d616416c1..000000000 --- a/providers/twitch/session_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package twitch - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func Test_ImplementsSession(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} diff --git a/providers/twitch/twitch.go b/providers/twitch/twitch.go deleted file mode 100644 index 1fe70702b..000000000 --- a/providers/twitch/twitch.go +++ /dev/null @@ -1,369 +0,0 @@ -// Package twitch implements the OAuth2 protocol for authenticating users through Twitch. -// This package can be used as a reference implementation of an OAuth2 provider for Twitch. -package twitch - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://id.twitch.tv/oauth2/authorize" - tokenURL string = "https://id.twitch.tv/oauth2/token" - userEndpoint string = "https://api.twitch.tv/helix/users" -) - -const ( - // ScopeAnalyticsReadExtensions provides access to view analytics data for - // the Twitch Extensions owned by the authenticated account. - ScopeAnalyticsReadExtensions = "analytics:read:extensions" - // ScopeAnalyticsReadGames provides accesss to view analytics data for the - // games owned by the authenticated account. - ScopeAnalyticsReadGames = "analytics:read:games" - // ScopeBitsRead provides access to view Bits information for a channel. - ScopeBitsRead = "bits:read" - // ScopeChannelManageBroadcast provides access to manage a channel’s - // broadcast configuration, including updating channel configuration and - // managing stream markers and stream tags. - ScopeChannelManageBroadcast = "channel:manage:broadcast" - // ScopeChannelReadCharity provides access to read charity campaign details - // and user donations on your channel. - ScopeChannelReadCharity = "channel:read:charity" - // ScopeChannelEditCommercial provides access to run commercials on a - // channel. - ScopeChannelEditCommercial = "channel:edit:commercial" - // ScopeChannelReadEditors provides access to view a list of users with the - // editor role for a channel. - ScopeChannelReadEditors = "channel:read:editors" - // ScopeChannelManageExtensions provides access to manage a channel’s - // Extension configuration, including activating Extensions. - ScopeChannelManageExtensions = "channel:manage:extensions" - // ScopeChannelReadGoals provides access to view Creator Goals for a - // channel. - ScopeChannelReadGoals = "channel:read:goals" - // ScopeChannelReadGuestStar provides access to read Guest Star details - // for your channel. - ScopeChannelReadGuestStar = "channel:read:guest_star" - // ScopeChannelManageGuestStar provides access to manage Guest Star - // for your channel. - ScopeChannelManageGuestStar = "channel:manage:guest_star" - // ScopeChannelReadHypeTrain provides access to view Hype Train information - // for a channel. - ScopeChannelReadHypeTrain = "channel:read:hype_train" - // ScopeChannelManageModerators provides access to add or remove the - // moderator role from users in your channel. - ScopeChannelManageModerators = "channel:manage:moderators" - // ScopeChannelReadPolls provides access to view a channel’s polls. - ScopeChannelReadPolls = "channel:read:polls" - // ScopeChannelManagePolls provides access to manage a channel’s polls. - ScopeChannelManagePolls = "channel:manage:polls" - // ScopeChannelReadPredictions provides access to view a channel’s Channel - // Points Predictions. - ScopeChannelReadPredictions = "channel:read:predictions" - // ScopeChannelManagePredictions provides access to manage a channel’s - // Channel Points Predictions. - ScopeChannelManagePredictions = "channel:manage:predictions" - // ScopeChannelManageRaids provides access to manage a channel raiding another channel. - ScopeChannelManageRaids = "channel:manage:raids" - // ScopeChannelReadRedemptions provides access to view Channel Points custom - // rewards and their redemptions on a channel. - ScopeChannelReadRedemptions = "channel:read:redemptions" - // ScopeChannelManageRedemptions provides access to manage Channel Points - // custom rewards and their redemptions on a channel. - ScopeChannelManageRedemptions = "channel:manage:redemptions" - // ScopeChannelManageSchedule provides access to manage a channel’s stream - // schedule. - ScopeChannelManageSchedule = "channel:manage:schedule" - // ScopeChannelReadStreamKey provides access to view an authorized user’s - // stream key. - ScopeChannelReadStreamKey = "channel:read:stream_key" - // ScopeChannelReadSubscriptions provides access to view a list of all - // subscribers to a channel and check if a user is subscribed to a channel. - ScopeChannelReadSubscriptions = "channel:read:subscriptions" - // ScopeChannelManageVideos provides access to manage a channel’s videos, - // including deleting videos. - ScopeChannelManageVideos = "channel:manage:videos" - // ScopeChannelReadVips provides access to read the list of VIPs in your channel. - ScopeChannelReadVips = "channel:read:vips" - // ScopeChannelManageVips provide access to add or remove the VIP role from - // users in your channel. - ScopeChannelManageVips = "channel:manage:vips" - // ScopeClipsEdit provides access to manage Clips for a channel. - ScopeClipsEdit = "clips:edit" - // ScopeModerationRead provides access to view a channel’s moderation data - // including Moderators, Bans, Timeouts, and AutoMod settings. - ScopeModerationRead = "moderation:read" - // ScopeModeratorManageAnnouncements provides access to send announcements - // in channels where you have the moderator role. - ScopeModeratorManageAnnouncements = "moderator:manage:announcements" - // ScopeModeratorManageAutomod provides access to manage messages held for - // review by AutoMod in channels where you are a moderator. - ScopeModeratorManageAutomod = "moderator:manage:automod" - // ScopeModeratorReadAutomodSettings provides access to view a broadcaster’s - // AutoMod settings. - ScopeModeratorReadAutomodSettings = "moderator:read:automod_settings" - // ScopeModeratorManageAutomodSettings provides access to manage a broadcaster’s - // AutoMod settings. - ScopeModeratorManageAutomodSettings = "moderator:manage:automod_settings" - // ScopeModeratorManageBannedUsers provides access to ban and unban users. - ScopeModeratorManageBannedUsers = "moderator:manage:banned_users" - // ScopeModeratorReadBlockedTerms provides access to view a broadcaster’s - // list of blocked terms. - ScopeModeratorReadBlockedTerms = "moderator:read:blocked_terms" - // ScopeModeratorManageBlockedTerms provides access to manage a - // broadcaster’s list of blocked terms. - ScopeModeratorManageBlockedTerms = "moderator:manage:blocked_terms" - // ScopeModeratorManageChatMessages provides access to delete chat messages - // in channels where you have the moderator role - ScopeModeratorManageChatMessages = "moderator:manage:chat_messages" - // ScopeModeratorReadChatSettings provides access to view a broadcaster’s - // chat room settings. - ScopeModeratorReadChatSettings = "moderator:read:chat_settings" - // ScopeModeratorManageChatSettings provides access to manage a - // broadcaster’s chat room settings. - ScopeModeratorManageChatSettings = "moderator:manage:chat_settings" - // ScopeModeratorReadChatters provides access to view the chatters - // in a broadcaster’s chat room. - ScopeModeratorReadChatters = "moderator:read:chatters" - // ScopeModeratorReadFollowers provides access to read the followers of a broadcaster. - ScopeModeratorReadFollowers = "moderator:read:followers" - // ScopeModeratorReadGuestStar provides access to read Guest Star details - // for channels where you are a Guest Star moderator. - ScopeModeratorReadGuestStar = "moderator:read:guest_star" - // ScopeModeratorManageGuestStar provides access to Manage Guest Star for - // channels where you are a Guest Star moderator. - ScopeModeratorManageGuestStar = "moderator:manage:guest_star" - // ScopeModeratorReadShieldMode provides access to view a broadcaster’s - // Shield Mode status. - ScopeModeratorReadShieldMode = "moderator:read:shield_mode" - // ScopeModeratorManageShieldMode provides access to manage a broadcaster’s - // Shield Mode status. - ScopeModeratorManageShieldMode = "moderator:manage:shield_mode" - // ScopeModeratorReadShoutouts provides access to view a broadcaster’s shoutouts. - ScopeModeratorReadShoutouts = "moderator:read:shoutouts" - // ScopeModeratorManageShoutouts provides access to manage a broadcaster’s shoutouts. - ScopeModeratorManageShoutouts = "moderator:manage:shoutouts" - // ScopeUserEdit provides access to manage a user object. - ScopeUserEdit = "user:edit" - // ScopeUserEditFollows is deprecated. Was previously used for - // “Create User Follows” and “Delete User Follows.” - ScopeUserEditFollows = "user:edit:follows" - // ScopeUserReadBlockedUsers provides access to view the block list of a - // user. - ScopeUserReadBlockedUsers = "user:read:blocked_users" - // ScopeUserManageBlockedUsers provides access to manage the block list of a - // user. - ScopeUserManageBlockedUsers = "user:manage:blocked_users" - // ScopeUserReadBroadcast provides access to view a user’s broadcasting - // configuration, including Extension configurations. - ScopeUserReadBroadcast = "user:read:broadcast" - // ScopeUserManageChatColor provides access to update the color use - // for the user’s name in chat. - ScopeUserManageChatColor = "user:manage:chat_color" - // ScopeUserReadEmail provides access to view a user’s email address. - ScopeUserReadEmail = "user:read:email" - // ScopeUserReadFollows provides access to view the list of channels a user - // follows. - ScopeUserReadFollows = "user:read:follows" - // ScopeUserReadSubscriptions provides access to view if an authorized user - // is subscribed to specific channels. - ScopeUserReadSubscriptions = "user:read:subscriptions" - // ScopeUserManageWhispers provides access to read whispers that you send and receive, - // and send whispers on your behalf. - ScopeUserManageWhispers = "user:manage:whispers" - // ScopeChannelModerate provides access to perform moderation actions in a channel. - // The user requesting the scope must be a moderator in the channel. - ScopeChannelModerate = "channel:moderate" - // ScopeChatEdit provides access to send live stream chat messages. - ScopeChatEdit = "chat:edit" - // ScopeChatRead provides access to view live stream chat messages. - ScopeChatRead = "chat:read" - // ScopeWhispersRead provides access to view your whisper messages. - ScopeWhispersRead = "whispers:read" - // ScopeWhispersEdit provides access to send whisper messages. - ScopeWhispersEdit = "whispers:edit" - - // ScopeChannelSubscriptions is a v5 scope. - ScopeChannelSubscriptions = ScopeChannelReadSubscriptions - // ScopeChannelCommercial is a v5 scope. - ScopeChannelCommercial = ScopeChannelEditCommercial - // ScopeChannelEditor is a v5 scope which maps to channel:manage:broadcast - // and channel:manage:videos. - ScopeChannelEditor = "channel_editor" - // ScopeUserFollowsEdit is a v5 scope. - ScopeUserFollowsEdit = ScopeUserEditFollows - // ScopeChannelRead is a v5 scope which maps to channel:read:editors, - // channel:read:stream_key, and user:read:email. - ScopeChannelRead = "channel_read" - // ScopeUserRead is a v5 scope. - ScopeUserRead = ScopeUserReadEmail - // ScopeUserBlocksRead is a v5 scope. - ScopeUserBlocksRead = ScopeUserReadBlockedUsers - // ScopeUserBlocksEdit is a v5 scope. - ScopeUserBlocksEdit = ScopeUserManageBlockedUsers - // ScopeUserSubscriptions is a v5 scope. - ScopeUserSubscriptions = ScopeUserReadSubscriptions -) - -// New creates a new Twitch provider, and sets up important connection details. -// You should always call `twitch.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "twitch", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Twitch -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// Name gets the name used to retrieve this provider. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client ... -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is no-op for the Twitch package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Twitch for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - s := &Session{ - AuthURL: url, - } - return s, nil -} - -// FetchUser will go to Twitch and access basic info about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - - s := session.(*Session) - - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", userEndpoint, nil) - if err != nil { - return user, err - } - req.Header.Set("Client-Id", p.config.ClientID) - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func userFromReader(r io.Reader, user *goth.User) error { - var users struct { - Data []struct { - ID string `json:"id"` - Name string `json:"login"` - Nickname string `json:"display_name"` - Description string `json:"description"` - AvatarURL string `json:"profile_image_url"` - Email string `json:"email"` - } `json:"data"` - } - err := json.NewDecoder(r).Decode(&users) - if err != nil { - return err - } - if len(users.Data) == 0 { - return errors.New("user not found") - } - u := users.Data[0] - user.Name = u.Name - user.Email = u.Email - user.NickName = u.Nickname - user.Location = "No location is provided by the Twitch API" - user.AvatarURL = u.AvatarURL - user.Description = u.Description - user.UserID = u.ID - - return nil -} - -func newConfig(p *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: p.ClientKey, - ClientSecret: p.Secret, - RedirectURL: p.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = []string{ScopeUserReadEmail} - } - - return c -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/twitch/twitch_test.go b/providers/twitch/twitch_test.go deleted file mode 100644 index 73d5bd7e1..000000000 --- a/providers/twitch/twitch_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package twitch - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/stretchr/testify/assert" -) - -func provider() *Provider { - return New(os.Getenv("TWITCH_KEY"), - os.Getenv("TWITCH_SECRET"), "/foo", "user") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("TWITCH_KEY")) - a.Equal(p.Secret, os.Getenv("TWITCH_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_ImplementsProvider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "id.twitch.tv/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://id.twitch.tv/oauth2/authorize", "AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*Session) - a.Equal(s.AuthURL, "https://id.twitch.tv/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} diff --git a/providers/twitter/session.go b/providers/twitter/session.go deleted file mode 100644 index 049928ff2..000000000 --- a/providers/twitter/session.go +++ /dev/null @@ -1,54 +0,0 @@ -package twitter - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" - "github.com/mrjones/oauth" -) - -// Session stores data during the auth process with Twitter. -type Session struct { - AuthURL string - AccessToken *oauth.AccessToken - RequestToken *oauth.RequestToken -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Twitter provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Twitter and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) - if err != nil { - return "", err - } - - s.AccessToken = accessToken - return accessToken.Token, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/twitter/session_test.go b/providers/twitter/session_test.go deleted file mode 100644 index 1773b07b9..000000000 --- a/providers/twitter/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package twitter_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/twitter" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &twitter.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &twitter.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &twitter.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":null,"RequestToken":null}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &twitter.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/twitter/twitter.go b/providers/twitter/twitter.go deleted file mode 100644 index ad4abced2..000000000 --- a/providers/twitter/twitter.go +++ /dev/null @@ -1,167 +0,0 @@ -// Package twitter implements the OAuth protocol for authenticating users through Twitter. -// This package can be used as a reference implementation of an OAuth provider for Goth. -package twitter - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "github.com/mrjones/oauth" - "golang.org/x/oauth2" -) - -var ( - requestURL = "https://api.twitter.com/oauth/request_token" - authorizeURL = "https://api.twitter.com/oauth/authorize" - authenticateURL = "https://api.twitter.com/oauth/authenticate" - tokenURL = "https://api.twitter.com/oauth/access_token" - endpointProfile = "https://api.twitter.com/1.1/account/verify_credentials.json" -) - -// New creates a new Twitter provider, and sets up important connection details. -// You should always call `twitter.New` to get a new Provider. Never try to create -// one manually. -// -// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead. -func New(clientKey, secret, callbackURL string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "twitter", - } - p.consumer = newConsumer(p, authorizeURL) - return p -} - -// NewAuthenticate is the almost same as New. -// NewAuthenticate uses the authenticate URL instead of the authorize URL. -func NewAuthenticate(clientKey, secret, callbackURL string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "twitter", - } - p.consumer = newConsumer(p, authenticateURL) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Twitter. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - debug bool - consumer *oauth.Consumer - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug sets the logging of the OAuth client to verbose. -func (p *Provider) Debug(debug bool) { - p.debug = debug -} - -// BeginAuth asks Twitter for an authentication end-point and a request token for a session. -// Twitter does not support the "state" variable. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) - session := &Session{ - AuthURL: url, - RequestToken: requestToken, - } - return session, err -} - -// FetchUser will go to Twitter and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - Provider: p.Name(), - } - - if sess.AccessToken == nil { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.consumer.Get( - endpointProfile, - map[string]string{"include_entities": "false", "skip_status": "true", "include_email": "true"}, - sess.AccessToken) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - user.Name = user.RawData["name"].(string) - user.NickName = user.RawData["screen_name"].(string) - if user.RawData["email"] != nil { - user.Email = user.RawData["email"].(string) - } - user.Description = user.RawData["description"].(string) - user.AvatarURL = user.RawData["profile_image_url"].(string) - user.UserID = user.RawData["id_str"].(string) - user.Location = user.RawData["location"].(string) - user.AccessToken = sess.AccessToken.Token - user.AccessTokenSecret = sess.AccessToken.Secret - return user, err -} - -func newConsumer(provider *Provider, authURL string) *oauth.Consumer { - c := oauth.NewConsumer( - provider.ClientKey, - provider.Secret, - oauth.ServiceProvider{ - RequestTokenUrl: requestURL, - AuthorizeTokenUrl: authURL, - AccessTokenUrl: tokenURL, - }) - - c.Debug(provider.debug) - return c -} - -// RefreshToken refresh token is not provided by twitter -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by twitter") -} - -// RefreshTokenAvailable refresh token is not provided by twitter -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/twitter/twitter_test.go b/providers/twitter/twitter_test.go deleted file mode 100644 index 141b5d8d7..000000000 --- a/providers/twitter/twitter_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package twitter - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gorilla/pat" - "github.com/markbates/goth" - "github.com/mrjones/oauth" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := twitterProvider() - a.Equal(provider.ClientKey, os.Getenv("TWITTER_KEY")) - a.Equal(provider.Secret, os.Getenv("TWITTER_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), twitterProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := twitterProvider() - session, err := provider.BeginAuth("state") - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "authorize?oauth_token=TOKEN") - a.Equal("TOKEN", s.RequestToken.Token) - a.Equal("SECRET", s.RequestToken.Secret) - - provider = twitterProviderAuthenticate() - session, err = provider.BeginAuth("state") - s = session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "authenticate?oauth_token=TOKEN") - a.Equal("TOKEN", s.RequestToken.Token) - a.Equal("SECRET", s.RequestToken.Secret) -} - -func Test_FetchUser(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := twitterProvider() - session := Session{AccessToken: &oauth.AccessToken{Token: "TOKEN", Secret: "SECRET"}} - - user, err := provider.FetchUser(&session) - a.NoError(err) - - a.Equal("Homer", user.Name) - a.Equal("duffman", user.NickName) - a.Equal("Duff rules!!", user.Description) - a.Equal("http://example.com/image.jpg", user.AvatarURL) - a.Equal("1234", user.UserID) - a.Equal("Springfield", user.Location) - a.Equal("TOKEN", user.AccessToken) - a.Equal("duffman@springfield.com", user.Email) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := twitterProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) - a.NoError(err) - session := s.(*Session) - a.Equal(session.AuthURL, "http://com/auth_url") - a.Equal(session.AccessToken.Token, "1234567890") - a.Equal(session.AccessToken.Secret, "secret!!") - a.Equal(session.RequestToken.Token, "0987654321") - a.Equal(session.RequestToken.Secret, "!!secret") -} - -func twitterProvider() *Provider { - return New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") -} - -func twitterProviderAuthenticate() *Provider { - return NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") -} - -func init() { - p := pat.New() - p.Get("/oauth/request_token", func(res http.ResponseWriter, req *http.Request) { - fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") - }) - p.Get("/1.1/account/verify_credentials.json", func(res http.ResponseWriter, req *http.Request) { - data := map[string]string{ - "name": "Homer", - "screen_name": "duffman", - "description": "Duff rules!!", - "profile_image_url": "http://example.com/image.jpg", - "id_str": "1234", - "location": "Springfield", - "email": "duffman@springfield.com", - } - json.NewEncoder(res).Encode(&data) - }) - ts := httptest.NewServer(p) - - requestURL = ts.URL + "/oauth/request_token" - endpointProfile = ts.URL + "/1.1/account/verify_credentials.json" -} diff --git a/providers/twitterv2/session.go b/providers/twitterv2/session.go index ef298dde7..47fa4e384 100644 --- a/providers/twitterv2/session.go +++ b/providers/twitterv2/session.go @@ -4,16 +4,19 @@ import ( "encoding/json" "errors" "strings" + "time" "github.com/markbates/goth" - "github.com/mrjones/oauth" + "golang.org/x/oauth2" ) // Session stores data during the auth process with Twitter. type Session struct { AuthURL string - AccessToken *oauth.AccessToken - RequestToken *oauth.RequestToken + AccessToken string + RefreshToken string + ExpiresAt time.Time + CodeVerifier string } // GetAuthURL will return the URL set by calling the `BeginAuth` function on the Twitter provider. @@ -27,13 +30,25 @@ func (s Session) GetAuthURL() (string, error) { // Authorize the session with Twitter and return the access token to be stored for future use. func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { p := provider.(*Provider) - accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) + + opts := []oauth2.AuthCodeOption{} + if s.CodeVerifier != "" { + opts = append(opts, oauth2.VerifierOption(s.CodeVerifier)) + } + + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"), opts...) if err != nil { return "", err } - s.AccessToken = accessToken - return accessToken.Token, err + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err } // Marshal the session into a string diff --git a/providers/twitterv2/session_test.go b/providers/twitterv2/session_test.go index 9ef101a5c..1794503a1 100644 --- a/providers/twitterv2/session_test.go +++ b/providers/twitterv2/session_test.go @@ -36,7 +36,7 @@ func Test_ToJSON(t *testing.T) { s := &twitterv2.Session{} data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":null,"RequestToken":null}`) + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","CodeVerifier":""}`) } func Test_String(t *testing.T) { diff --git a/providers/twitterv2/twitterv2.go b/providers/twitterv2/twitterv2.go index ee04ca72a..3718b786e 100644 --- a/providers/twitterv2/twitterv2.go +++ b/providers/twitterv2/twitterv2.go @@ -1,33 +1,25 @@ -// Package twitterv2 implements the OAuth protocol for authenticating users through Twitter. -// This package can be used as a reference implementation of an OAuth provider for Goth. package twitterv2 import ( "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" "github.com/markbates/goth" - "github.com/mrjones/oauth" "golang.org/x/oauth2" ) var ( - requestURL = "https://api.twitter.com/oauth/request_token" - authorizeURL = "https://api.twitter.com/oauth/authorize" - authenticateURL = "https://api.twitter.com/oauth/authenticate" - tokenURL = "https://api.twitter.com/oauth/access_token" + AuthURL = "https://twitter.com/i/oauth2/authorize" + TokenURL = "https://api.twitter.com/2/oauth2/token" endpointProfile = "https://api.twitter.com/2/users/me" ) // New creates a new Twitter provider, and sets up important connection details. // You should always call `twitter.New` to get a new Provider. Never try to create // one manually. -// -// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead. func New(clientKey, secret, callbackURL string) *Provider { p := &Provider{ ClientKey: clientKey, @@ -35,20 +27,28 @@ func New(clientKey, secret, callbackURL string) *Provider { CallbackURL: callbackURL, providerName: "twitterv2", } - p.consumer = newConsumer(p, authorizeURL) + p.config = newConfig(p, []string{"users.read", "tweet.read", "offline.access"}) return p } -// NewAuthenticate is the almost same as New. -// NewAuthenticate uses the authenticate URL instead of the authorize URL. +// NewAuthenticate is the same as New for OAuth 2.0. +// Kept for backward compatibility. func NewAuthenticate(clientKey, secret, callbackURL string) *Provider { + return New(clientKey, secret, callbackURL) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { p := &Provider{ ClientKey: clientKey, Secret: secret, CallbackURL: callbackURL, providerName: "twitterv2", } - p.consumer = newConsumer(p, authenticateURL) + AuthURL = authURL + TokenURL = tokenURL + endpointProfile = profileURL + p.config = newConfig(p, scopes) return p } @@ -59,7 +59,7 @@ type Provider struct { CallbackURL string HTTPClient *http.Client debug bool - consumer *oauth.Consumer + config *oauth2.Config providerName string } @@ -83,32 +83,47 @@ func (p *Provider) Debug(debug bool) { } // BeginAuth asks Twitter for an authentication end-point and a request token for a session. -// Twitter does not support the "state" variable. +// Twitter uses PKCE for OAuth 2.0. func (p *Provider) BeginAuth(state string) (goth.Session, error) { - requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) + verifier := oauth2.GenerateVerifier() + + url := p.config.AuthCodeURL( + state, + oauth2.S256ChallengeOption(verifier), + ) session := &Session{ AuthURL: url, - RequestToken: requestToken, + CodeVerifier: verifier, } - return session, err + return session, nil } // FetchUser will go to Twitter and access basic information about the user. func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { sess := session.(*Session) user := goth.User{ - Provider: p.Name(), + Provider: p.Name(), + AccessToken: sess.AccessToken, + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, } - if sess.AccessToken == nil { + if sess.AccessToken == "" { // data is not yet retrieved since accessToken is still empty return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) } - response, err := p.consumer.Get( - endpointProfile, - map[string]string{"user.fields": "id,name,username,description,profile_image_url,location"}, - sess.AccessToken) + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + + q := req.URL.Query() + q.Add("user.fields", "id,name,username,description,profile_image_url,location") + req.URL.RawQuery = q.Encode() + + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) if err != nil { return user, err } @@ -133,41 +148,56 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { } user.RawData = userInfo.Data - user.Name = user.RawData["name"].(string) - user.NickName = user.RawData["username"].(string) + if user.RawData["name"] != nil { + user.Name = user.RawData["name"].(string) + } + if user.RawData["username"] != nil { + user.NickName = user.RawData["username"].(string) + } if user.RawData["description"] != nil { user.Description = user.RawData["description"].(string) } - user.AvatarURL = user.RawData["profile_image_url"].(string) - user.UserID = user.RawData["id"].(string) + if user.RawData["profile_image_url"] != nil { + user.AvatarURL = user.RawData["profile_image_url"].(string) + } + if user.RawData["id"] != nil { + user.UserID = user.RawData["id"].(string) + } if user.RawData["location"] != nil { user.Location = user.RawData["location"].(string) } - user.AccessToken = sess.AccessToken.Token - user.AccessTokenSecret = sess.AccessToken.Secret + return user, err } -func newConsumer(provider *Provider, authURL string) *oauth.Consumer { - c := oauth.NewConsumer( - provider.ClientKey, - provider.Secret, - oauth.ServiceProvider{ - RequestTokenUrl: requestURL, - AuthorizeTokenUrl: authURL, - AccessTokenUrl: tokenURL, - }) - - c.Debug(provider.debug) +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: AuthURL, + TokenURL: TokenURL, + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: scopes, + } + return c } -// RefreshToken refresh token is not provided by twitter -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by twitter") +// RefreshTokenAvailable refresh token is provided by twitter +func (p *Provider) RefreshTokenAvailable() bool { + return true } -// RefreshTokenAvailable refresh token is not provided by twitter -func (p *Provider) RefreshTokenAvailable() bool { - return false +// RefreshToken get a new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err } diff --git a/providers/twitterv2/twitterv2_test.go b/providers/twitterv2/twitterv2_test.go index c7649aafa..51507729d 100644 --- a/providers/twitterv2/twitterv2_test.go +++ b/providers/twitterv2/twitterv2_test.go @@ -2,15 +2,14 @@ package twitterv2 import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" "os" "testing" + "time" "github.com/gorilla/pat" "github.com/markbates/goth" - "github.com/mrjones/oauth" "github.com/stretchr/testify/assert" ) @@ -39,17 +38,19 @@ func Test_BeginAuth(t *testing.T) { session, err := provider.BeginAuth("state") s := session.(*Session) a.NoError(err) - a.Contains(s.AuthURL, "authorize?oauth_token=TOKEN") - a.Equal("TOKEN", s.RequestToken.Token) - a.Equal("SECRET", s.RequestToken.Secret) + a.Contains(s.AuthURL, "twitter.com/i/oauth2/authorize") + a.Contains(s.AuthURL, "code_challenge=") + a.Contains(s.AuthURL, "code_challenge_method=S256") + a.NotEmpty(s.CodeVerifier) provider = twitterProviderAuthenticate() session, err = provider.BeginAuth("state") s = session.(*Session) a.NoError(err) - a.Contains(s.AuthURL, "authenticate?oauth_token=TOKEN") - a.Equal("TOKEN", s.RequestToken.Token) - a.Equal("SECRET", s.RequestToken.Secret) + a.Contains(s.AuthURL, "twitter.com/i/oauth2/authorize") + a.Contains(s.AuthURL, "code_challenge=") + a.Contains(s.AuthURL, "code_challenge_method=S256") + a.NotEmpty(s.CodeVerifier) } func Test_FetchUser(t *testing.T) { @@ -57,7 +58,7 @@ func Test_FetchUser(t *testing.T) { a := assert.New(t) provider := twitterProvider() - session := Session{AccessToken: &oauth.AccessToken{Token: "TOKEN", Secret: "SECRET"}} + session := Session{AccessToken: "TOKEN", RefreshToken: "REFRESH", ExpiresAt: time.Now()} user, err := provider.FetchUser(&session) a.NoError(err) @@ -69,7 +70,8 @@ func Test_FetchUser(t *testing.T) { a.Equal("1234", user.UserID) a.Equal("Springfield", user.Location) a.Equal("TOKEN", user.AccessToken) - a.Equal("", user.Email) + a.Equal("REFRESH", user.RefreshToken) + a.Equal("", user.Email) // email is not strictly mapped right now natively } func Test_SessionFromJSON(t *testing.T) { @@ -78,14 +80,13 @@ func Test_SessionFromJSON(t *testing.T) { provider := twitterProvider() - s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) + s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":"1234567890","RefreshToken":"refresh","CodeVerifier":"verifier"}`) a.NoError(err) session := s.(*Session) a.Equal(session.AuthURL, "http://com/auth_url") - a.Equal(session.AccessToken.Token, "1234567890") - a.Equal(session.AccessToken.Secret, "secret!!") - a.Equal(session.RequestToken.Token, "0987654321") - a.Equal(session.RequestToken.Secret, "!!secret") + a.Equal(session.AccessToken, "1234567890") + a.Equal(session.RefreshToken, "refresh") + a.Equal(session.CodeVerifier, "verifier") } func twitterProvider() *Provider { @@ -98,9 +99,6 @@ func twitterProviderAuthenticate() *Provider { func init() { p := pat.New() - p.Get("/oauth/request_token", func(res http.ResponseWriter, req *http.Request) { - fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") - }) p.Get("/2/users/me", func(res http.ResponseWriter, req *http.Request) { data := map[string]interface{}{ "data": map[string]string{ @@ -117,6 +115,5 @@ func init() { }) ts := httptest.NewServer(p) - requestURL = ts.URL + "/oauth/request_token" endpointProfile = ts.URL + "/2/users/me" } diff --git a/providers/typetalk/session.go b/providers/typetalk/session.go deleted file mode 100644 index 9d5d5cf94..000000000 --- a/providers/typetalk/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package typetalk - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Typetalk. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Typetalk provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Typetalk and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/typetalk/session_test.go b/providers/typetalk/session_test.go deleted file mode 100644 index cf4bcee2b..000000000 --- a/providers/typetalk/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package typetalk_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/typetalk" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &typetalk.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &typetalk.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &typetalk.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &typetalk.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/typetalk/typetalk.go b/providers/typetalk/typetalk.go deleted file mode 100644 index 9822cb475..000000000 --- a/providers/typetalk/typetalk.go +++ /dev/null @@ -1,205 +0,0 @@ -// Package typetalk implements the OAuth2 protocol for authenticating users through Typetalk. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -// -// Typetalk API Docs: https://developer.nulab-inc.com/docs/typetalk/auth/ -package typetalk - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://typetalk.com/oauth2/authorize" - tokenURL string = "https://typetalk.com/oauth2/access_token" - endpointProfile string = "https://typetalk.com/api/v1/profile" - endpointUser string = "https://typetalk.com/api/v1/accounts/profile/" -) - -// Provider is the implementation of `goth.Provider` for accessing Typetalk. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Typetalk provider and sets up important connection details. -// You should always call `typetalk.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "typetalk", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers os 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client returns HTTP client. -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the typetalk package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Typetalk for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Typetalk and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - // Get username - response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user name", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - u := struct { - Account struct { - Name string `json:"name"` - } `json:"account"` - }{} - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u) - if err != nil { - return user, err - } - - // Get user profile info - response, err = p.Client().Get(endpointUser + u.Account.Name + "?access_token=" + url.QueryEscape(sess.AccessToken)) - if err != nil { - if response != nil { - response.Body.Close() - } - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch profile", p.providerName, response.StatusCode) - } - - bits, err = io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "my") - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - FullName string `json:"fullName"` - Suggestion string `json:"suggestion"` - MailAddress string `json:"mailAddress"` - ImageURL string `json:"imageUrl"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - ImageUpdatedAt string `json:"imageUpdatedAt"` - } `json:"account"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.UserID = strconv.FormatInt(u.Account.ID, 10) - user.Email = u.Account.MailAddress - user.Name = u.Account.FullName - user.NickName = u.Account.Name - user.AvatarURL = u.Account.ImageURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, nil -} diff --git a/providers/typetalk/typetalk_test.go b/providers/typetalk/typetalk_test.go deleted file mode 100644 index f09f361a1..000000000 --- a/providers/typetalk/typetalk_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package typetalk_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/typetalk" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("TYPETALK_KEY")) - a.Equal(p.Secret, os.Getenv("TYPETALK_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*typetalk.Session) - a.NoError(err) - a.Contains(s.AuthURL, "typetalk.com/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://typetalk.com/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*typetalk.Session) - a.Equal(s.AuthURL, "https://typetalk.com/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *typetalk.Provider { - return typetalk.New(os.Getenv("TYPETALK_KEY"), os.Getenv("TYPETALK_SECRET"), "/foo") -} diff --git a/providers/uber/session.go b/providers/uber/session.go deleted file mode 100644 index 41dabc361..000000000 --- a/providers/uber/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package uber - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Uber. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Uber provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Uber and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/uber/session_test.go b/providers/uber/session_test.go deleted file mode 100644 index e8ffa0b73..000000000 --- a/providers/uber/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package uber_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/uber" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &uber.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &uber.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &uber.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &uber.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/uber/uber.go b/providers/uber/uber.go deleted file mode 100644 index de48df81c..000000000 --- a/providers/uber/uber.go +++ /dev/null @@ -1,161 +0,0 @@ -// Package uber implements the OAuth2 protocol for authenticating users through uber. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package uber - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://login.uber.com/oauth/authorize" - tokenURL string = "https://login.uber.com/oauth/token" - endpointProfile string = "https://api.uber.com/v1/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Uber. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Uber provider and sets up important connection details. -// You should always call `uber.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "uber", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the uber package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Uber for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Uber and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "profile") - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Name string `json:"first_name"` - Email string `json:"email"` - ID string `json:"uuid"` - AvatarURL string `json:"picture"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.UserID = u.ID - user.AvatarURL = u.AvatarURL - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/uber/uber_test.go b/providers/uber/uber_test.go deleted file mode 100644 index efd2d8114..000000000 --- a/providers/uber/uber_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package uber_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/uber" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("UBER_KEY")) - a.Equal(p.Secret, os.Getenv("UBER_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*uber.Session) - a.NoError(err) - a.Contains(s.AuthURL, "login.uber.com/oauth/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://login.uber.com/oauth/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*uber.Session) - a.Equal(s.AuthURL, "https://login.uber.com/oauth/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *uber.Provider { - return uber.New(os.Getenv("UBER_KEY"), os.Getenv("UBER_SECRET"), "/foo") -} diff --git a/providers/vk/session.go b/providers/vk/session.go deleted file mode 100644 index 4331a4afa..000000000 --- a/providers/vk/session.go +++ /dev/null @@ -1,62 +0,0 @@ -package vk - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with VK. -type Session struct { - AuthURL string - AccessToken string - ExpiresAt time.Time - email string -} - -// GetAuthURL returns the URL for the authentication end-point for the provider. -func (s *Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Marshal the session into a string -func (s *Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -// Authorize the session with VK and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - email, ok := token.Extra("email").(string) - if !ok { - return "", errors.New("Cannot fetch user email") - } - - s.AccessToken = token.AccessToken - s.ExpiresAt = token.Expiry - s.email = email - return s.AccessToken, err -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := new(Session) - err := json.NewDecoder(strings.NewReader(data)).Decode(&sess) - return sess, err -} diff --git a/providers/vk/session_test.go b/providers/vk/session_test.go deleted file mode 100644 index 53abf246e..000000000 --- a/providers/vk/session_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package vk_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/vk" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &vk.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &vk.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &vk.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} diff --git a/providers/vk/vk.go b/providers/vk/vk.go deleted file mode 100644 index f0619f2e5..000000000 --- a/providers/vk/vk.go +++ /dev/null @@ -1,183 +0,0 @@ -// Package vk implements the OAuth2 protocol for authenticating users through vk.com. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package vk - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -var ( - authURL = "https://oauth.vk.com/authorize" - tokenURL = "https://oauth.vk.com/access_token" - endpointUser = "https://api.vk.com/method/users.get" - apiVersion = "5.131" -) - -// New creates a new VK provider and sets up important connection details. -// You should always call `vk.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "vk", - } - p.config = newConfig(p, scopes) - return p -} - -// Provider is the implementation of `goth.Provider` for accessing VK. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string - version string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// BeginAuth asks VK for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - url := p.config.AuthCodeURL(state) - session := &Session{ - AuthURL: url, - } - - return session, nil -} - -// FetchUser will go to VK and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - ExpiresAt: sess.ExpiresAt, - Email: sess.email, - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - fields := "photo_200,nickname" - requestURL := fmt.Sprintf("%s?fields=%s&access_token=%s&v=%s", endpointUser, fields, sess.AccessToken, apiVersion) - response, err := p.Client().Get(requestURL) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func userFromReader(reader io.Reader, user *goth.User) error { - response := struct { - Response []struct { - ID int64 `json:"id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - NickName string `json:"nickname"` - Photo200 string `json:"photo_200"` - } `json:"response"` - }{} - - err := json.NewDecoder(reader).Decode(&response) - if err != nil { - return err - } - - if len(response.Response) == 0 { - return fmt.Errorf("vk cannot get user information") - } - - u := response.Response[0] - - user.UserID = strconv.FormatInt(u.ID, 10) - user.FirstName = u.FirstName - user.LastName = u.LastName - user.NickName = u.NickName - user.AvatarURL = u.Photo200 - - return err -} - -// Debug is a no-op for the vk package. -func (p *Provider) Debug(debug bool) {} - -// RefreshToken refresh token is not provided by vk -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by vk") -} - -// RefreshTokenAvailable refresh token is not provided by vk -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{ - "email", - }, - } - - defaultScopes := map[string]struct{}{ - "email": {}, - } - - for _, scope := range scopes { - if _, exists := defaultScopes[scope]; !exists { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} diff --git a/providers/vk/vk_test.go b/providers/vk/vk_test.go deleted file mode 100644 index a4310b79c..000000000 --- a/providers/vk/vk_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package vk_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/vk" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := vkProvider() - a.Equal(provider.ClientKey, os.Getenv("VK_KEY")) - a.Equal(provider.Secret, os.Getenv("VK_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Name(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := vkProvider() - a.Equal(provider.Name(), "vk") -} - -func Test_SetName(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := vkProvider() - provider.SetName("foo") - a.Equal(provider.Name(), "foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), vkProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := vkProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*vk.Session) - a.NoError(err) - a.Contains(s.AuthURL, "oauth.vk.com/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("VK_KEY"))) - a.Contains(s.AuthURL, "state=test_state") - a.Contains(s.AuthURL, "scope=email") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := vkProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://vk.com/auth_url","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*vk.Session) - a.Equal(session.AuthURL, "http://vk.com/auth_url") - a.Equal(session.AccessToken, "1234567890") -} - -func vkProvider() *vk.Provider { - return vk.New(os.Getenv("VK_KEY"), os.Getenv("VK_SECRET"), "/foo", "user") -} diff --git a/providers/wechat/session.go b/providers/wechat/session.go deleted file mode 100644 index ab0330a77..000000000 --- a/providers/wechat/session.go +++ /dev/null @@ -1,67 +0,0 @@ -package wechat - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Wechat. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time - Openid string - Unionid string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Wepay provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Wepay and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, openid, err := p.fetchToken(params.Get("code")) - - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - s.Openid = openid - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/wechat/session_test.go b/providers/wechat/session_test.go deleted file mode 100644 index 128473a3a..000000000 --- a/providers/wechat/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package wechat_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/wechat" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wechat.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wechat.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wechat.Session{} - - data := s.Marshal() - a.Equal(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","Openid":"","Unionid":""}`, data) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wechat.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/wechat/wechat.go b/providers/wechat/wechat.go deleted file mode 100644 index 416b5eb04..000000000 --- a/providers/wechat/wechat.go +++ /dev/null @@ -1,237 +0,0 @@ -package wechat - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - AuthURL = "https://open.weixin.qq.com/connect/qrconnect" - TokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token" - - ScopeSnsapiLogin = "snsapi_login" - - ProfileURL = "https://api.weixin.qq.com/sns/userinfo" -) - -type Provider struct { - providerName string - config *oauth2.Config - httpClient *http.Client - ClientID string - ClientSecret string - RedirectURL string - Lang WechatLangType - - AuthURL string - TokenURL string - ProfileURL string -} - -type WechatLangType string - -const ( - WECHAT_LANG_CN WechatLangType = "cn" - WECHAT_LANG_EN WechatLangType = "en" -) - -// New creates a new Wechat provider, and sets up important connection details. -// You should always call `wechat.New` to get a new Provider. Never try to create -// one manually. -func New(clientID, clientSecret, redirectURL string, lang WechatLangType) *Provider { - p := &Provider{ - providerName: "wechat", - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: redirectURL, - Lang: lang, - AuthURL: AuthURL, - TokenURL: TokenURL, - ProfileURL: ProfileURL, - } - p.config = newConfig(p) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.httpClient) -} - -// Debug is a no-op for the wechat package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Wechat for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - params := url.Values{} - params.Add("appid", p.ClientID) - params.Add("response_type", "code") - params.Add("state", state) - params.Add("scope", ScopeSnsapiLogin) - params.Add("redirect_uri", p.RedirectURL) - session := &Session{ - AuthURL: fmt.Sprintf("%s?%s", p.AuthURL, params.Encode()), - } - return session, nil -} - -// FetchUser will go to Wepay and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - params := url.Values{} - params.Add("access_token", s.AccessToken) - params.Add("openid", s.Openid) - params.Add("lang", string(p.Lang)) - - url := fmt.Sprintf("%s?%s", p.ProfileURL, params.Encode()) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return user, err - } - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientID, - ClientSecret: provider.ClientSecret, - RedirectURL: provider.RedirectURL, - Endpoint: oauth2.Endpoint{ - AuthURL: provider.AuthURL, - TokenURL: provider.TokenURL, - }, - Scopes: []string{}, - } - - c.Scopes = append(c.Scopes, ScopeSnsapiLogin) - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Openid string `json:"openid"` - Nickname string `json:"nickname"` - Sex int `json:"sex"` - Province string `json:"province"` - City string `json:"city"` - Country string `json:"country"` - AvatarURL string `json:"headimgurl"` - Unionid string `json:"unionid"` - Code int `json:"errcode"` - Msg string `json:"errmsg"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - - if len(u.Msg) > 0 { - return fmt.Errorf("CODE: %d, MSG: %s", u.Code, u.Msg) - } - - user.Email = fmt.Sprintf("%s@wechat.com", u.Openid) - user.Name = u.Nickname - user.UserID = u.Openid - user.NickName = u.Nickname - user.Location = u.City - user.AvatarURL = u.AvatarURL - user.RawData = map[string]interface{}{ - "Unionid": u.Unionid, - } - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - - return nil, nil -} - -func (p *Provider) fetchToken(code string) (*oauth2.Token, string, error) { - - params := url.Values{} - params.Add("appid", p.ClientID) - params.Add("secret", p.ClientSecret) - params.Add("grant_type", "authorization_code") - params.Add("code", code) - url := fmt.Sprintf("%s?%s", p.TokenURL, params.Encode()) - resp, err := p.Client().Get(url) - - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, "", fmt.Errorf("wechat /gettoken returns code: %d", resp.StatusCode) - } - - obj := struct { - AccessToken string `json:"access_token"` - ExpiresIn time.Duration `json:"expires_in"` - Openid string `json:"openid"` - Code int `json:"errcode"` - Msg string `json:"errmsg"` - }{} - if err = json.NewDecoder(resp.Body).Decode(&obj); err != nil { - return nil, "", err - } - if obj.Code != 0 { - return nil, "", fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) - } - - token := &oauth2.Token{ - AccessToken: obj.AccessToken, - Expiry: time.Now().Add(obj.ExpiresIn * time.Second), - } - - return token, obj.Openid, nil -} diff --git a/providers/wechat/wechat_test.go b/providers/wechat/wechat_test.go deleted file mode 100644 index 317f2112d..000000000 --- a/providers/wechat/wechat_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package wechat_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/wechat" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientID, os.Getenv("WECHAT_KEY")) - a.Equal(p.ClientSecret, os.Getenv("WECHAT_SECRET")) - a.Equal(p.RedirectURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*wechat.Session) - a.NoError(err) - a.Contains(s.AuthURL, "open.weixin.qq.com/connect/qrconnect") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://open.weixin.qq.com/connect/qrconnect","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*wechat.Session) - a.Equal(s.AuthURL, "https://open.weixin.qq.com/connect/qrconnect") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *wechat.Provider { - return wechat.New(os.Getenv("WECHAT_KEY"), os.Getenv("WECHAT_SECRET"), "/foo", wechat.WECHAT_LANG_CN) -} diff --git a/providers/wecom/session.go b/providers/wecom/session.go deleted file mode 100644 index 49ae5ba2a..000000000 --- a/providers/wecom/session.go +++ /dev/null @@ -1,55 +0,0 @@ -package wecom - -import ( - "encoding/json" - "errors" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with WeCom. -type Session struct { - AuthURL string - AccessToken string - UserID string -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the WeCom provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with WeCom and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.fetchToken() - if err != nil { - return "", err - } - s.AccessToken = token.AccessToken - - userID, err := p.fetchUserID(s, params.Get("code")) - if err != nil { - return "", err - } - s.UserID = userID - - return s.AccessToken, nil -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/wecom/session_test.go b/providers/wecom/session_test.go deleted file mode 100644 index 2ba260aed..000000000 --- a/providers/wecom/session_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package wecom_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/wecom" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wecom.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wecom.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_Marshal(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wecom.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","UserID":""}`) -} diff --git a/providers/wecom/wecom.go b/providers/wecom/wecom.go deleted file mode 100644 index c30cf5c0e..000000000 --- a/providers/wecom/wecom.go +++ /dev/null @@ -1,217 +0,0 @@ -// Package wecom implements the qrConnect protocol for authenticating users through WeCom. -// Reference: https://work.weixin.qq.com/api/doc/90000/90135/90988 -package wecom - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -var ( - AuthURL = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect" - BaseURL = "https://qyapi.weixin.qq.com/cgi-bin" -) - -// New creates a new WeCom provider, and sets up important connection details. -func New(corpID, secret, agentID, callbackURL string) *Provider { - return &Provider{ - ClientKey: corpID, - Secret: secret, - AgentID: agentID, - CallbackURL: callbackURL, - providerName: "wecom", - authURL: AuthURL, - baseURL: BaseURL, - } -} - -// Provider is the implementation of `goth.Provider` for accessing WeCom. -type Provider struct { - ClientKey string - Secret string - AgentID string - CallbackURL string - HTTPClient *http.Client - providerName string - - // token caches the access_token - token *oauth2.Token - - authURL string - baseURL string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the wecom package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks WeCom for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - params := url.Values{} - params.Add("appid", p.ClientKey) - params.Add("agentid", p.AgentID) - params.Add("state", state) - params.Add("redirect_uri", p.CallbackURL) - session := &Session{ - AuthURL: fmt.Sprintf("%s?%s", p.authURL, params.Encode()), - } - return session, nil -} - -// FetchUser will go to WeCom and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - } - - if user.AccessToken == "" { - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - params := url.Values{} - params.Add("access_token", user.AccessToken) - params.Add("userid", sess.UserID) - resp, err := p.Client().Get(fmt.Sprintf("%s/user/get?%s", p.baseURL, params.Encode())) - if err != nil { - return user, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("wecom /user/get returns code: %d", resp.StatusCode) - } - - if err := userFromReader(resp.Body, &user); err != nil { - return user, err - } - - return user, nil -} - -// RefreshToken refresh token is not provided by WeCom -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("refresh token is not provided by wecom") -} - -// RefreshTokenAvailable refresh token is not provided by WeCom -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -func (p *Provider) fetchToken() (*oauth2.Token, error) { - if p.token != nil && p.token.Valid() { - return p.token, nil - } - - params := url.Values{} - params.Add("corpid", p.ClientKey) - params.Add("corpsecret", p.Secret) - resp, err := p.Client().Get(fmt.Sprintf("%s/gettoken?%s", p.baseURL, params.Encode())) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("wecom /gettoken returns code: %d", resp.StatusCode) - } - - obj := struct { - AccessToken string `json:"access_token"` - ExpiresIn time.Duration `json:"expires_in"` - Code int `json:"errcode"` - Msg string `json:"errmsg"` - }{} - if err = json.NewDecoder(resp.Body).Decode(&obj); err != nil { - return nil, err - } - if obj.Code != 0 { - return nil, fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) - } - - p.token = &oauth2.Token{ - AccessToken: obj.AccessToken, - Expiry: time.Now().Add(obj.ExpiresIn * time.Second), - } - - return p.token, nil -} - -func (p *Provider) fetchUserID(session goth.Session, code string) (string, error) { - sess := session.(*Session) - params := url.Values{} - params.Add("access_token", sess.AccessToken) - params.Add("code", code) - resp, err := p.Client().Get(fmt.Sprintf("%s/user/getuserinfo?%s", p.baseURL, params.Encode())) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("wecom /getuserinfo returns code: %d", resp.StatusCode) - } - - obj := struct { - UserId string `json:"UserId"` - Code int `json:"errcode"` - Msg string `json:"errmsg"` - }{} - if err = json.NewDecoder(resp.Body).Decode(&obj); err != nil { - return "", err - } - if obj.Code != 0 { - return "", fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) - } - - return obj.UserId, nil -} - -func userFromReader(reader io.Reader, user *goth.User) error { - obj := struct { - UserId string `json:"userid"` - Name string `json:"name"` - Email string `json:"email"` - Alias string `json:"alias"` - Avatar string `json:"avatar"` - Address string `json:"address"` - Code int `json:"errcode"` - Msg string `json:"errmsg"` - }{} - - if err := json.NewDecoder(reader).Decode(&obj); err != nil { - return err - } - if obj.Code != 0 { - return fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) - } - - user.Name = obj.Name - user.NickName = obj.Alias - user.Email = obj.Email - user.UserID = obj.UserId - user.AvatarURL = obj.Avatar - user.Location = obj.Address - - return nil -} diff --git a/providers/wecom/wecom_test.go b/providers/wecom/wecom_test.go deleted file mode 100644 index 2e4457561..000000000 --- a/providers/wecom/wecom_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package wecom_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/wecom" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), wecomProvider()) -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := wecomProvider() - a.Equal(provider.ClientKey, os.Getenv("WECOM_CORP_ID")) - a.Equal(provider.Secret, os.Getenv("WECOM_SECRET")) - a.Equal(provider.AgentID, os.Getenv("WECOM_AGENT_ID")) - a.Equal(provider.CallbackURL, "/foo") -} - -func TestBeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := wecomProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*wecom.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://open.work.weixin.qq.com/wwopen/sso/qrConnect") - a.Contains(s.AuthURL, fmt.Sprintf("appid=%s", os.Getenv("WECOM_CORP_ID"))) - a.Contains(s.AuthURL, fmt.Sprintf("agentid=%s", os.Getenv("WECOM_AGENT_ID"))) - a.Contains(s.AuthURL, "state=test_state") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := wecomProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://wecom/auth_url","AccessToken":"1234567890","UserID":"1122334455"}`) - a.NoError(err) - session := s.(*wecom.Session) - a.Equal(session.AuthURL, "http://wecom/auth_url") - a.Equal(session.AccessToken, "1234567890") - a.Equal(session.UserID, "1122334455") -} - -func wecomProvider() *wecom.Provider { - return wecom.New(os.Getenv("WECOM_CORP_ID"), os.Getenv("WECOM_SECRET"), os.Getenv("WECOM_AGENT_ID"), "/foo") -} diff --git a/providers/wepay/session.go b/providers/wepay/session.go deleted file mode 100644 index 0316aec26..000000000 --- a/providers/wepay/session.go +++ /dev/null @@ -1,65 +0,0 @@ -package wepay - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Wepay. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Wepay provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Wepay and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - oauth2.RegisterBrokenAuthHeaderProvider(tokenURL) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/wepay/session_test.go b/providers/wepay/session_test.go deleted file mode 100644 index c2a483588..000000000 --- a/providers/wepay/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package wepay_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/wepay" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wepay.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wepay.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wepay.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &wepay.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/wepay/wepay.go b/providers/wepay/wepay.go deleted file mode 100644 index ad8fdf248..000000000 --- a/providers/wepay/wepay.go +++ /dev/null @@ -1,155 +0,0 @@ -// Package wepay implements the OAuth2 protocol for authenticating users through wepay. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package wepay - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.wepay.com/v2/oauth2/authorize" - tokenURL string = "https://wepayapi.com/v2/oauth2/token" - endpointProfile string = "https://wepayapi.com/v2/user" -) - -// Provider is the implementation of `goth.Provider` for accessing Wepay. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Wepay provider and sets up important connection details. -// You should always call `wepay.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "wepay", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the wepay package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Wepay for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Wepay and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - // Wepay is not recognising scope, if scope is not present as first parameter - newAuthURL := authURL - - if len(scopes) > 0 { - newAuthURL = newAuthURL + "?scope=" + strings.Join(scopes, ",") - } else { - newAuthURL = newAuthURL + "?scope=view_user" - } - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: newAuthURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - Email string `json:"email"` - UserName string `json:"user_name"` - ID int `json:"user_id"` - }{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.UserName - user.UserID = strconv.Itoa(u.ID) - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return false -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - - return nil, nil -} diff --git a/providers/wepay/wepay_test.go b/providers/wepay/wepay_test.go deleted file mode 100644 index c8a322920..000000000 --- a/providers/wepay/wepay_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package wepay_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/wepay" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("WEPAY_KEY")) - a.Equal(p.Secret, os.Getenv("WEPAY_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*wepay.Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.wepay.com/v2/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.wepay.com/v2/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*wepay.Session) - a.Equal(s.AuthURL, "https://www.wepay.com/v2/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *wepay.Provider { - return wepay.New(os.Getenv("WEPAY_KEY"), os.Getenv("WEPAY_SECRET"), "/foo") -} diff --git a/providers/xero/session.go b/providers/xero/session.go deleted file mode 100644 index c4ed58cc5..000000000 --- a/providers/xero/session.go +++ /dev/null @@ -1,61 +0,0 @@ -package xero - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "github.com/mrjones/oauth" -) - -// Session stores data during the auth process with Xero. -type Session struct { - AuthURL string - AccessToken *oauth.AccessToken - RequestToken *oauth.RequestToken - AccessTokenExpires time.Time -} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Xero provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Xero and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - if p.Method == "private" { - return p.ClientKey, nil - } - accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) - if err != nil { - return "", err - } - - s.AccessTokenExpires = time.Now().UTC().Add(30 * time.Minute) - s.AccessToken = accessToken - - return accessToken.Token, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) - return sess, err -} diff --git a/providers/xero/session_test.go b/providers/xero/session_test.go deleted file mode 100644 index 326245655..000000000 --- a/providers/xero/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package xero_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/xero" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &xero.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &xero.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &xero.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":null,"RequestToken":null,"AccessTokenExpires":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &xero.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/xero/xero.go b/providers/xero/xero.go deleted file mode 100644 index 621bd6bc8..000000000 --- a/providers/xero/xero.go +++ /dev/null @@ -1,260 +0,0 @@ -// Package xero implements the OAuth protocol for authenticating users through Xero. -package xero - -import ( - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "io" - "log" - "net/http" - "os" - "time" - - "golang.org/x/oauth2" - - "github.com/markbates/goth" - "github.com/mrjones/oauth" -) - -// Organisation is the expected response from the Organisation endpoint - this is not a complete schema -type Organisation struct { - // Display name of organisation shown in Xero - Name string `json:"Name,omitempty"` - - // Organisation name shown on Reports - LegalName string `json:"LegalName,omitempty"` - - // Organisation Type - OrganisationType string `json:"OrganisationType,omitempty"` - - // Country code for organisation. See ISO 3166-2 Country Codes - CountryCode string `json:"CountryCode,omitempty"` - - // A unique identifier for the organisation. Potential uses. - ShortCode string `json:"ShortCode,omitempty"` -} - -// APIResponse is the Total response from the Xero API -type APIResponse struct { - Organisations []Organisation `json:"Organisations,omitempty"` -} - -var ( - requestURL = "https://api.xero.com/oauth/RequestToken" - authorizeURL = "https://api.xero.com/oauth/Authorize" - tokenURL = "https://api.xero.com/oauth/AccessToken" - endpointProfile = "https://api.xero.com/api.xro/2.0/" - // userAgentString should be changed to match the name of your Application - userAgentString = os.Getenv("XERO_USER_AGENT") + " (goth-xero 1.0)" - privateKeyFilePath = os.Getenv("XERO_PRIVATE_KEY_PATH") -) - -// New creates a new Xero provider, and sets up important connection details. -// You should always call `xero.New` to get a new Provider. Never try to create -// one manually. -func New(clientKey, secret, callbackURL string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - // Method determines how you will connect to Xero. - // Options are public, private, and partner - // Use public if this is your first time. - // More details here: https://developer.xero.com/documentation/getting-started/api-application-types - Method: os.Getenv("XERO_METHOD"), - providerName: "xero", - } - - switch p.Method { - case "private": - p.consumer = newPrivateOrPartnerConsumer(p, authorizeURL) - case "public": - p.consumer = newPublicConsumer(p, authorizeURL) - case "partner": - p.consumer = newPrivateOrPartnerConsumer(p, authorizeURL) - default: - p.consumer = newPublicConsumer(p, authorizeURL) - } - return p -} - -// Provider is the implementation of `goth.Provider` for accessing Xero. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - Method string - debug bool - consumer *oauth.Consumer - providerName string -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Client does pretty much everything -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug sets the logging of the OAuth client to verbose. -func (p *Provider) Debug(debug bool) { - p.debug = debug -} - -// BeginAuth asks Xero for an authentication end-point and a request token for a session. -// Xero does not support the "state" variable. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) - if err != nil { - return nil, err - } - session := &Session{ - AuthURL: url, - RequestToken: requestToken, - } - return session, err -} - -// FetchUser will go to Xero and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - Provider: p.Name(), - } - - if sess.AccessToken == nil { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - response, err := p.consumer.Get( - endpointProfile+"Organisation", - nil, - sess.AccessToken) - - if err != nil { - return user, err - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - var apiResponse APIResponse - responseBytes, err := io.ReadAll(response.Body) - if err != nil { - return user, fmt.Errorf("Could not read response: %s", err.Error()) - } - if responseBytes == nil { - return user, fmt.Errorf("Received no response: %s", err.Error()) - } - err = json.Unmarshal(responseBytes, &apiResponse) - if err != nil { - return user, fmt.Errorf("Could not unmarshal response: %s", err.Error()) - } - - user.Name = apiResponse.Organisations[0].Name - user.NickName = apiResponse.Organisations[0].LegalName - user.Location = apiResponse.Organisations[0].CountryCode - user.Description = apiResponse.Organisations[0].OrganisationType - user.UserID = apiResponse.Organisations[0].ShortCode - - user.AccessToken = sess.AccessToken.Token - user.AccessTokenSecret = sess.AccessToken.Secret - user.ExpiresAt = sess.AccessTokenExpires - return user, err -} - -// newPublicConsumer creates a consumer capable of communicating with a Public application: https://developer.xero.com/documentation/auth-and-limits/public-applications -func newPublicConsumer(provider *Provider, authURL string) *oauth.Consumer { - c := oauth.NewConsumer( - provider.ClientKey, - provider.Secret, - oauth.ServiceProvider{ - RequestTokenUrl: requestURL, - AuthorizeTokenUrl: authURL, - AccessTokenUrl: tokenURL}, - ) - - c.Debug(provider.debug) - - accepttype := []string{"application/json"} - useragent := []string{userAgentString} - c.AdditionalHeaders = map[string][]string{ - "Accept": accepttype, - "User-Agent": useragent, - } - - return c -} - -// newPartnerConsumer creates a consumer capable of communicating with a Partner application: https://developer.xero.com/documentation/auth-and-limits/partner-applications -func newPrivateOrPartnerConsumer(provider *Provider, authURL string) *oauth.Consumer { - privateKeyFileContents, err := os.ReadFile(privateKeyFilePath) - if err != nil { - log.Fatal(err) - } - - block, _ := pem.Decode(privateKeyFileContents) - - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - log.Fatal(err) - } - c := oauth.NewRSAConsumer( - provider.ClientKey, - privateKey, - oauth.ServiceProvider{ - RequestTokenUrl: requestURL, - AuthorizeTokenUrl: authURL, - AccessTokenUrl: tokenURL}, - ) - - c.Debug(provider.debug) - - accepttype := []string{"application/json"} - useragent := []string{userAgentString} - c.AdditionalHeaders = map[string][]string{ - "Accept": accepttype, - "User-Agent": useragent, - } - - return c -} - -// RefreshOAuth1Token should be used instead of RefeshToken which is not compliant with the Oauth1.0a standard -func (p *Provider) RefreshOAuth1Token(session *Session) error { - newAccessToken, err := p.consumer.RefreshToken(session.AccessToken) - if err != nil { - return err - } - session.AccessToken = newAccessToken - session.AccessTokenExpires = time.Now().UTC().Add(30 * time.Minute) - return nil -} - -// RefreshToken refresh token is not provided by the Xero Public or Private Application - -// only the Partner Application and you must use RefreshOAuth1Token instead -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is only provided by Xero for Partner Applications") -} - -// RefreshTokenAvailable refresh token is not provided by the Xero Public or Private Application - -// only the Partner Application and you must use RefreshOAuth1Token instead -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/xero/xero_test.go b/providers/xero/xero_test.go deleted file mode 100644 index 17b2eb05f..000000000 --- a/providers/xero/xero_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package xero - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gorilla/pat" - "github.com/markbates/goth" - "github.com/mrjones/oauth" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := xeroProvider() - a.Equal(provider.ClientKey, os.Getenv("XERO_KEY")) - a.Equal(provider.Secret, os.Getenv("XERO_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - - a.Implements((*goth.Provider)(nil), xeroProvider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := xeroProvider() - session, err := provider.BeginAuth("state") - if err != nil { - a.Error(err, nil) - } - s := session.(*Session) - a.NoError(err) - a.Contains(s.AuthURL, "Authorize") - a.Equal("TOKEN", s.RequestToken.Token) - a.Equal("SECRET", s.RequestToken.Secret) -} - -func Test_FetchUser(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := xeroProvider() - session := Session{AccessToken: &oauth.AccessToken{Token: "TOKEN", Secret: "SECRET"}} - - user, err := provider.FetchUser(&session) - if err != nil { - a.Error(err, nil) - } - - a.NoError(err) - - a.Equal("Vanderlay Industries", user.Name) - a.Equal("Vanderlay Industries", user.NickName) - a.Equal("COMPANY", user.Description) - a.Equal("111-11", user.UserID) - a.Equal("NZ", user.Location) - a.Empty(user.Email) -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := xeroProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) - a.NoError(err) - session := s.(*Session) - a.Equal(session.AuthURL, "http://com/auth_url") - a.Equal(session.AccessToken.Token, "1234567890") - a.Equal(session.AccessToken.Secret, "secret!!") - a.Equal(session.RequestToken.Token, "0987654321") - a.Equal(session.RequestToken.Secret, "!!secret") -} - -func xeroProvider() *Provider { - return New(os.Getenv("XERO_KEY"), os.Getenv("XERO_SECRET"), "/foo") -} - -func init() { - p := pat.New() - p.Get("/oauth/RequestToken", func(res http.ResponseWriter, req *http.Request) { - fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") - }) - p.Get("/oauth/Authorize", func(res http.ResponseWriter, req *http.Request) { - fmt.Fprint(res, "DO NOT USE THIS ENDPOINT") - }) - p.Get("/oauth/AccessToken", func(res http.ResponseWriter, req *http.Request) { - fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") - }) - p.Get("/api.xro/2.0/Organisation", func(res http.ResponseWriter, req *http.Request) { - apiResponse := APIResponse{ - Organisations: []Organisation{ - {"Vanderlay Industries", "Vanderlay Industries", "COMPANY", "NZ", "111-11"}, - }, - } - - js, err := json.Marshal(apiResponse) - if err != nil { - fmt.Fprint(res, "Json did not Marshal") - } - - res.Write(js) - }) - - ts := httptest.NewServer(p) - - requestURL = ts.URL + "/oauth/RequestToken" - endpointProfile = ts.URL + "/api.xro/2.0/" - authorizeURL = ts.URL + "/oauth/Authorize" - tokenURL = ts.URL + "/oauth/AccessToken" -} diff --git a/providers/yahoo/session.go b/providers/yahoo/session.go deleted file mode 100644 index 3cacb5831..000000000 --- a/providers/yahoo/session.go +++ /dev/null @@ -1,63 +0,0 @@ -package yahoo - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Yahoo. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Yahoo provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Yahoo and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/yahoo/session_test.go b/providers/yahoo/session_test.go deleted file mode 100644 index 8f0be485a..000000000 --- a/providers/yahoo/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package yahoo_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/yahoo" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yahoo.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yahoo.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yahoo.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yahoo.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/yahoo/yahoo.go b/providers/yahoo/yahoo.go deleted file mode 100644 index e6a2065d4..000000000 --- a/providers/yahoo/yahoo.go +++ /dev/null @@ -1,166 +0,0 @@ -// Package yahoo implements the OAuth2 protocol for authenticating users through yahoo. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package yahoo - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://api.login.yahoo.com/oauth2/request_auth" - tokenURL string = "https://api.login.yahoo.com/oauth2/get_token" - endpointProfile string = "https://api.login.yahoo.com/openid/v1/userinfo" -) - -// Provider is the implementation of `goth.Provider` for accessing Yahoo. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Yahoo provider and sets up important connection details. -// You should always call `yahoo.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "yahoo", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the yahoo package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Yahoo for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Yahoo and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - ExpiresAt: s.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -type yahooUser struct { - Email string `json:"email"` - Name string `json:"name"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - Nickname string `json:"nickname"` - Picture string `json:"picture"` - Sub string `json:"sub"` -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := yahooUser{} - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.Email = u.Email - user.Name = u.Name - user.FirstName = u.GivenName - user.LastName = u.FamilyName - user.NickName = u.Nickname - user.AvatarURL = u.Picture - user.UserID = u.Sub - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/yahoo/yahoo_test.go b/providers/yahoo/yahoo_test.go deleted file mode 100644 index 7c42df5e2..000000000 --- a/providers/yahoo/yahoo_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package yahoo_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/yahoo" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("YAHOO_KEY")) - a.Equal(p.Secret, os.Getenv("YAHOO_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*yahoo.Session) - a.NoError(err) - a.Contains(s.AuthURL, "api.login.yahoo.com/oauth2/request_auth") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://api.login.yahoo.com/oauth2/request_auth","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*yahoo.Session) - a.Equal(s.AuthURL, "https://api.login.yahoo.com/oauth2/request_auth") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *yahoo.Provider { - return yahoo.New(os.Getenv("YAHOO_KEY"), os.Getenv("YAHOO_SECRET"), "/foo") -} diff --git a/providers/yammer/session.go b/providers/yammer/session.go deleted file mode 100644 index b39019ef9..000000000 --- a/providers/yammer/session.go +++ /dev/null @@ -1,109 +0,0 @@ -package yammer - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/markbates/goth" -) - -// Session stores data during the auth process with Yammer. -type Session struct { - AuthURL string - AccessToken string -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Yammer provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Yammer and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - v := url.Values{ - "grant_type": {"authorization_code"}, - "code": CondVal(params.Get("code")), - "redirect_uri": CondVal(p.config.RedirectURL), - "scope": CondVal(strings.Join(p.config.Scopes, " ")), - } - // Cant use standard auth2 implementation as yammer returns access_token as json rather than string - // stand methods are throwing exception - // token, err := p.config.Exchange(goth.ContextForClient(p.Client), params.Get("code")) - autData, err := retrieveAuthData(p, tokenURL, v) - if err != nil { - return "", err - } - token := autData["access_token"]["token"].(string) - s.AccessToken = token - return token, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// Custom implementation for yammer to get access token and user data -// Yammer provides user data along with access token, no separate api available -func retrieveAuthData(p *Provider, TokenURL string, v url.Values) (map[string]map[string]interface{}, error) { - v.Set("client_id", p.ClientKey) - v.Set("client_secret", p.Secret) - req, err := http.NewRequest("POST", TokenURL, strings.NewReader(v.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - r, err := p.Client().Do(req) - if err != nil { - return nil, err - } - defer r.Body.Close() - body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) - } - if code := r.StatusCode; code < 200 || code > 299 { - return nil, fmt.Errorf("oauth2: cannot fetch token: %v\nResponse: %s", r.Status, body) - } - - var objmap map[string]map[string]interface{} - - err = json.Unmarshal(body, &objmap) - - if err != nil { - return nil, err - } - return objmap, nil -} - -// CondVal convert string in string array -func CondVal(v string) []string { - if v == "" { - return nil - } - return []string{v} -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/yammer/session_test.go b/providers/yammer/session_test.go deleted file mode 100644 index bb6a1d4de..000000000 --- a/providers/yammer/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package yammer_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/yammer" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yammer.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yammer.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yammer.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":""}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yammer.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/yammer/yammer.go b/providers/yammer/yammer.go deleted file mode 100644 index bcc7a2168..000000000 --- a/providers/yammer/yammer.go +++ /dev/null @@ -1,160 +0,0 @@ -// Package yammer implements the OAuth2 protocol for authenticating users through yammer. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package yammer - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authURL string = "https://www.yammer.com/oauth2/authorize" - tokenURL string = "https://www.yammer.com/oauth2/access_token" - endpointProfile string = "https://www.yammer.com/api/v1/users/current.json" -) - -// Provider is the implementation of `goth.Provider` for accessing Yammer. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Yammer provider and sets up important connection details. -// You should always call `yammer.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "yammer", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the yammer package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Yammer for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Yammer and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", endpointProfile, nil) - if err != nil { - return user, err - } - - req.Header.Set("Authorization", "Bearer "+sess.AccessToken) - - response, err := p.Client().Do(req) - if err != nil { - return user, err - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) - } - - bits, err := io.ReadAll(response.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = populateUser(user.RawData, &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - return c -} - -func populateUser(userMap map[string]interface{}, user *goth.User) error { - user.Email = stringValue(userMap["email"]) - user.Name = stringValue(userMap["full_name"]) - user.NickName = stringValue(userMap["full_name"]) - user.UserID = strconv.FormatFloat(userMap["id"].(float64), 'f', -1, 64) - user.Location = stringValue(userMap["location"]) - return nil -} - -func stringValue(v interface{}) string { - if v == nil { - return "" - } - return v.(string) -} - -// RefreshToken refresh token is not provided by yammer -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - return nil, errors.New("Refresh token is not provided by yammer") -} - -// RefreshTokenAvailable refresh token is not provided by yammer -func (p *Provider) RefreshTokenAvailable() bool { - return false -} diff --git a/providers/yammer/yammer_test.go b/providers/yammer/yammer_test.go deleted file mode 100644 index 5d76a0150..000000000 --- a/providers/yammer/yammer_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package yammer_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/yammer" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("YAMMER_KEY")) - a.Equal(p.Secret, os.Getenv("YAMMER_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*yammer.Session) - a.NoError(err) - a.Contains(s.AuthURL, "www.yammer.com/oauth2/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://www.yammer.com/oauth2/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*yammer.Session) - a.Equal(s.AuthURL, "https://www.yammer.com/oauth2/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *yammer.Provider { - return yammer.New(os.Getenv("YAMMER_KEY"), os.Getenv("YAMMER_SECRET"), "/foo") -} diff --git a/providers/yandex/session.go b/providers/yandex/session.go deleted file mode 100644 index 587941664..000000000 --- a/providers/yandex/session.go +++ /dev/null @@ -1,64 +0,0 @@ -package yandex - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Yandex. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Yandex provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Yandex and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - s.ExpiresAt = token.Expiry - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession will unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/yandex/session_test.go b/providers/yandex/session_test.go deleted file mode 100644 index c52a97e67..000000000 --- a/providers/yandex/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package yandex_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/yandex" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yandex.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yandex.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yandex.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &yandex.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/yandex/yandex.go b/providers/yandex/yandex.go deleted file mode 100644 index a500564ea..000000000 --- a/providers/yandex/yandex.go +++ /dev/null @@ -1,182 +0,0 @@ -// package yandex implements the OAuth2 protocol for authenticating users through Yandex. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package yandex - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -const ( - authEndpoint string = "https://oauth.yandex.ru/authorize" - tokenEndpoint string = "https://oauth.yandex.com/token" - profileEndpoint string = "https://login.yandex.ru/info" - avatarURL string = "https://avatars.yandex.net/get-yapic" - avatarSize string = "islands-200" -) - -// Provider is the implementation of `goth.Provider` for accessing Yandex. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -// New creates a new Yandex provider and sets up important connection details. -// You should always call `yandex.New` to get a new provider. Never try to -// create one manually. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "yandex", - } - p.config = newConfig(p, scopes) - return p -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Name is the name used to retrieve this provider later. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -// Debug is a no-op for the yandex package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth asks Yandex for an authentication end-point. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser will go to Yandex and access basic information about the user. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - sess := session.(*Session) - user := goth.User{ - AccessToken: sess.AccessToken, - Provider: p.Name(), - RefreshToken: sess.RefreshToken, - ExpiresAt: sess.ExpiresAt, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", profileEndpoint, nil) - if err != nil { - return user, err - } - req.Header.Set("Authorization", "OAuth "+sess.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - if resp != nil { - resp.Body.Close() - } - return user, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - bits, err := io.ReadAll(resp.Body) - if err != nil { - return user, err - } - - err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) - if err != nil { - return user, err - } - - err = userFromReader(bytes.NewReader(bits), &user) - return user, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authEndpoint, - TokenURL: tokenEndpoint, - }, - Scopes: []string{}, - } - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } else { - c.Scopes = append(c.Scopes, "login:email login:info login:avatar") - } - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - u := struct { - UserID string `json:"id"` - Email string `json:"default_email"` - Login string `json:"login"` - Name string `json:"real_name"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - AvatarID string `json:"default_avatar_id"` - IsAvatarEmpty bool `json:"is_avatar_empty"` - }{} - - err := json.NewDecoder(r).Decode(&u) - if err != nil { - return err - } - user.UserID = u.UserID - user.Email = u.Email - user.NickName = u.Login - user.Name = u.Name - user.FirstName = u.FirstName - user.LastName = u.LastName - if u.AvatarID != `` { - user.AvatarURL = fmt.Sprintf("%s/%s/%s", avatarURL, u.AvatarID, avatarSize) - } - return nil -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} diff --git a/providers/yandex/yandex_test.go b/providers/yandex/yandex_test.go deleted file mode 100644 index c37ea3a16..000000000 --- a/providers/yandex/yandex_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package yandex_test - -import ( - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/yandex" - "github.com/stretchr/testify/assert" -) - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - - a.Equal(p.ClientKey, os.Getenv("YANDEX_KEY")) - a.Equal(p.Secret, os.Getenv("YANDEX_SECRET")) - a.Equal(p.CallbackURL, "/foo") -} - -func Test_Name(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - a.Equal(p.Name(), "yandex") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), provider()) -} - -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - p := provider() - session, err := p.BeginAuth("test_state") - s := session.(*yandex.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://oauth.yandex.ru/authorize") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - p := provider() - session, err := p.UnmarshalSession(`{"AuthURL":"https://oauth.yandex.ru/authorize","AccessToken":"1234567890"}`) - a.NoError(err) - - s := session.(*yandex.Session) - a.Equal(s.AuthURL, "https://oauth.yandex.ru/authorize") - a.Equal(s.AccessToken, "1234567890") -} - -func provider() *yandex.Provider { - return yandex.New(os.Getenv("YANDEX_KEY"), os.Getenv("YANDEX_SECRET"), "/foo") -} diff --git a/providers/zoom/session.go b/providers/zoom/session.go deleted file mode 100644 index 913f2d335..000000000 --- a/providers/zoom/session.go +++ /dev/null @@ -1,79 +0,0 @@ -package zoom - -import ( - "encoding/json" - "errors" - "strings" - "time" - - "github.com/markbates/goth" - "golang.org/x/oauth2" -) - -// Session stores data during the auth process with Zoom. -type Session struct { - AuthURL string - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -var _ goth.Session = &Session{} - -// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Zoom provider. -func (s Session) GetAuthURL() (string, error) { - if s.AuthURL == "" { - return "", errors.New(goth.NoAuthUrlErrorMessage) - } - return s.AuthURL, nil -} - -// Authorize the session with Zoom and return the access token to be stored for future use. -func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { - p := provider.(*Provider) - - var authParams []oauth2.AuthCodeOption - - // override redirect_uri if passed as param - redirectURL := params.Get("redirect_uri") - if redirectURL != "" { - authParams = append(authParams, oauth2.SetAuthURLParam("redirect_uri", redirectURL)) - } - - // set code_verifier if passed as param - codeVerifier := params.Get("code_verifier") - if codeVerifier != "" { - authParams = append(authParams, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) - } - - token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"), authParams...) - - if err != nil { - return "", err - } - - if !token.Valid() { - return "", errors.New("Invalid token received from provider") - } - - s.AccessToken = token.AccessToken - s.RefreshToken = token.RefreshToken - return token.AccessToken, err -} - -// Marshal the session into a string -func (s Session) Marshal() string { - b, _ := json.Marshal(s) - return string(b) -} - -func (s Session) String() string { - return s.Marshal() -} - -// UnmarshalSession wil unmarshal a JSON string into a session. -func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { - s := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(s) - return s, err -} diff --git a/providers/zoom/session_test.go b/providers/zoom/session_test.go deleted file mode 100644 index 470aa81b7..000000000 --- a/providers/zoom/session_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package zoom_test - -import ( - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/zoom" - "github.com/stretchr/testify/assert" -) - -func Test_Implements_Session(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &zoom.Session{} - - a.Implements((*goth.Session)(nil), s) -} - -func Test_GetAuthURL(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &zoom.Session{} - - _, err := s.GetAuthURL() - a.Error(err) - - s.AuthURL = "/foo" - - url, _ := s.GetAuthURL() - a.Equal(url, "/foo") -} - -func Test_ToJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &zoom.Session{} - - data := s.Marshal() - a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) -} - -func Test_String(t *testing.T) { - t.Parallel() - a := assert.New(t) - s := &zoom.Session{} - - a.Equal(s.String(), s.Marshal()) -} diff --git a/providers/zoom/zoom.go b/providers/zoom/zoom.go deleted file mode 100644 index a563f1457..000000000 --- a/providers/zoom/zoom.go +++ /dev/null @@ -1,178 +0,0 @@ -// Package zoom implements the OAuth2 protocol for authenticating users through zoo. -// This package can be used as a reference implementation of an OAuth2 provider for Goth. -package zoom - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "golang.org/x/oauth2" - - "github.com/markbates/goth" -) - -var ( - authorizeURL = "https://zoom.us/oauth/authorize" - tokenURL = "https://zoom.us/oauth/token" - profileURL = "https://zoom.us/v2/users/me" -) - -// Provider is the implementation of `goth.Provider` for accessing Zoom. -type Provider struct { - ClientKey string - Secret string - CallbackURL string - HTTPClient *http.Client - config *oauth2.Config - providerName string -} - -type profileResp struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - AvatarURL string `json:"pic_url"` - ID string `json:"id"` -} - -// New creates a new Zoom provider and sets up connection details. -func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { - p := &Provider{ - ClientKey: clientKey, - Secret: secret, - CallbackURL: callbackURL, - providerName: "zoom", - } - p.config = newConfig(p, scopes) - return p -} - -// Name is the name used to retrieve the provider. -func (p *Provider) Name() string { - return p.providerName -} - -// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) -func (p *Provider) SetName(name string) { - p.providerName = name -} - -func (p *Provider) Client() *http.Client { - return goth.HTTPClientWithFallBack(p.HTTPClient) -} - -// Debug is a no-op for the zoom package. -func (p *Provider) Debug(debug bool) {} - -// BeginAuth returns zoom authentication endpoint. -func (p *Provider) BeginAuth(state string) (goth.Session, error) { - return &Session{ - AuthURL: p.config.AuthCodeURL(state), - }, nil -} - -// FetchUser makes a request to profileURL and returns zoom user data. -func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { - s := session.(*Session) - user := goth.User{ - AccessToken: s.AccessToken, - Provider: p.Name(), - RefreshToken: s.RefreshToken, - } - - if user.AccessToken == "" { - // data is not yet retrieved since accessToken is still empty - return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) - } - - req, err := http.NewRequest("GET", profileURL, nil) - if err != nil { - return user, err - } - - req.Header.Set("Authorization", "Bearer "+s.AccessToken) - resp, err := p.Client().Do(req) - if err != nil { - return user, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) - } - - err = userFromReader(resp.Body, &user) - return user, err -} - -// RefreshTokenAvailable refresh token is provided by auth provider or not -func (p *Provider) RefreshTokenAvailable() bool { - return true -} - -// RefreshToken get new access token based on the refresh token -func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { - token := &oauth2.Token{RefreshToken: refreshToken} - ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) - newToken, err := ts.Token() - if err != nil { - return nil, err - } - return newToken, err -} - -func newConfig(provider *Provider, scopes []string) *oauth2.Config { - c := &oauth2.Config{ - ClientID: provider.ClientKey, - ClientSecret: provider.Secret, - RedirectURL: provider.CallbackURL, - Endpoint: oauth2.Endpoint{ - AuthURL: authorizeURL, - TokenURL: tokenURL, - }, - Scopes: []string{}, - } - - if len(scopes) > 0 { - for _, scope := range scopes { - c.Scopes = append(c.Scopes, scope) - } - } - - return c -} - -func userFromReader(r io.Reader, user *goth.User) error { - var rawData map[string]interface{} - - buf := new(bytes.Buffer) - _, err := buf.ReadFrom(r) - if err != nil { - return err - } - - err = json.Unmarshal(buf.Bytes(), &rawData) - if err != nil { - return err - } - - u := &profileResp{} - err = json.Unmarshal(buf.Bytes(), &u) - if err != nil { - return err - } - - user.Email = u.Email - user.FirstName = u.FirstName - user.LastName = u.LastName - user.Name = fmt.Sprintf("%s %s", u.FirstName, u.LastName) - user.UserID = u.ID - user.AvatarURL = u.AvatarURL - user.RawData = rawData - - return nil -} diff --git a/providers/zoom/zoom_test.go b/providers/zoom/zoom_test.go deleted file mode 100644 index b90263dfe..000000000 --- a/providers/zoom/zoom_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package zoom_test - -import ( - "fmt" - "os" - "testing" - - "github.com/markbates/goth" - "github.com/markbates/goth/providers/zoom" - "github.com/stretchr/testify/assert" -) - -func zoomProvider() *zoom.Provider { - return zoom.New(os.Getenv("ZOOM_KEY"), os.Getenv("ZOOM_SECRET"), "/foo", "basic") -} - -func Test_New(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := zoomProvider() - a.Equal(provider.ClientKey, os.Getenv("ZOOM_KEY")) - a.Equal(provider.Secret, os.Getenv("ZOOM_SECRET")) - a.Equal(provider.CallbackURL, "/foo") -} - -func Test_Implements_Provider(t *testing.T) { - t.Parallel() - a := assert.New(t) - a.Implements((*goth.Provider)(nil), zoomProvider()) -} -func Test_BeginAuth(t *testing.T) { - t.Parallel() - a := assert.New(t) - provider := zoomProvider() - session, err := provider.BeginAuth("test_state") - s := session.(*zoom.Session) - a.NoError(err) - a.Contains(s.AuthURL, "https://zoom.us/oauth/authorize") - a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("ZOOM_KEY"))) - a.Contains(s.AuthURL, "state=test_state") -} - -func Test_SessionFromJSON(t *testing.T) { - t.Parallel() - a := assert.New(t) - - provider := zoomProvider() - - s, err := provider.UnmarshalSession(`{"AuthURL":"https://app.zoom.io/oauth","AccessToken":"1234567890"}`) - a.NoError(err) - session := s.(*zoom.Session) - a.Equal(session.AuthURL, "https://app.zoom.io/oauth") - a.Equal(session.AccessToken, "1234567890") -} diff --git a/session.go b/session.go deleted file mode 100644 index 2d40b50bb..000000000 --- a/session.go +++ /dev/null @@ -1,21 +0,0 @@ -package goth - -// Params is used to pass data to sessions for authorization. An existing -// implementation, and the one most likely to be used, is `url.Values`. -type Params interface { - Get(string) string -} - -// Session needs to be implemented as part of the provider package. -// It will be marshaled and persisted between requests to "tie" -// the start and the end of the authorization process with a -// 3rd party provider. -type Session interface { - // GetAuthURL returns the URL for the authentication end-point for the provider. - GetAuthURL() (string, error) - // Marshal generates a string representation of the Session for storing between requests. - Marshal() string - // Authorize should validate the data from the provider and return an access token - // that can be stored for later access to the provider. - Authorize(Provider, Params) (string, error) -} diff --git a/user.go b/user.go deleted file mode 100644 index 3a8fd6c54..000000000 --- a/user.go +++ /dev/null @@ -1,31 +0,0 @@ -package goth - -import ( - "encoding/gob" - "time" -) - -func init() { - gob.Register(User{}) -} - -// User contains the information common amongst most OAuth and OAuth2 providers. -// All the "raw" data from the provider can be found in the `RawData` field. -type User struct { - RawData map[string]interface{} - Provider string - Email string - Name string - FirstName string - LastName string - NickName string - Description string - UserID string - AvatarURL string - Location string - AccessToken string - AccessTokenSecret string - RefreshToken string - ExpiresAt time.Time - IDToken string -} diff --git a/user_test.go b/user_test.go deleted file mode 100644 index d5c015325..000000000 --- a/user_test.go +++ /dev/null @@ -1 +0,0 @@ -package goth_test From 59ec605e1087fda308f9bc5295ab03a401293c2e Mon Sep 17 00:00:00 2001 From: Moeed ul Hassan Date: Thu, 26 Feb 2026 11:19:47 +0500 Subject: [PATCH 2/2] fix(twitterv2): upgrade provider to OAuth 2.0 with PKCE Resolves #635 by replacing mrjones/oauth with golang.org/x/oauth2, implementing PKCE required by X's Free tier API, and updating Session/Token structures accordingly. --- .git-blame-ignore-revs | 1 + .github/workflows/ci.yml | 37 + .github/workflows/codeql.yml | 44 ++ .gitignore | 31 + LICENSE.txt | 22 + README.md | 158 ++++ doc.go | 10 + examples/main.go | 291 +++++++ go.sum | 122 +++ gothic/gothic.go | 316 ++++++++ gothic/gothic_test.go | 292 +++++++ gothic/provider.go | 68 ++ gothic/provider_test.go | 44 ++ provider.go | 87 +++ provider_test.go | 35 + providers/amazon/amazon.go | 166 ++++ providers/amazon/amazon_test.go | 53 ++ providers/amazon/session.go | 63 ++ providers/amazon/session_test.go | 48 ++ providers/apple/apple.go | 187 +++++ providers/apple/apple_test.go | 119 +++ providers/apple/session.go | 162 ++++ providers/apple/session_test.go | 98 +++ providers/auth0/auth0.go | 183 +++++ providers/auth0/auth0_test.go | 101 +++ providers/auth0/session.go | 64 ++ providers/auth0/session_test.go | 48 ++ providers/azuread/azuread.go | 187 +++++ providers/azuread/azuread_test.go | 55 ++ providers/azuread/session.go | 63 ++ providers/azuread/session_test.go | 48 ++ providers/azureadv2/azureadv2.go | 232 ++++++ providers/azureadv2/azureadv2_test.go | 62 ++ providers/azureadv2/scopes.go | 717 ++++++++++++++++++ providers/azureadv2/session.go | 63 ++ providers/azureadv2/session_test.go | 48 ++ providers/battlenet/battlenet.go | 153 ++++ providers/battlenet/battlenet_test.go | 53 ++ providers/battlenet/session.go | 63 ++ providers/battlenet/session_test.go | 48 ++ providers/bitbucket/bitbucket.go | 241 ++++++ providers/bitbucket/bitbucket_test.go | 59 ++ providers/bitbucket/session.go | 61 ++ providers/bitbucket/session_test.go | 48 ++ providers/bitly/bitly.go | 170 +++++ providers/bitly/bitly_test.go | 52 ++ providers/bitly/session.go | 59 ++ providers/bitly/session_test.go | 33 + providers/box/box.go | 158 ++++ providers/box/box_test.go | 53 ++ providers/box/session.go | 63 ++ providers/box/session_test.go | 48 ++ providers/classlink/provider.go | 156 ++++ providers/classlink/provider_test.go | 59 ++ providers/classlink/session.go | 49 ++ providers/classlink/session_test.go | 48 ++ providers/cloudfoundry/cf.go | 176 +++++ providers/cloudfoundry/cf_test.go | 53 ++ providers/cloudfoundry/session.go | 66 ++ providers/cloudfoundry/session_test.go | 48 ++ providers/cognito/cognito.go | 238 ++++++ providers/cognito/cognito_test.go | 67 ++ providers/cognito/session.go | 64 ++ providers/cognito/session_test.go | 47 ++ providers/dailymotion/dailymotion.go | 188 +++++ providers/dailymotion/dailymotion_test.go | 53 ++ providers/dailymotion/session.go | 62 ++ providers/dailymotion/session_test.go | 48 ++ providers/deezer/deezer.go | 179 +++++ providers/deezer/deezer_test.go | 53 ++ providers/deezer/session.go | 66 ++ providers/deezer/session_test.go | 48 ++ providers/digitalocean/digitalocean.go | 177 +++++ providers/digitalocean/digitalocean_test.go | 51 ++ providers/digitalocean/session.go | 63 ++ providers/digitalocean/session_test.go | 39 + providers/dingtalk/dingtalk.go | 400 ++++++++++ providers/dingtalk/dingtalk_test.go | 53 ++ providers/dingtalk/session.go | 139 ++++ providers/dingtalk/session_test.go | 57 ++ providers/discord/discord.go | 236 ++++++ providers/discord/discord_test.go | 54 ++ providers/discord/session.go | 66 ++ providers/discord/session_test.go | 38 + providers/dropbox/dropbox.go | 211 ++++++ providers/dropbox/dropbox_test.go | 166 ++++ providers/eveonline/eveonline.go | 162 ++++ providers/eveonline/eveonline_test.go | 53 ++ providers/eveonline/session.go | 63 ++ providers/eveonline/session_test.go | 48 ++ providers/facebook/facebook.go | 215 ++++++ providers/facebook/facebook_test.go | 72 ++ providers/facebook/session.go | 59 ++ providers/facebook/session_test.go | 48 ++ providers/faux/README.md | 3 + providers/faux/faux.go | 110 +++ providers/fitbit/fitbit.go | 195 +++++ providers/fitbit/fitbit_test.go | 55 ++ providers/fitbit/session.go | 61 ++ providers/fitbit/session_test.go | 38 + providers/gitea/gitea.go | 186 +++++ providers/gitea/gitea_test.go | 67 ++ providers/gitea/session.go | 63 ++ providers/gitea/session_test.go | 48 ++ providers/github/github.go | 244 ++++++ providers/github/github_test.go | 73 ++ providers/github/session.go | 56 ++ providers/github/session_test.go | 48 ++ providers/gitlab/gitlab.go | 187 +++++ providers/gitlab/gitlab_test.go | 67 ++ providers/gitlab/session.go | 63 ++ providers/gitlab/session_test.go | 48 ++ providers/google/endpoint.go | 11 + providers/google/endpoint_legacy.go | 14 + providers/google/google.go | 223 ++++++ providers/google/google_test.go | 152 ++++ providers/google/session.go | 65 ++ providers/google/session_test.go | 48 ++ providers/heroku/heroku.go | 157 ++++ providers/heroku/heroku_test.go | 53 ++ providers/heroku/session.go | 63 ++ providers/heroku/session_test.go | 48 ++ providers/hubspot/hubspot.go | 174 +++++ providers/hubspot/hubspot_test.go | 53 ++ providers/hubspot/session.go | 60 ++ providers/hubspot/session_test.go | 48 ++ providers/influxcloud/influxcloud.go | 180 +++++ providers/influxcloud/influxcloud_test.go | 89 +++ providers/influxcloud/session.go | 58 ++ providers/influxcloud/session_test.go | 48 ++ providers/instagram/instagram.go | 173 +++++ providers/instagram/instagram_test.go | 56 ++ providers/instagram/session.go | 56 ++ providers/instagram/session_test.go | 48 ++ providers/intercom/intercom.go | 181 +++++ providers/intercom/intercom_test.go | 143 ++++ providers/intercom/session.go | 60 ++ providers/intercom/session_test.go | 48 ++ providers/kakao/kakao.go | 162 ++++ providers/kakao/kakao_test.go | 53 ++ providers/kakao/session.go | 65 ++ providers/kakao/session_test.go | 48 ++ providers/lark/lark.go | 307 ++++++++ providers/lark/lark_test.go | 185 +++++ providers/lark/session.go | 71 ++ providers/lark/session_test.go | 112 +++ providers/lastfm/lastfm.go | 230 ++++++ providers/lastfm/lastfm_test.go | 59 ++ providers/lastfm/session.go | 54 ++ providers/lastfm/session_test.go | 47 ++ providers/line/line.go | 196 +++++ providers/line/line_test.go | 65 ++ providers/line/session.go | 69 ++ providers/line/session_test.go | 48 ++ providers/linkedin/linkedin.go | 278 +++++++ providers/linkedin/linkedin_test.go | 59 ++ providers/linkedin/session.go | 58 ++ providers/linkedin/session_test.go | 48 ++ providers/mailru/mailru.go | 138 ++++ providers/mailru/mailru_test.go | 66 ++ providers/mailru/session.go | 59 ++ providers/mailru/session_test.go | 40 + providers/mastodon/mastodon.go | 184 +++++ providers/mastodon/mastodon_test.go | 67 ++ providers/mastodon/session.go | 63 ++ providers/mastodon/session_test.go | 48 ++ providers/meetup/meetup.go | 196 +++++ providers/meetup/meetup_test.go | 53 ++ providers/meetup/session.go | 63 ++ providers/meetup/session_test.go | 48 ++ providers/microsoftonline/microsoftonline.go | 190 +++++ .../microsoftonline/microsoftonline_test.go | 54 ++ providers/microsoftonline/session.go | 62 ++ providers/microsoftonline/session_test.go | 54 ++ providers/naver/naver.go | 172 +++++ providers/naver/naver_test.go | 56 ++ providers/naver/session.go | 61 ++ providers/naver/session_test.go | 53 ++ providers/nextcloud/README.md | 85 +++ providers/nextcloud/nextcloud.go | 205 +++++ providers/nextcloud/nextcloud_setup.png | Bin 0 -> 85944 bytes providers/nextcloud/nextcloud_test.go | 72 ++ providers/nextcloud/session.go | 63 ++ providers/nextcloud/session_test.go | 48 ++ providers/okta/okta.go | 197 +++++ providers/okta/okta_test.go | 67 ++ providers/okta/session.go | 64 ++ providers/okta/session_test.go | 48 ++ providers/onedrive/onedrive.go | 163 ++++ providers/onedrive/onedrive_test.go | 53 ++ providers/onedrive/session.go | 63 ++ providers/onedrive/session_test.go | 48 ++ providers/openidConnect/openidConnect.go | 529 +++++++++++++ providers/openidConnect/openidConnect_test.go | 123 +++ providers/openidConnect/session.go | 81 ++ providers/openidConnect/session_test.go | 47 ++ providers/oura/errors.go | 16 + providers/oura/oura.go | 191 +++++ providers/oura/oura_test.go | 55 ++ providers/oura/session.go | 64 ++ providers/oura/session_test.go | 38 + providers/patreon/patreon.go | 219 ++++++ providers/patreon/patreon_test.go | 53 ++ providers/patreon/session.go | 63 ++ providers/patreon/session_test.go | 37 + providers/paypal/paypal.go | 199 +++++ providers/paypal/paypal_test.go | 67 ++ providers/paypal/session.go | 63 ++ providers/paypal/session_test.go | 48 ++ providers/reddit/reddit.go | 137 ++++ providers/reddit/reddit_test.go | 88 +++ providers/reddit/session.go | 46 ++ providers/reddit/session_test.go | 122 +++ providers/salesforce/salesforce.go | 191 +++++ providers/salesforce/salesforce_test.go | 53 ++ providers/salesforce/session.go | 72 ++ providers/salesforce/session_test.go | 48 ++ providers/seatalk/seatalk.go | 161 ++++ providers/seatalk/seatalk_test.go | 53 ++ providers/seatalk/session.go | 54 ++ providers/seatalk/session_test.go | 48 ++ providers/shopify/scopes.go | 49 ++ providers/shopify/session.go | 103 +++ providers/shopify/session_test.go | 48 ++ providers/shopify/shopify.go | 192 +++++ providers/shopify/shopify_test.go | 55 ++ providers/slack/session.go | 63 ++ providers/slack/session_test.go | 48 ++ providers/slack/slack.go | 236 ++++++ providers/slack/slack_test.go | 236 ++++++ providers/soundcloud/session.go | 63 ++ providers/soundcloud/session_test.go | 48 ++ providers/soundcloud/soundcloud.go | 169 +++++ providers/soundcloud/soundcloud_test.go | 53 ++ providers/spotify/session.go | 63 ++ providers/spotify/session_test.go | 38 + providers/spotify/spotify.go | 224 ++++++ providers/spotify/spotify_test.go | 54 ++ providers/steam/session.go | 100 +++ providers/steam/session_test.go | 48 ++ providers/steam/steam.go | 199 +++++ providers/steam/steam_test.go | 55 ++ providers/strava/session.go | 61 ++ providers/strava/session_test.go | 48 ++ providers/strava/strava.go | 182 +++++ providers/strava/strava_test.go | 59 ++ providers/stripe/session.go | 65 ++ providers/stripe/session_test.go | 48 ++ providers/stripe/stripe.go | 164 ++++ providers/stripe/stripe_test.go | 53 ++ providers/tiktok/session.go | 104 +++ providers/tiktok/session_test.go | 48 ++ providers/tiktok/tiktok.go | 278 +++++++ providers/tiktok/tiktok_test.go | 59 ++ providers/tumblr/session.go | 54 ++ providers/tumblr/tumblr.go | 152 ++++ providers/twitch/session.go | 65 ++ providers/twitch/session_test.go | 38 + providers/twitch/twitch.go | 369 +++++++++ providers/twitch/twitch_test.go | 54 ++ providers/twitter/session.go | 54 ++ providers/twitter/session_test.go | 48 ++ providers/twitter/twitter.go | 167 ++++ providers/twitter/twitter_test.go | 120 +++ providers/typetalk/session.go | 63 ++ providers/typetalk/session_test.go | 48 ++ providers/typetalk/typetalk.go | 205 +++++ providers/typetalk/typetalk_test.go | 53 ++ providers/uber/session.go | 63 ++ providers/uber/session_test.go | 48 ++ providers/uber/uber.go | 161 ++++ providers/uber/uber_test.go | 53 ++ providers/vk/session.go | 62 ++ providers/vk/session_test.go | 40 + providers/vk/vk.go | 183 +++++ providers/vk/vk_test.go | 76 ++ providers/wechat/session.go | 67 ++ providers/wechat/session_test.go | 48 ++ providers/wechat/wechat.go | 237 ++++++ providers/wechat/wechat_test.go | 53 ++ providers/wecom/session.go | 55 ++ providers/wecom/session_test.go | 40 + providers/wecom/wecom.go | 217 ++++++ providers/wecom/wecom_test.go | 61 ++ providers/wepay/session.go | 65 ++ providers/wepay/session_test.go | 48 ++ providers/wepay/wepay.go | 155 ++++ providers/wepay/wepay_test.go | 53 ++ providers/xero/session.go | 61 ++ providers/xero/session_test.go | 48 ++ providers/xero/xero.go | 260 +++++++ providers/xero/xero_test.go | 124 +++ providers/yahoo/session.go | 63 ++ providers/yahoo/session_test.go | 48 ++ providers/yahoo/yahoo.go | 166 ++++ providers/yahoo/yahoo_test.go | 53 ++ providers/yammer/session.go | 109 +++ providers/yammer/session_test.go | 48 ++ providers/yammer/yammer.go | 160 ++++ providers/yammer/yammer_test.go | 53 ++ providers/yandex/session.go | 64 ++ providers/yandex/session_test.go | 48 ++ providers/yandex/yandex.go | 182 +++++ providers/yandex/yandex_test.go | 61 ++ providers/zoom/session.go | 79 ++ providers/zoom/session_test.go | 48 ++ providers/zoom/zoom.go | 178 +++++ providers/zoom/zoom_test.go | 55 ++ session.go | 21 + user.go | 31 + user_test.go | 1 + 311 files changed, 30478 insertions(+) create mode 100644 .git-blame-ignore-revs create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 doc.go create mode 100644 examples/main.go create mode 100644 go.sum create mode 100644 gothic/gothic.go create mode 100644 gothic/gothic_test.go create mode 100644 gothic/provider.go create mode 100644 gothic/provider_test.go create mode 100644 provider.go create mode 100644 provider_test.go create mode 100644 providers/amazon/amazon.go create mode 100644 providers/amazon/amazon_test.go create mode 100644 providers/amazon/session.go create mode 100644 providers/amazon/session_test.go create mode 100644 providers/apple/apple.go create mode 100644 providers/apple/apple_test.go create mode 100644 providers/apple/session.go create mode 100644 providers/apple/session_test.go create mode 100644 providers/auth0/auth0.go create mode 100644 providers/auth0/auth0_test.go create mode 100644 providers/auth0/session.go create mode 100644 providers/auth0/session_test.go create mode 100644 providers/azuread/azuread.go create mode 100644 providers/azuread/azuread_test.go create mode 100644 providers/azuread/session.go create mode 100644 providers/azuread/session_test.go create mode 100644 providers/azureadv2/azureadv2.go create mode 100644 providers/azureadv2/azureadv2_test.go create mode 100644 providers/azureadv2/scopes.go create mode 100644 providers/azureadv2/session.go create mode 100644 providers/azureadv2/session_test.go create mode 100644 providers/battlenet/battlenet.go create mode 100644 providers/battlenet/battlenet_test.go create mode 100644 providers/battlenet/session.go create mode 100644 providers/battlenet/session_test.go create mode 100644 providers/bitbucket/bitbucket.go create mode 100644 providers/bitbucket/bitbucket_test.go create mode 100644 providers/bitbucket/session.go create mode 100644 providers/bitbucket/session_test.go create mode 100644 providers/bitly/bitly.go create mode 100644 providers/bitly/bitly_test.go create mode 100644 providers/bitly/session.go create mode 100644 providers/bitly/session_test.go create mode 100644 providers/box/box.go create mode 100644 providers/box/box_test.go create mode 100644 providers/box/session.go create mode 100644 providers/box/session_test.go create mode 100644 providers/classlink/provider.go create mode 100644 providers/classlink/provider_test.go create mode 100644 providers/classlink/session.go create mode 100644 providers/classlink/session_test.go create mode 100644 providers/cloudfoundry/cf.go create mode 100644 providers/cloudfoundry/cf_test.go create mode 100644 providers/cloudfoundry/session.go create mode 100644 providers/cloudfoundry/session_test.go create mode 100644 providers/cognito/cognito.go create mode 100644 providers/cognito/cognito_test.go create mode 100644 providers/cognito/session.go create mode 100644 providers/cognito/session_test.go create mode 100644 providers/dailymotion/dailymotion.go create mode 100644 providers/dailymotion/dailymotion_test.go create mode 100644 providers/dailymotion/session.go create mode 100644 providers/dailymotion/session_test.go create mode 100644 providers/deezer/deezer.go create mode 100644 providers/deezer/deezer_test.go create mode 100644 providers/deezer/session.go create mode 100644 providers/deezer/session_test.go create mode 100644 providers/digitalocean/digitalocean.go create mode 100644 providers/digitalocean/digitalocean_test.go create mode 100644 providers/digitalocean/session.go create mode 100644 providers/digitalocean/session_test.go create mode 100644 providers/dingtalk/dingtalk.go create mode 100644 providers/dingtalk/dingtalk_test.go create mode 100644 providers/dingtalk/session.go create mode 100644 providers/dingtalk/session_test.go create mode 100644 providers/discord/discord.go create mode 100644 providers/discord/discord_test.go create mode 100644 providers/discord/session.go create mode 100644 providers/discord/session_test.go create mode 100644 providers/dropbox/dropbox.go create mode 100644 providers/dropbox/dropbox_test.go create mode 100644 providers/eveonline/eveonline.go create mode 100644 providers/eveonline/eveonline_test.go create mode 100644 providers/eveonline/session.go create mode 100644 providers/eveonline/session_test.go create mode 100644 providers/facebook/facebook.go create mode 100644 providers/facebook/facebook_test.go create mode 100644 providers/facebook/session.go create mode 100644 providers/facebook/session_test.go create mode 100644 providers/faux/README.md create mode 100644 providers/faux/faux.go create mode 100644 providers/fitbit/fitbit.go create mode 100644 providers/fitbit/fitbit_test.go create mode 100644 providers/fitbit/session.go create mode 100644 providers/fitbit/session_test.go create mode 100644 providers/gitea/gitea.go create mode 100644 providers/gitea/gitea_test.go create mode 100644 providers/gitea/session.go create mode 100644 providers/gitea/session_test.go create mode 100644 providers/github/github.go create mode 100644 providers/github/github_test.go create mode 100644 providers/github/session.go create mode 100644 providers/github/session_test.go create mode 100644 providers/gitlab/gitlab.go create mode 100644 providers/gitlab/gitlab_test.go create mode 100644 providers/gitlab/session.go create mode 100644 providers/gitlab/session_test.go create mode 100644 providers/google/endpoint.go create mode 100644 providers/google/endpoint_legacy.go create mode 100644 providers/google/google.go create mode 100644 providers/google/google_test.go create mode 100644 providers/google/session.go create mode 100644 providers/google/session_test.go create mode 100644 providers/heroku/heroku.go create mode 100644 providers/heroku/heroku_test.go create mode 100644 providers/heroku/session.go create mode 100644 providers/heroku/session_test.go create mode 100644 providers/hubspot/hubspot.go create mode 100644 providers/hubspot/hubspot_test.go create mode 100644 providers/hubspot/session.go create mode 100644 providers/hubspot/session_test.go create mode 100644 providers/influxcloud/influxcloud.go create mode 100644 providers/influxcloud/influxcloud_test.go create mode 100644 providers/influxcloud/session.go create mode 100644 providers/influxcloud/session_test.go create mode 100644 providers/instagram/instagram.go create mode 100644 providers/instagram/instagram_test.go create mode 100644 providers/instagram/session.go create mode 100644 providers/instagram/session_test.go create mode 100644 providers/intercom/intercom.go create mode 100644 providers/intercom/intercom_test.go create mode 100644 providers/intercom/session.go create mode 100644 providers/intercom/session_test.go create mode 100644 providers/kakao/kakao.go create mode 100644 providers/kakao/kakao_test.go create mode 100644 providers/kakao/session.go create mode 100644 providers/kakao/session_test.go create mode 100644 providers/lark/lark.go create mode 100644 providers/lark/lark_test.go create mode 100644 providers/lark/session.go create mode 100644 providers/lark/session_test.go create mode 100644 providers/lastfm/lastfm.go create mode 100644 providers/lastfm/lastfm_test.go create mode 100644 providers/lastfm/session.go create mode 100644 providers/lastfm/session_test.go create mode 100644 providers/line/line.go create mode 100644 providers/line/line_test.go create mode 100644 providers/line/session.go create mode 100644 providers/line/session_test.go create mode 100644 providers/linkedin/linkedin.go create mode 100644 providers/linkedin/linkedin_test.go create mode 100644 providers/linkedin/session.go create mode 100644 providers/linkedin/session_test.go create mode 100644 providers/mailru/mailru.go create mode 100644 providers/mailru/mailru_test.go create mode 100644 providers/mailru/session.go create mode 100644 providers/mailru/session_test.go create mode 100644 providers/mastodon/mastodon.go create mode 100644 providers/mastodon/mastodon_test.go create mode 100644 providers/mastodon/session.go create mode 100644 providers/mastodon/session_test.go create mode 100644 providers/meetup/meetup.go create mode 100644 providers/meetup/meetup_test.go create mode 100644 providers/meetup/session.go create mode 100644 providers/meetup/session_test.go create mode 100644 providers/microsoftonline/microsoftonline.go create mode 100644 providers/microsoftonline/microsoftonline_test.go create mode 100644 providers/microsoftonline/session.go create mode 100644 providers/microsoftonline/session_test.go create mode 100644 providers/naver/naver.go create mode 100644 providers/naver/naver_test.go create mode 100644 providers/naver/session.go create mode 100644 providers/naver/session_test.go create mode 100644 providers/nextcloud/README.md create mode 100644 providers/nextcloud/nextcloud.go create mode 100644 providers/nextcloud/nextcloud_setup.png create mode 100644 providers/nextcloud/nextcloud_test.go create mode 100644 providers/nextcloud/session.go create mode 100644 providers/nextcloud/session_test.go create mode 100644 providers/okta/okta.go create mode 100644 providers/okta/okta_test.go create mode 100644 providers/okta/session.go create mode 100644 providers/okta/session_test.go create mode 100644 providers/onedrive/onedrive.go create mode 100644 providers/onedrive/onedrive_test.go create mode 100644 providers/onedrive/session.go create mode 100644 providers/onedrive/session_test.go create mode 100644 providers/openidConnect/openidConnect.go create mode 100644 providers/openidConnect/openidConnect_test.go create mode 100644 providers/openidConnect/session.go create mode 100644 providers/openidConnect/session_test.go create mode 100644 providers/oura/errors.go create mode 100644 providers/oura/oura.go create mode 100644 providers/oura/oura_test.go create mode 100644 providers/oura/session.go create mode 100644 providers/oura/session_test.go create mode 100644 providers/patreon/patreon.go create mode 100644 providers/patreon/patreon_test.go create mode 100644 providers/patreon/session.go create mode 100644 providers/patreon/session_test.go create mode 100644 providers/paypal/paypal.go create mode 100644 providers/paypal/paypal_test.go create mode 100644 providers/paypal/session.go create mode 100644 providers/paypal/session_test.go create mode 100644 providers/reddit/reddit.go create mode 100644 providers/reddit/reddit_test.go create mode 100644 providers/reddit/session.go create mode 100644 providers/reddit/session_test.go create mode 100644 providers/salesforce/salesforce.go create mode 100644 providers/salesforce/salesforce_test.go create mode 100644 providers/salesforce/session.go create mode 100644 providers/salesforce/session_test.go create mode 100644 providers/seatalk/seatalk.go create mode 100644 providers/seatalk/seatalk_test.go create mode 100644 providers/seatalk/session.go create mode 100644 providers/seatalk/session_test.go create mode 100644 providers/shopify/scopes.go create mode 100644 providers/shopify/session.go create mode 100644 providers/shopify/session_test.go create mode 100644 providers/shopify/shopify.go create mode 100644 providers/shopify/shopify_test.go create mode 100644 providers/slack/session.go create mode 100644 providers/slack/session_test.go create mode 100644 providers/slack/slack.go create mode 100644 providers/slack/slack_test.go create mode 100644 providers/soundcloud/session.go create mode 100644 providers/soundcloud/session_test.go create mode 100644 providers/soundcloud/soundcloud.go create mode 100644 providers/soundcloud/soundcloud_test.go create mode 100644 providers/spotify/session.go create mode 100644 providers/spotify/session_test.go create mode 100644 providers/spotify/spotify.go create mode 100644 providers/spotify/spotify_test.go create mode 100644 providers/steam/session.go create mode 100644 providers/steam/session_test.go create mode 100644 providers/steam/steam.go create mode 100644 providers/steam/steam_test.go create mode 100644 providers/strava/session.go create mode 100644 providers/strava/session_test.go create mode 100644 providers/strava/strava.go create mode 100644 providers/strava/strava_test.go create mode 100644 providers/stripe/session.go create mode 100644 providers/stripe/session_test.go create mode 100644 providers/stripe/stripe.go create mode 100644 providers/stripe/stripe_test.go create mode 100644 providers/tiktok/session.go create mode 100644 providers/tiktok/session_test.go create mode 100644 providers/tiktok/tiktok.go create mode 100644 providers/tiktok/tiktok_test.go create mode 100644 providers/tumblr/session.go create mode 100644 providers/tumblr/tumblr.go create mode 100644 providers/twitch/session.go create mode 100644 providers/twitch/session_test.go create mode 100644 providers/twitch/twitch.go create mode 100644 providers/twitch/twitch_test.go create mode 100644 providers/twitter/session.go create mode 100644 providers/twitter/session_test.go create mode 100644 providers/twitter/twitter.go create mode 100644 providers/twitter/twitter_test.go create mode 100644 providers/typetalk/session.go create mode 100644 providers/typetalk/session_test.go create mode 100644 providers/typetalk/typetalk.go create mode 100644 providers/typetalk/typetalk_test.go create mode 100644 providers/uber/session.go create mode 100644 providers/uber/session_test.go create mode 100644 providers/uber/uber.go create mode 100644 providers/uber/uber_test.go create mode 100644 providers/vk/session.go create mode 100644 providers/vk/session_test.go create mode 100644 providers/vk/vk.go create mode 100644 providers/vk/vk_test.go create mode 100644 providers/wechat/session.go create mode 100644 providers/wechat/session_test.go create mode 100644 providers/wechat/wechat.go create mode 100644 providers/wechat/wechat_test.go create mode 100644 providers/wecom/session.go create mode 100644 providers/wecom/session_test.go create mode 100644 providers/wecom/wecom.go create mode 100644 providers/wecom/wecom_test.go create mode 100644 providers/wepay/session.go create mode 100644 providers/wepay/session_test.go create mode 100644 providers/wepay/wepay.go create mode 100644 providers/wepay/wepay_test.go create mode 100644 providers/xero/session.go create mode 100644 providers/xero/session_test.go create mode 100644 providers/xero/xero.go create mode 100644 providers/xero/xero_test.go create mode 100644 providers/yahoo/session.go create mode 100644 providers/yahoo/session_test.go create mode 100644 providers/yahoo/yahoo.go create mode 100644 providers/yahoo/yahoo_test.go create mode 100644 providers/yammer/session.go create mode 100644 providers/yammer/session_test.go create mode 100644 providers/yammer/yammer.go create mode 100644 providers/yammer/yammer_test.go create mode 100644 providers/yandex/session.go create mode 100644 providers/yandex/session_test.go create mode 100644 providers/yandex/yandex.go create mode 100644 providers/yandex/yandex_test.go create mode 100644 providers/zoom/session.go create mode 100644 providers/zoom/session_test.go create mode 100644 providers/zoom/zoom.go create mode 100644 providers/zoom/zoom_test.go create mode 100644 session.go create mode 100644 user.go create mode 100644 user_test.go diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..aac1886aa --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +042f5311fcab71f9bd8ac33c1e25597799eb34d7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..22d230011 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +name: ci + +jobs: + test: + strategy: + matrix: + go-version: [ 1.22.x, 1.23.x, 1.24.x, 1.25.x, 1.26.x ] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v4 + - name: Restore cache + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Format + run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi + if: matrix.os != 'windows-latest' && matrix.go-version == '1.24.x' + - name: Test + run: go test -race ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..fca62a5ee --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '43 17 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: 'ubuntu-latest' + permissions: + # required for all workflows + security-events: write + # required to fetch internal or private CodeQL packs + packages: read + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + go-version: [1.26.x] + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5ce409a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +*.log +.DS_Store +doc +tmp +pkg +*.gem +*.pid +coverage +coverage.data +build/* +*.pbxuser +*.mode1v3 +.svn +profile +.console_history +.sass-cache/* +.rake_tasks~ +*.log.lck +solr/ +.jhw-cache/ +jhw.* +*.sublime* +node_modules/ +dist/ +generated/ +.vendor/ +vendor +*.swp +.vscode/launch.json +.vscode/settings.json +.idea diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..f8e6d5b27 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014 Mark Bates + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..95a432f8b --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Goth: Multi-Provider Authentication for Go [![GoDoc](https://godoc.org/github.com/markbates/goth?status.svg)](https://godoc.org/github.com/markbates/goth) [![Build Status](https://github.com/markbates/goth/workflows/ci/badge.svg)](https://github.com/markbates/goth/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/markbates/goth)](https://goreportcard.com/report/github.com/markbates/goth) + +Package goth provides a simple, clean, and idiomatic way to write authentication +packages for Go web applications. + +Unlike other similar packages, Goth, lets you write OAuth, OAuth2, or any other +protocol providers, as long as they implement the [Provider](https://github.com/markbates/goth/blob/master/provider.go#L13-L22) and [Session](https://github.com/markbates/goth/blob/master/session.go#L13-L21) interfaces. + +This package was inspired by [https://github.com/intridea/omniauth](https://github.com/intridea/omniauth). + +## Installation + +```text +$ go get github.com/markbates/goth +``` + +## Supported Providers + +* Amazon +* Apple +* Auth0 +* Azure AD +* Battle.net +* Bitbucket +* Box +* ClassLink +* Cloud Foundry +* Dailymotion +* Deezer +* DigitalOcean +* DingTalk +* Discord +* Dropbox +* Eve Online +* Facebook +* Fitbit +* Gitea +* GitHub +* Gitlab +* Google +* Heroku +* InfluxCloud +* Instagram +* Intercom +* Kakao +* Lastfm +* LINE +* Linkedin +* Mailru +* Meetup +* MicrosoftOnline +* Naver +* Nextcloud +* Okta +* OneDrive +* OpenID Connect (auto discovery) +* Oura +* Patreon +* Paypal +* Reddit +* SalesForce +* Shopify +* Slack +* Soundcloud +* Spotify +* Steam +* Strava +* Stripe +* TikTok +* Tumblr +* Twitch +* Twitter +* Typetalk +* Uber +* VK +* WeCom +* Wepay +* Xero +* Yahoo +* Yammer +* Yandex +* Zoom + +## Examples + +See the [examples](examples) folder for a working application that lets users authenticate +through Twitter, Facebook, Google Plus etc. + +To run the example either clone the source from GitHub + +```text +$ git clone git@github.com:markbates/goth.git +``` +or use +```text +$ go get github.com/markbates/goth +``` +```text +$ cd goth/examples +$ go get -v +$ go build +$ ./examples +``` + +Now open up your browser and go to [http://localhost:3000](http://localhost:3000) to see the example. + +To actually use the different providers, please make sure you set environment variables. Example given in the examples/main.go file + +## Security Notes + +By default, gothic uses a `CookieStore` from the `gorilla/sessions` package to store session data. + +As configured, this default store (`gothic.Store`) will generate cookies with `Options`: + +```go +&Options{ + Path: "/", + Domain: "", + MaxAge: 86400 * 30, + HttpOnly: true, + Secure: false, + } +``` + +To tailor these fields for your application, you can override the `gothic.Store` variable at startup. + +The following snippet shows one way to do this: + +```go +key := "" // Replace with your SESSION_SECRET or similar +maxAge := 86400 * 30 // 30 days +isProd := false // Set to true when serving over https + +store := sessions.NewCookieStore([]byte(key)) +store.MaxAge(maxAge) +store.Options.Path = "/" +store.Options.HttpOnly = true // HttpOnly should always be enabled +store.Options.Secure = isProd + +gothic.Store = store +``` + +## Issues + +Issues always stand a significantly better chance of getting fixed if they are accompanied by a +pull request. + +## Contributing + +Would I love to see more providers? Certainly! Would you love to contribute one? Hopefully, yes! + +1. Fork it +2. Create your feature branch (git checkout -b my-new-feature) +3. Write Tests! +4. Make sure the codebase adhere to the Go coding standards by executing `gofmt -s -w ./` +5. Commit your changes (git commit -am 'Add some feature') +6. Push to the branch (git push origin my-new-feature) +7. Create new Pull Request diff --git a/doc.go b/doc.go new file mode 100644 index 000000000..d0bec281c --- /dev/null +++ b/doc.go @@ -0,0 +1,10 @@ +/* +Package goth provides a simple, clean, and idiomatic way to write authentication +packages for Go web applications. + +This package was inspired by https://github.com/intridea/omniauth. + +See the examples folder for a working application that lets users authenticate +through Twitter or Facebook. +*/ +package goth diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 000000000..f72938152 --- /dev/null +++ b/examples/main.go @@ -0,0 +1,291 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" + "os" + "sort" + + "github.com/gorilla/pat" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/amazon" + "github.com/markbates/goth/providers/apple" + "github.com/markbates/goth/providers/auth0" + "github.com/markbates/goth/providers/azuread" + "github.com/markbates/goth/providers/battlenet" + "github.com/markbates/goth/providers/bitbucket" + "github.com/markbates/goth/providers/box" + "github.com/markbates/goth/providers/dailymotion" + "github.com/markbates/goth/providers/deezer" + "github.com/markbates/goth/providers/digitalocean" + "github.com/markbates/goth/providers/dingtalk" + "github.com/markbates/goth/providers/discord" + "github.com/markbates/goth/providers/dropbox" + "github.com/markbates/goth/providers/eveonline" + "github.com/markbates/goth/providers/facebook" + "github.com/markbates/goth/providers/fitbit" + "github.com/markbates/goth/providers/gitea" + "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/gitlab" + "github.com/markbates/goth/providers/google" + "github.com/markbates/goth/providers/heroku" + "github.com/markbates/goth/providers/instagram" + "github.com/markbates/goth/providers/intercom" + "github.com/markbates/goth/providers/kakao" + "github.com/markbates/goth/providers/lastfm" + "github.com/markbates/goth/providers/line" + "github.com/markbates/goth/providers/linkedin" + "github.com/markbates/goth/providers/mastodon" + "github.com/markbates/goth/providers/meetup" + "github.com/markbates/goth/providers/microsoftonline" + "github.com/markbates/goth/providers/naver" + "github.com/markbates/goth/providers/nextcloud" + "github.com/markbates/goth/providers/okta" + "github.com/markbates/goth/providers/onedrive" + "github.com/markbates/goth/providers/openidConnect" + "github.com/markbates/goth/providers/patreon" + "github.com/markbates/goth/providers/paypal" + "github.com/markbates/goth/providers/salesforce" + "github.com/markbates/goth/providers/seatalk" + "github.com/markbates/goth/providers/shopify" + "github.com/markbates/goth/providers/slack" + "github.com/markbates/goth/providers/soundcloud" + "github.com/markbates/goth/providers/spotify" + "github.com/markbates/goth/providers/steam" + "github.com/markbates/goth/providers/strava" + "github.com/markbates/goth/providers/stripe" + "github.com/markbates/goth/providers/tiktok" + "github.com/markbates/goth/providers/twitch" + "github.com/markbates/goth/providers/twitter" + "github.com/markbates/goth/providers/twitterv2" + "github.com/markbates/goth/providers/typetalk" + "github.com/markbates/goth/providers/uber" + "github.com/markbates/goth/providers/vk" + "github.com/markbates/goth/providers/wecom" + "github.com/markbates/goth/providers/wepay" + "github.com/markbates/goth/providers/xero" + "github.com/markbates/goth/providers/yahoo" + "github.com/markbates/goth/providers/yammer" + "github.com/markbates/goth/providers/yandex" + "github.com/markbates/goth/providers/zoom" +) + +func main() { + goth.UseProviders( + // Use twitterv2 instead of twitter if you only have access to the Essential API Level + // the twitter provider uses a v1.1 API that is not available to the Essential Level + twitterv2.New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitterv2/callback"), + // If you'd like to use authenticate instead of authorize in TwitterV2 provider, use this instead. + // twitterv2.NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitterv2/callback"), + + twitter.New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitter/callback"), + // If you'd like to use authenticate instead of authorize in Twitter provider, use this instead. + // twitter.NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "http://localhost:3000/auth/twitter/callback"), + + tiktok.New(os.Getenv("TIKTOK_KEY"), os.Getenv("TIKTOK_SECRET"), "http://localhost:3000/auth/tiktok/callback"), + facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "http://localhost:3000/auth/facebook/callback"), + fitbit.New(os.Getenv("FITBIT_KEY"), os.Getenv("FITBIT_SECRET"), "http://localhost:3000/auth/fitbit/callback"), + google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGLE_SECRET"), "http://localhost:3000/auth/google/callback"), + github.New(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "http://localhost:3000/auth/github/callback"), + spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "http://localhost:3000/auth/spotify/callback"), + linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "http://localhost:3000/auth/linkedin/callback"), + line.New(os.Getenv("LINE_KEY"), os.Getenv("LINE_SECRET"), "http://localhost:3000/auth/line/callback", "profile", "openid", "email"), + lastfm.New(os.Getenv("LASTFM_KEY"), os.Getenv("LASTFM_SECRET"), "http://localhost:3000/auth/lastfm/callback"), + twitch.New(os.Getenv("TWITCH_KEY"), os.Getenv("TWITCH_SECRET"), "http://localhost:3000/auth/twitch/callback"), + dropbox.New(os.Getenv("DROPBOX_KEY"), os.Getenv("DROPBOX_SECRET"), "http://localhost:3000/auth/dropbox/callback"), + digitalocean.New(os.Getenv("DIGITALOCEAN_KEY"), os.Getenv("DIGITALOCEAN_SECRET"), "http://localhost:3000/auth/digitalocean/callback", "read"), + bitbucket.New(os.Getenv("BITBUCKET_KEY"), os.Getenv("BITBUCKET_SECRET"), "http://localhost:3000/auth/bitbucket/callback"), + instagram.New(os.Getenv("INSTAGRAM_KEY"), os.Getenv("INSTAGRAM_SECRET"), "http://localhost:3000/auth/instagram/callback"), + intercom.New(os.Getenv("INTERCOM_KEY"), os.Getenv("INTERCOM_SECRET"), "http://localhost:3000/auth/intercom/callback"), + box.New(os.Getenv("BOX_KEY"), os.Getenv("BOX_SECRET"), "http://localhost:3000/auth/box/callback"), + salesforce.New(os.Getenv("SALESFORCE_KEY"), os.Getenv("SALESFORCE_SECRET"), "http://localhost:3000/auth/salesforce/callback"), + seatalk.New(os.Getenv("SEATALK_KEY"), os.Getenv("SEATALK_SECRET"), "http://localhost:3000/auth/seatalk/callback"), + amazon.New(os.Getenv("AMAZON_KEY"), os.Getenv("AMAZON_SECRET"), "http://localhost:3000/auth/amazon/callback"), + yammer.New(os.Getenv("YAMMER_KEY"), os.Getenv("YAMMER_SECRET"), "http://localhost:3000/auth/yammer/callback"), + onedrive.New(os.Getenv("ONEDRIVE_KEY"), os.Getenv("ONEDRIVE_SECRET"), "http://localhost:3000/auth/onedrive/callback"), + azuread.New(os.Getenv("AZUREAD_KEY"), os.Getenv("AZUREAD_SECRET"), "http://localhost:3000/auth/azuread/callback", nil), + microsoftonline.New(os.Getenv("MICROSOFTONLINE_KEY"), os.Getenv("MICROSOFTONLINE_SECRET"), "http://localhost:3000/auth/microsoftonline/callback"), + battlenet.New(os.Getenv("BATTLENET_KEY"), os.Getenv("BATTLENET_SECRET"), "http://localhost:3000/auth/battlenet/callback"), + eveonline.New(os.Getenv("EVEONLINE_KEY"), os.Getenv("EVEONLINE_SECRET"), "http://localhost:3000/auth/eveonline/callback"), + kakao.New(os.Getenv("KAKAO_KEY"), os.Getenv("KAKAO_SECRET"), "http://localhost:3000/auth/kakao/callback"), + + // Pointed https://localhost.com to http://localhost:3000/auth/yahoo/callback + // Yahoo only accepts urls that starts with https + yahoo.New(os.Getenv("YAHOO_KEY"), os.Getenv("YAHOO_SECRET"), "https://localhost.com"), + typetalk.New(os.Getenv("TYPETALK_KEY"), os.Getenv("TYPETALK_SECRET"), "http://localhost:3000/auth/typetalk/callback", "my"), + slack.New(os.Getenv("SLACK_KEY"), os.Getenv("SLACK_SECRET"), "http://localhost:3000/auth/slack/callback"), + stripe.New(os.Getenv("STRIPE_KEY"), os.Getenv("STRIPE_SECRET"), "http://localhost:3000/auth/stripe/callback"), + wepay.New(os.Getenv("WEPAY_KEY"), os.Getenv("WEPAY_SECRET"), "http://localhost:3000/auth/wepay/callback", "view_user"), + // By default paypal production auth urls will be used, please set PAYPAL_ENV=sandbox as environment variable for testing + // in sandbox environment + paypal.New(os.Getenv("PAYPAL_KEY"), os.Getenv("PAYPAL_SECRET"), "http://localhost:3000/auth/paypal/callback"), + steam.New(os.Getenv("STEAM_KEY"), "http://localhost:3000/auth/steam/callback"), + heroku.New(os.Getenv("HEROKU_KEY"), os.Getenv("HEROKU_SECRET"), "http://localhost:3000/auth/heroku/callback"), + uber.New(os.Getenv("UBER_KEY"), os.Getenv("UBER_SECRET"), "http://localhost:3000/auth/uber/callback"), + soundcloud.New(os.Getenv("SOUNDCLOUD_KEY"), os.Getenv("SOUNDCLOUD_SECRET"), "http://localhost:3000/auth/soundcloud/callback"), + gitlab.New(os.Getenv("GITLAB_KEY"), os.Getenv("GITLAB_SECRET"), "http://localhost:3000/auth/gitlab/callback"), + dailymotion.New(os.Getenv("DAILYMOTION_KEY"), os.Getenv("DAILYMOTION_SECRET"), "http://localhost:3000/auth/dailymotion/callback", "email"), + deezer.New(os.Getenv("DEEZER_KEY"), os.Getenv("DEEZER_SECRET"), "http://localhost:3000/auth/deezer/callback", "email"), + discord.New(os.Getenv("DISCORD_KEY"), os.Getenv("DISCORD_SECRET"), "http://localhost:3000/auth/discord/callback", discord.ScopeIdentify, discord.ScopeEmail), + meetup.New(os.Getenv("MEETUP_KEY"), os.Getenv("MEETUP_SECRET"), "http://localhost:3000/auth/meetup/callback"), + + // Auth0 allocates domain per customer, a domain must be provided for auth0 to work + auth0.New(os.Getenv("AUTH0_KEY"), os.Getenv("AUTH0_SECRET"), "http://localhost:3000/auth/auth0/callback", os.Getenv("AUTH0_DOMAIN")), + xero.New(os.Getenv("XERO_KEY"), os.Getenv("XERO_SECRET"), "http://localhost:3000/auth/xero/callback"), + vk.New(os.Getenv("VK_KEY"), os.Getenv("VK_SECRET"), "http://localhost:3000/auth/vk/callback"), + naver.New(os.Getenv("NAVER_KEY"), os.Getenv("NAVER_SECRET"), "http://localhost:3000/auth/naver/callback"), + yandex.New(os.Getenv("YANDEX_KEY"), os.Getenv("YANDEX_SECRET"), "http://localhost:3000/auth/yandex/callback"), + nextcloud.NewCustomisedDNS(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "http://localhost:3000/auth/nextcloud/callback", os.Getenv("NEXTCLOUD_URL")), + gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "http://localhost:3000/auth/gitea/callback"), + shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "http://localhost:3000/auth/shopify/callback", shopify.ScopeReadCustomers, shopify.ScopeReadOrders), + apple.New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "http://localhost:3000/auth/apple/callback", nil, apple.ScopeName, apple.ScopeEmail), + strava.New(os.Getenv("STRAVA_KEY"), os.Getenv("STRAVA_SECRET"), "http://localhost:3000/auth/strava/callback"), + okta.New(os.Getenv("OKTA_ID"), os.Getenv("OKTA_SECRET"), os.Getenv("OKTA_ORG_URL"), "http://localhost:3000/auth/okta/callback", "openid", "profile", "email"), + mastodon.New(os.Getenv("MASTODON_KEY"), os.Getenv("MASTODON_SECRET"), "http://localhost:3000/auth/mastodon/callback", "read:accounts"), + wecom.New(os.Getenv("WECOM_CORP_ID"), os.Getenv("WECOM_SECRET"), os.Getenv("WECOM_AGENT_ID"), "http://localhost:3000/auth/wecom/callback"), + zoom.New(os.Getenv("ZOOM_KEY"), os.Getenv("ZOOM_SECRET"), "http://localhost:3000/auth/zoom/callback", "read:user"), + patreon.New(os.Getenv("PATREON_KEY"), os.Getenv("PATREON_SECRET"), "http://localhost:3000/auth/patreon/callback"), + // DingTalk provider + dingtalk.New(os.Getenv("DINGTALK_KEY"), os.Getenv("DINGTALK_SECRET"), "https://f7ca-103-148-203-253.ngrok-free.app/auth/dingtalk/callback", os.Getenv("DINGTALK_CORP_ID"), "openid", "corpid"), + ) + + // OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html) + // because the OpenID Connect provider initialize itself in the New(), it can return an error which should be handled or ignored + // ignore the error for now + openidConnect, _ := openidConnect.New(os.Getenv("OPENID_CONNECT_KEY"), os.Getenv("OPENID_CONNECT_SECRET"), "http://localhost:3000/auth/openid-connect/callback", os.Getenv("OPENID_CONNECT_DISCOVERY_URL")) + if openidConnect != nil { + goth.UseProviders(openidConnect) + } + + m := map[string]string{ + "amazon": "Amazon", + "apple": "Apple", + "auth0": "Auth0", + "azuread": "Azure AD", + "battlenet": "Battle.net", + "bitbucket": "Bitbucket", + "box": "Box", + "dailymotion": "Dailymotion", + "deezer": "Deezer", + "digitalocean": "Digital Ocean", + "dingtalk": "DingTalk", + "discord": "Discord", + "dropbox": "Dropbox", + "eveonline": "Eve Online", + "facebook": "Facebook", + "fitbit": "Fitbit", + "gitea": "Gitea", + "github": "Github", + "gitlab": "Gitlab", + "google": "Google", + "heroku": "Heroku", + "instagram": "Instagram", + "intercom": "Intercom", + "kakao": "Kakao", + "lastfm": "Last FM", + "line": "LINE", + "linkedin": "LinkedIn", + "mastodon": "Mastodon", + "meetup": "Meetup.com", + "microsoftonline": "Microsoft Online", + "naver": "Naver", + "nextcloud": "NextCloud", + "okta": "Okta", + "onedrive": "Onedrive", + "openid-connect": "OpenID Connect", + "patreon": "Patreon", + "paypal": "Paypal", + "salesforce": "Salesforce", + "seatalk": "SeaTalk", + "shopify": "Shopify", + "slack": "Slack", + "soundcloud": "SoundCloud", + "spotify": "Spotify", + "steam": "Steam", + "strava": "Strava", + "stripe": "Stripe", + "tiktok": "TikTok", + "twitch": "Twitch", + "twitter": "Twitter", + "twitterv2": "Twitter", + "typetalk": "Typetalk", + "uber": "Uber", + "vk": "VK", + "wecom": "WeCom", + "wepay": "Wepay", + "xero": "Xero", + "yahoo": "Yahoo", + "yammer": "Yammer", + "yandex": "Yandex", + "zoom": "Zoom", + } + var keys []string + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + providerIndex := &ProviderIndex{Providers: keys, ProvidersMap: m} + + p := pat.New() + p.Get("/auth/{provider}/callback", func(res http.ResponseWriter, req *http.Request) { + + user, err := gothic.CompleteUserAuth(res, req) + if err != nil { + fmt.Fprintln(res, err) + return + } + t, _ := template.New("foo").Parse(userTemplate) + t.Execute(res, user) + }) + + p.Get("/logout/{provider}", func(res http.ResponseWriter, req *http.Request) { + gothic.Logout(res, req) + res.Header().Set("Location", "/") + res.WriteHeader(http.StatusTemporaryRedirect) + }) + + p.Get("/auth/{provider}", func(res http.ResponseWriter, req *http.Request) { + // try to get the user without re-authenticating + if gothUser, err := gothic.CompleteUserAuth(res, req); err == nil { + t, _ := template.New("foo").Parse(userTemplate) + t.Execute(res, gothUser) + } else { + gothic.BeginAuthHandler(res, req) + } + }) + + p.Get("/", func(res http.ResponseWriter, req *http.Request) { + t, _ := template.New("foo").Parse(indexTemplate) + t.Execute(res, providerIndex) + }) + + log.Println("listening on localhost:3000") + log.Fatal(http.ListenAndServe(":3000", p)) +} + +type ProviderIndex struct { + Providers []string + ProvidersMap map[string]string +} + +var indexTemplate = `{{range $key,$value:=.Providers}} + +{{end}}` + +var userTemplate = ` +

logout

+

Name: {{.Name}} [{{.LastName}}, {{.FirstName}}]

+

Email: {{.Email}}

+

NickName: {{.NickName}}

+

Location: {{.Location}}

+

AvatarURL: {{.AvatarURL}}

+

Description: {{.Description}}

+

UserID: {{.UserID}}

+

AccessToken: {{.AccessToken}}

+

ExpiresAt: {{.ExpiresAt}}

+

RefreshToken: {{.RefreshToken}}

+` diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..7bde53395 --- /dev/null +++ b/go.sum @@ -0,0 +1,122 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1 h1:LqbZZ9sNMWVjeXS4NN5oVvhMjDyLhmA1LG86oSo+IqY= +github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da h1:FjHUJJ7oBW4G/9j1KzlHaXL09LyMVM9rupS39lncbXk= +github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.29 h1:QT0utmUJ4/12rmsVQrJ3u55bycPkKqGYuGT4tyRhxSQ= +github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/markbates/going v1.0.0 h1:DQw0ZP7NbNlFGcKbcE/IVSOAFzScxRtLpd0rLMzLhq0= +github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA= +github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c h1:3wkDRdxK92dF+c1ke2dtj7ZzemFWBHB9plnJOtlwdFA= +github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gothic/gothic.go b/gothic/gothic.go new file mode 100644 index 000000000..b895a12d1 --- /dev/null +++ b/gothic/gothic.go @@ -0,0 +1,316 @@ +/* +Package gothic wraps common behaviour when using Goth. This makes it quick, and easy, to get up +and running with Goth. Of course, if you want complete control over how things flow, in regard +to the authentication process, feel free and use Goth directly. + +See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action. +*/ +package gothic + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + + "github.com/gorilla/sessions" + "github.com/markbates/goth" +) + +// SessionName is the key used to access the session store. +const SessionName = "_gothic_session" + +// Store can/should be set by applications using gothic. The default is a cookie store. +var Store sessions.Store +var defaultStore sessions.Store + +var keySet = false + +type key int + +// ProviderParamKey can be used as a key in context when passing in a provider +const ProviderParamKey key = iota + +func init() { + key := []byte(os.Getenv("SESSION_SECRET")) + keySet = len(key) != 0 + + cookieStore := sessions.NewCookieStore(key) + cookieStore.Options.HttpOnly = true + Store = cookieStore + defaultStore = Store +} + +/* +BeginAuthHandler is a convenience handler for starting the authentication process. +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +BeginAuthHandler will redirect the user to the appropriate authentication end-point +for the requested provider. + +See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action. +*/ +func BeginAuthHandler(res http.ResponseWriter, req *http.Request) { + url, err := GetAuthURL(res, req) + if err != nil { + res.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(res, err) + return + } + + http.Redirect(res, req, url, http.StatusTemporaryRedirect) +} + +// SetState sets the state string associated with the given request. +// If no state string is associated with the request, one will be generated. +// This state is sent to the provider and can be retrieved during the +// callback. +var SetState = func(req *http.Request) string { + state := req.URL.Query().Get("state") + if len(state) > 0 { + return state + } + + // If a state query param is not passed in, generate a random + // base64-encoded nonce so that the state on the auth URL + // is unguessable, preventing CSRF attacks, as described in + // + // https://auth0.com/docs/protocols/oauth2/oauth-state#keep-reading + nonceBytes := make([]byte, 64) + _, err := io.ReadFull(rand.Reader, nonceBytes) + if err != nil { + panic("gothic: source of randomness unavailable: " + err.Error()) + } + return base64.URLEncoding.EncodeToString(nonceBytes) +} + +// GetState gets the state returned by the provider during the callback. +// This is used to prevent CSRF attacks, see +// http://tools.ietf.org/html/rfc6749#section-10.12 +var GetState = func(req *http.Request) string { + params := req.URL.Query() + if params.Encode() == "" && req.Method == http.MethodPost { + return req.FormValue("state") + } + return params.Get("state") +} + +/* +GetAuthURL starts the authentication process with the requested provided. +It will return a URL that should be used to send users to. + +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +I would recommend using the BeginAuthHandler instead of doing all of these steps +yourself, but that's entirely up to you. +*/ +func GetAuthURL(res http.ResponseWriter, req *http.Request) (string, error) { + if !keySet && defaultStore == Store { + fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.") + } + + providerName, err := GetProviderName(req) + if err != nil { + return "", err + } + + provider, err := goth.GetProvider(providerName) + if err != nil { + return "", err + } + sess, err := provider.BeginAuth(SetState(req)) + if err != nil { + return "", err + } + + url, err := sess.GetAuthURL() + if err != nil { + return "", err + } + + err = StoreInSession(providerName, sess.Marshal(), req, res) + + if err != nil { + return "", err + } + + return url, err +} + +/* +CompleteUserAuth does what it says on the tin. It completes the authentication +process and fetches all the basic information about the user from the provider. + +It expects to be able to get the name of the provider from the query parameters +as either "provider" or ":provider". + +See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action. +*/ +var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + if !keySet && defaultStore == Store { + fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.") + } + + providerName, err := GetProviderName(req) + if err != nil { + return goth.User{}, err + } + + provider, err := goth.GetProvider(providerName) + if err != nil { + return goth.User{}, err + } + + value, err := GetFromSession(providerName, req) + if err != nil { + return goth.User{}, err + } + defer Logout(res, req) + sess, err := provider.UnmarshalSession(value) + if err != nil { + return goth.User{}, err + } + + err = validateState(req, sess) + if err != nil { + return goth.User{}, err + } + + user, err := provider.FetchUser(sess) + if err == nil { + // user can be found with existing session data + return user, err + } + + params := req.URL.Query() + if params.Encode() == "" && req.Method == "POST" { + req.ParseForm() + params = req.Form + } + + // get new token and retry fetch + _, err = sess.Authorize(provider, params) + if err != nil { + return goth.User{}, err + } + + err = StoreInSession(providerName, sess.Marshal(), req, res) + + if err != nil { + return goth.User{}, err + } + + gu, err := provider.FetchUser(sess) + return gu, err +} + +// validateState ensures that the state token param from the original +// AuthURL matches the one included in the current (callback) request. +func validateState(req *http.Request, sess goth.Session) error { + rawAuthURL, err := sess.GetAuthURL() + if err != nil { + return err + } + + authURL, err := url.Parse(rawAuthURL) + if err != nil { + return err + } + + reqState := GetState(req) + + originalState := authURL.Query().Get("state") + if originalState != "" && (originalState != reqState) { + return errors.New("state token mismatch") + } + return nil +} + +// Logout invalidates a user session. +func Logout(res http.ResponseWriter, req *http.Request) error { + session, err := Store.Get(req, SessionName) + if err != nil { + return err + } + session.Options.MaxAge = -1 + session.Values = make(map[interface{}]interface{}) + err = session.Save(req, res) + if err != nil { + return errors.New("Could not delete user session ") + } + return nil +} + +// GetContextWithProvider returns a new request context containing the provider +func GetContextWithProvider(req *http.Request, provider string) *http.Request { + return req.WithContext(context.WithValue(req.Context(), ProviderParamKey, provider)) +} + +// StoreInSession stores a specified key/value pair in the session. +func StoreInSession(key string, value string, req *http.Request, res http.ResponseWriter) error { + session, _ := Store.New(req, SessionName) + + if err := updateSessionValue(session, key, value); err != nil { + return err + } + + return session.Save(req, res) +} + +// GetFromSession retrieves a previously-stored value from the session. +// If no value has previously been stored at the specified key, it will return an error. +func GetFromSession(key string, req *http.Request) (string, error) { + session, _ := Store.Get(req, SessionName) + value, err := getSessionValue(session, key) + if err != nil { + return "", errors.New("could not find a matching session for this request") + } + + return value, nil +} + +func getSessionValue(session *sessions.Session, key string) (string, error) { + value := session.Values[key] + if value == nil { + return "", fmt.Errorf("could not find a matching session for this request") + } + + rdata := strings.NewReader(value.(string)) + r, err := gzip.NewReader(rdata) + if err != nil { + return "", err + } + s, err := io.ReadAll(r) + if err != nil { + return "", err + } + + return string(s), nil +} + +func updateSessionValue(session *sessions.Session, key, value string) error { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + if _, err := gz.Write([]byte(value)); err != nil { + return err + } + if err := gz.Flush(); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + + session.Values[key] = b.String() + return nil +} diff --git a/gothic/gothic_test.go b/gothic/gothic_test.go new file mode 100644 index 000000000..22c8448a2 --- /dev/null +++ b/gothic/gothic_test.go @@ -0,0 +1,292 @@ +package gothic_test + +import ( + "bytes" + "compress/gzip" + "fmt" + "html" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gorilla/sessions" + "github.com/markbates/goth" + . "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/faux" + "github.com/stretchr/testify/assert" +) + +type mapKey struct { + r *http.Request + n string +} + +type ProviderStore struct { + Store map[mapKey]*sessions.Session +} + +func NewProviderStore() *ProviderStore { + return &ProviderStore{map[mapKey]*sessions.Session{}} +} + +func (p ProviderStore) Get(r *http.Request, name string) (*sessions.Session, error) { + s := p.Store[mapKey{r, name}] + if s == nil { + s, err := p.New(r, name) + return s, err + } + return s, nil +} + +func (p ProviderStore) New(r *http.Request, name string) (*sessions.Session, error) { + s := sessions.NewSession(p, name) + s.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 30, + } + p.Store[mapKey{r, name}] = s + return s, nil +} + +func (p ProviderStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { + p.Store[mapKey{r, s.Name()}] = s + return nil +} + +var fauxProvider goth.Provider + +func init() { + Store = NewProviderStore() + fauxProvider = &faux.Provider{} + goth.UseProviders(fauxProvider) +} + +func Test_BeginAuthHandler(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth?provider=faux", nil) + a.NoError(err) + + BeginAuthHandler(res, req) + + sess, err := Store.Get(req, SessionName) + if err != nil { + t.Fatalf("error getting faux Gothic session: %v", err) + } + + sessStr, ok := sess.Values["faux"].(string) + if !ok { + t.Fatalf("Gothic session not stored as marshalled string; was %T (value %v)", + sess.Values["faux"], sess.Values["faux"]) + } + gothSession, err := fauxProvider.UnmarshalSession(ungzipString(sessStr)) + if err != nil { + t.Fatalf("error unmarshalling faux Gothic session: %v", err) + } + au, _ := gothSession.GetAuthURL() + + a.Equal(http.StatusTemporaryRedirect, res.Code) + a.Contains(res.Body.String(), + fmt.Sprintf(`Temporary Redirect`, html.EscapeString(au))) +} + +func Test_GetAuthURL(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth?provider=faux", nil) + a.NoError(err) + + u, err := GetAuthURL(res, req) + a.NoError(err) + + // Check that we get the correct auth URL with a state parameter + parsed, err := url.Parse(u) + a.NoError(err) + a.Equal("http", parsed.Scheme) + a.Equal("example.com", parsed.Host) + q := parsed.Query() + a.Contains(q, "client_id") + a.Equal("code", q.Get("response_type")) + a.NotZero(q, "state") + + // Check that if we run GetAuthURL on another request, that request's + // auth URL has a different state from the previous one. + req2, err := http.NewRequest("GET", "/auth?provider=faux", nil) + a.NoError(err) + url2, err := GetAuthURL(httptest.NewRecorder(), req2) + a.NoError(err) + parsed2, err := url.Parse(url2) + a.NoError(err) + a.NotEqual(parsed.Query().Get("state"), parsed2.Query().Get("state")) +} + +func Test_CompleteUserAuth(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth/callback?provider=faux", nil) + a.NoError(err) + + sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} + session, _ := Store.Get(req, SessionName) + session.Values["faux"] = gzipString(sess.Marshal()) + err = session.Save(req, res) + a.NoError(err) + + user, err := CompleteUserAuth(res, req) + a.NoError(err) + + a.Equal(user.Name, "Homer Simpson") + a.Equal(user.Email, "homer@example.com") +} + +func Test_CompleteUserAuthWithSessionDeducedProvider(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + // Intentionally omit a provider argument, force looking in session. + req, err := http.NewRequest("GET", "/auth/callback", nil) + a.NoError(err) + + sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} + session, _ := Store.Get(req, SessionName) + session.Values["faux"] = gzipString(sess.Marshal()) + err = session.Save(req, res) + a.NoError(err) + + user, err := CompleteUserAuth(res, req) + a.NoError(err) + + a.Equal(user.Name, "Homer Simpson") + a.Equal(user.Email, "homer@example.com") +} + +func Test_CompleteUserAuthWithContextParamProvider(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth/callback", nil) + a.NoError(err) + + req = GetContextWithProvider(req, "faux") + + sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} + session, _ := Store.Get(req, SessionName) + session.Values["faux"] = gzipString(sess.Marshal()) + err = session.Save(req, res) + a.NoError(err) + + user, err := CompleteUserAuth(res, req) + a.NoError(err) + + a.Equal(user.Name, "Homer Simpson") + a.Equal(user.Email, "homer@example.com") +} + +func Test_Logout(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth/callback?provider=faux", nil) + a.NoError(err) + + sess := faux.Session{Name: "Homer Simpson", Email: "homer@example.com"} + session, _ := Store.Get(req, SessionName) + session.Values["faux"] = gzipString(sess.Marshal()) + err = session.Save(req, res) + a.NoError(err) + + user, err := CompleteUserAuth(res, req) + a.NoError(err) + + a.Equal(user.Name, "Homer Simpson") + a.Equal(user.Email, "homer@example.com") + err = Logout(res, req) + a.NoError(err) + session, _ = Store.Get(req, SessionName) + a.Equal(session.Values, make(map[interface{}]interface{})) + a.Equal(session.Options.MaxAge, -1) +} + +func Test_SetState(t *testing.T) { + a := assert.New(t) + + req, _ := http.NewRequest("GET", "/auth?state=state", nil) + a.Equal(SetState(req), "state") +} + +func Test_GetState(t *testing.T) { + a := assert.New(t) + + req, _ := http.NewRequest("GET", "/auth?state=state", nil) + a.Equal(GetState(req), "state") +} + +func Test_StateValidation(t *testing.T) { + a := assert.New(t) + + Store = NewProviderStore() + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth?provider=faux&state=state_REAL", nil) + a.NoError(err) + + BeginAuthHandler(res, req) + session, _ := Store.Get(req, SessionName) + + // Assert that matching states will return a nil error + req, _ = http.NewRequest("GET", "/auth/callback?provider=faux&state=state_REAL", nil) + session.Save(req, res) + _, err = CompleteUserAuth(res, req) + a.NoError(err) + + // Assert that mismatched states will return an error + req, _ = http.NewRequest("GET", "/auth/callback?provider=faux&state=state_FAKE", nil) + session.Save(req, res) + _, err = CompleteUserAuth(res, req) + a.Error(err) +} + +func Test_AppleStateValidation(t *testing.T) { + a := assert.New(t) + appleStateValue := "xyz123-#" + form := url.Values{} + form.Add("state", appleStateValue) + req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Form = form + a.Equal(appleStateValue, GetState(req)) +} + +func gzipString(value string) string { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + if _, err := gz.Write([]byte(value)); err != nil { + return "err" + } + if err := gz.Flush(); err != nil { + return "err" + } + if err := gz.Close(); err != nil { + return "err" + } + + return b.String() +} + +func ungzipString(value string) string { + rdata := strings.NewReader(value) + r, err := gzip.NewReader(rdata) + if err != nil { + return "err" + } + s, err := io.ReadAll(r) + if err != nil { + return "err" + } + + return string(s) +} diff --git a/gothic/provider.go b/gothic/provider.go new file mode 100644 index 000000000..f27d78dd2 --- /dev/null +++ b/gothic/provider.go @@ -0,0 +1,68 @@ +package gothic + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/mux" + "github.com/markbates/goth" +) + +// GetProviderName is a function used to get the name of a provider +// for a given request. By default, this provider is fetched from +// the URL query string. If you provide it in a different way, +// assign your own function to this variable that returns the provider +// name for your request. +var GetProviderName = getProviderName + +func getProviderName(req *http.Request) (string, error) { + // try to get it from the url param "provider" + if p := req.URL.Query().Get("provider"); p != "" { + return p, nil + } + + // try to get it from the url param ":provider" + if p := req.URL.Query().Get(":provider"); p != "" { + return p, nil + } + + // try to get it from the context's value of "provider" key + if p, ok := mux.Vars(req)["provider"]; ok { + return p, nil + } + + // try to get it from the go-context's value of "provider" key + if p, ok := req.Context().Value("provider").(string); ok { + return p, nil + } + + // try to get it from the url param "provider", when req is routed through 'chi' + if p := chi.URLParam(req, "provider"); p != "" { + return p, nil + } + + // try to get it from the route param for go >= 1.22 + if p := req.PathValue("provider"); p != "" { + return p, nil + } + + // try to get it from the go-context's value of providerContextKey key + if p, ok := req.Context().Value(ProviderParamKey).(string); ok { + return p, nil + } + + // As a fallback, loop over the used providers, if we already have a valid session for any provider (ie. user has already begun authentication with a provider), then return that provider name + providers := goth.GetProviders() + session, _ := Store.Get(req, SessionName) + for _, provider := range providers { + p := provider.Name() + value := session.Values[p] + if _, ok := value.(string); ok { + return p, nil + } + } + + // if not found then return an empty string with the corresponding error + return "", errors.New("you must select a provider") +} diff --git a/gothic/provider_test.go b/gothic/provider_test.go new file mode 100644 index 000000000..f17db86ed --- /dev/null +++ b/gothic/provider_test.go @@ -0,0 +1,44 @@ +package gothic_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/markbates/goth/gothic" + "github.com/stretchr/testify/assert" +) + +func Test_GetAuthURL122(t *testing.T) { + a := assert.New(t) + + res := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/auth", nil) + a.NoError(err) + req.SetPathValue("provider", "faux") + + u, err := gothic.GetAuthURL(res, req) + a.NoError(err) + + // Check that we get the correct auth URL with a state parameter + parsed, err := url.Parse(u) + a.NoError(err) + a.Equal("http", parsed.Scheme) + a.Equal("example.com", parsed.Host) + q := parsed.Query() + a.Contains(q, "client_id") + a.Equal("code", q.Get("response_type")) + a.NotZero(q, "state") + + // Check that if we run GetAuthURL on another request, that request's + // auth URL has a different state from the previous one. + req2, err := http.NewRequest("GET", "/auth?provider=faux", nil) + a.NoError(err) + req2.SetPathValue("provider", "faux") + url2, err := gothic.GetAuthURL(httptest.NewRecorder(), req2) + a.NoError(err) + parsed2, err := url.Parse(url2) + a.NoError(err) + a.NotEqual(parsed.Query().Get("state"), parsed2.Query().Get("state")) +} diff --git a/provider.go b/provider.go new file mode 100644 index 000000000..1aaf1b4bb --- /dev/null +++ b/provider.go @@ -0,0 +1,87 @@ +package goth + +import ( + "context" + "fmt" + "net/http" + "sync" + + "golang.org/x/oauth2" +) + +// Provider needs to be implemented for each 3rd party authentication provider +// e.g. Facebook, Twitter, etc... +type Provider interface { + Name() string + SetName(name string) + BeginAuth(state string) (Session, error) + UnmarshalSession(string) (Session, error) + FetchUser(Session) (User, error) + Debug(bool) + RefreshToken(refreshToken string) (*oauth2.Token, error) // Get new access token based on the refresh token + RefreshTokenAvailable() bool // Refresh token is provided by auth provider or not +} + +const NoAuthUrlErrorMessage = "an AuthURL has not been set" + +// Providers is the list of known/available providers. +type Providers map[string]Provider + +var ( + providersHat sync.RWMutex + providers = Providers{} +) + +// UseProviders adds a list of available providers for use with Goth. +// Can be called multiple times. If you pass the same provider more +// than once, the last will be used. +func UseProviders(viders ...Provider) { + providersHat.Lock() + defer providersHat.Unlock() + + for _, provider := range viders { + providers[provider.Name()] = provider + } +} + +// GetProviders returns a list of all the providers currently in use. +func GetProviders() Providers { + return providers +} + +// GetProvider returns a previously created provider. If Goth has not +// been told to use the named provider it will return an error. +func GetProvider(name string) (Provider, error) { + providersHat.RLock() + provider := providers[name] + providersHat.RUnlock() + if provider == nil { + return nil, fmt.Errorf("no provider for %s exists", name) + } + return provider, nil +} + +// ClearProviders will remove all providers currently in use. +// This is useful, mostly, for testing purposes. +func ClearProviders() { + providersHat.Lock() + defer providersHat.Unlock() + + providers = Providers{} +} + +// ContextForClient provides a context for use with oauth2. +func ContextForClient(h *http.Client) context.Context { + if h == nil { + return oauth2.NoContext + } + return context.WithValue(oauth2.NoContext, oauth2.HTTPClient, h) +} + +// HTTPClientWithFallBack to be used in all fetch operations. +func HTTPClientWithFallBack(h *http.Client) *http.Client { + if h != nil { + return h + } + return http.DefaultClient +} diff --git a/provider_test.go b/provider_test.go new file mode 100644 index 000000000..8890577dc --- /dev/null +++ b/provider_test.go @@ -0,0 +1,35 @@ +package goth_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/faux" + "github.com/stretchr/testify/assert" +) + +func Test_UseProviders(t *testing.T) { + a := assert.New(t) + + provider := &faux.Provider{} + goth.UseProviders(provider) + a.Equal(len(goth.GetProviders()), 1) + a.Equal(goth.GetProviders()[provider.Name()], provider) + goth.ClearProviders() +} + +func Test_GetProvider(t *testing.T) { + a := assert.New(t) + + provider := &faux.Provider{} + goth.UseProviders(provider) + + p, err := goth.GetProvider(provider.Name()) + a.NoError(err) + a.Equal(p, provider) + + _, err = goth.GetProvider("unknown") + a.Error(err) + a.Equal(err.Error(), "no provider for unknown exists") + goth.ClearProviders() +} diff --git a/providers/amazon/amazon.go b/providers/amazon/amazon.go new file mode 100644 index 000000000..5a0b175cb --- /dev/null +++ b/providers/amazon/amazon.go @@ -0,0 +1,166 @@ +// Package amazon implements the OAuth2 protocol for authenticating users through amazon. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package amazon + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.amazon.com/ap/oa" + tokenURL string = "https://api.amazon.com/auth/o2/token" + endpointProfile string = "https://api.amazon.com/user/profile" +) + +// Provider is the implementation of `goth.Provider` for accessing Amazon. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Amazon provider and sets up important connection details. +// You should always call `amazon.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "amazon", + } + p.config = newConfig(p, scopes) + return p +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Debug is a no-op for the amazon package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Amazon for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Amazon and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := goth.HTTPClientWithFallBack(p.Client()).Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "profile", "postal_code") + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Location string `json:"postal_code"` + Email string `json:"email"` + ID string `json:"user_id"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.NickName = u.Name + user.UserID = u.ID + user.Location = u.Location + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/amazon/amazon_test.go b/providers/amazon/amazon_test.go new file mode 100644 index 000000000..6360836bd --- /dev/null +++ b/providers/amazon/amazon_test.go @@ -0,0 +1,53 @@ +package amazon_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/amazon" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("AMAZON_KEY")) + a.Equal(p.Secret, os.Getenv("AMAZON_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*amazon.Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.amazon.com/ap/oa") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.amazon.com/ap/oa","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*amazon.Session) + a.Equal(s.AuthURL, "https://www.amazon.com/ap/oa") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *amazon.Provider { + return amazon.New(os.Getenv("AMAZON_KEY"), os.Getenv("AMAZON_SECRET"), "/foo") +} diff --git a/providers/amazon/session.go b/providers/amazon/session.go new file mode 100644 index 000000000..173f2a5b4 --- /dev/null +++ b/providers/amazon/session.go @@ -0,0 +1,63 @@ +package amazon + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Amazon. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Amazon provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Amazon and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/amazon/session_test.go b/providers/amazon/session_test.go new file mode 100644 index 000000000..32cadeb14 --- /dev/null +++ b/providers/amazon/session_test.go @@ -0,0 +1,48 @@ +package amazon_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/amazon" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &amazon.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &amazon.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &amazon.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &amazon.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/apple/apple.go b/providers/apple/apple.go new file mode 100644 index 000000000..cd3926b77 --- /dev/null +++ b/providers/apple/apple.go @@ -0,0 +1,187 @@ +// Package `apple` implements the OAuth2 protocol for authenticating users through Apple. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package apple + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authEndpoint = "https://appleid.apple.com/auth/authorize" + tokenEndpoint = "https://appleid.apple.com/auth/token" + + ScopeEmail = "email" + ScopeName = "name" + + AppleAudOrIss = "https://appleid.apple.com" +) + +type Provider struct { + providerName string + clientId string + secret string + redirectURL string + config *oauth2.Config + httpClient *http.Client + formPostResponseMode bool + timeNowFn func() time.Time +} + +func New(clientId, secret, redirectURL string, httpClient *http.Client, scopes ...string) *Provider { + p := &Provider{ + clientId: clientId, + secret: secret, + redirectURL: redirectURL, + providerName: "apple", + } + p.configure(scopes) + p.httpClient = httpClient + return p +} + +func (p Provider) Name() string { + return p.providerName +} + +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p Provider) ClientId() string { + return p.clientId +} + +type SecretParams struct { + PKCS8PrivateKey, TeamId, KeyId, ClientId string + Iat, Exp int +} + +func MakeSecret(sp SecretParams) (*string, error) { + block, rest := pem.Decode([]byte(strings.TrimSpace(sp.PKCS8PrivateKey))) + if block == nil || len(rest) > 0 { + return nil, errors.New("invalid private key") + } + pk, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": sp.TeamId, + "iat": sp.Iat, + "exp": sp.Exp, + "aud": AppleAudOrIss, + "sub": sp.ClientId, + }) + token.Header["kid"] = sp.KeyId + ss, err := token.SignedString(pk) + return &ss, err +} + +func (p Provider) Secret() string { + return p.secret +} + +func (p Provider) RedirectURL() string { + return p.redirectURL +} + +func (p Provider) BeginAuth(state string) (goth.Session, error) { + opts := make([]oauth2.AuthCodeOption, 0, 1) + if p.formPostResponseMode { + opts = append(opts, oauth2.SetAuthURLParam("response_mode", "form_post")) + } + authURL := p.config.AuthCodeURL(state, opts...) + if authURL != "" { + if u, err := url.Parse(authURL); err == nil { + // Apple requires spaces to be encoded as %20 instead of + + u.RawQuery = strings.ReplaceAll(u.RawQuery, "+", "%20") + authURL = u.String() + } + } + return &Session{ + AuthURL: authURL, + }, nil +} + +func (Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} + +// Apple doesn't seem to provide a user profile endpoint like all the other providers do. +// Therefore this will return a User with the unique identifier obtained through authorization +// as the only identifying attribute. +// A full name and email can be obtained from the form post response (parameter 'user') +// to the redirect page following authentication, if the name and email scopes are requested. +// Additionally, if the response type is form_post and the email scope is requested, the email +// will be encoded into the ID token in the email claim. +func (p Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + if s.AccessToken == "" { + return goth.User{}, fmt.Errorf("no access token obtained for session with provider %s", p.Name()) + } + return goth.User{ + Provider: p.Name(), + UserID: s.ID.Sub, + Email: s.ID.Email, + AccessToken: s.AccessToken, + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + }, nil +} + +// Debug is a no-op for the apple package. +func (Provider) Debug(bool) {} + +func (p Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.httpClient) +} + +func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func (Provider) RefreshTokenAvailable() bool { + return true +} + +func (p *Provider) configure(scopes []string) { + c := &oauth2.Config{ + ClientID: p.clientId, + ClientSecret: p.secret, + RedirectURL: p.redirectURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authEndpoint, + TokenURL: tokenEndpoint, + }, + Scopes: make([]string, 0, len(scopes)), + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + if scope == "name" || scope == "email" { + p.formPostResponseMode = true + } + } + + p.config = c +} diff --git a/providers/apple/apple_test.go b/providers/apple/apple_test.go new file mode 100644 index 000000000..2b5021f34 --- /dev/null +++ b/providers/apple/apple_test.go @@ -0,0 +1,119 @@ +package apple + +import ( + "net/http" + "net/url" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientId(), os.Getenv("APPLE_KEY")) + a.Equal(p.Secret(), os.Getenv("APPLE_SECRET")) + a.Equal(p.RedirectURL(), "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "appleid.apple.com/auth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://appleid.apple.com/auth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*Session) + a.Equal(s.AuthURL, "https://appleid.apple.com/auth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *Provider { + return New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "/foo", nil) +} + +func TestMakeSecret(t *testing.T) { + a := assert.New(t) + + iat := 1570636633 + ss, err := MakeSecret(SecretParams{ + PKCS8PrivateKey: `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPALVklHT2n9FNxeP +c1+TCP+Ep7YOU7T9KB5MTVpjL1ShRANCAATXAbDMQ/URATKRoSIFMkwetLH/M2S4 +nNFzkp23qt9IJDivieB/BBJct1UvhoICg5eZDhSR+x7UH3Uhog8qgoIC +-----END PRIVATE KEY-----`, // example + TeamId: "TK...", + KeyId: "", + ClientId: "", + Iat: iat, + Exp: iat + 15777000, + }) + a.NoError(err) + a.NotZero(ss) + // fmt.Printf("signed secret: %s", *ss) +} + +func TestAuthorize(t *testing.T) { + ss := "" // a value from MakeSecret + if ss == "" { + t.Skip() + } + + a := assert.New(t) + + client := http.DefaultClient + p := New( + "", + ss, + "https://example-app.com/redirect", + client, + "name", "email") + session, _ := p.BeginAuth("test_state") + + _, err := session.Authorize(p, url.Values{ + "code": []string{""}, + }) + if err != nil { + errStr := err.Error() + a.Fail(errStr) + } +} + +func TestBeginAuth(t *testing.T) { + a := assert.New(t) + + client := http.DefaultClient + p := New( + "", + "", + "https://example-app.com/redirect", + client, + "name", "email") + session, _ := p.BeginAuth("test_state") + + s := session.(*Session) + + // Apple requires spaces to be encoded as %20 instead of + + a.Equal(s.AuthURL, "https://appleid.apple.com/auth/authorize?client_id=%3CclientId%3E&redirect_uri=https%3A%2F%2Fexample-app.com%2Fredirect&response_mode=form_post&response_type=code&scope=name%20email&state=test_state") +} diff --git a/providers/apple/session.go b/providers/apple/session.go new file mode 100644 index 000000000..becfef364 --- /dev/null +++ b/providers/apple/session.go @@ -0,0 +1,162 @@ +package apple + +import ( + "context" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/jwk" + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + idTokenVerificationKeyEndpoint = "https://appleid.apple.com/auth/keys" +) + +type ID struct { + Sub string `json:"sub"` + Email string `json:"email"` + IsPrivateEmail bool `json:"is_private_email"` + EmailVerified bool `json:"email_verified"` +} + +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + ID +} + +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +type IDTokenClaims struct { + jwt.RegisteredClaims + AccessTokenHash string `json:"at_hash"` + AuthTime int `json:"auth_time"` + Email string `json:"email"` + IsPrivateEmail BoolString `json:"is_private_email"` + EmailVerified BoolString `json:"email_verified,omitempty"` +} + +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + opts := []oauth2.AuthCodeOption{ + // Apple requires client id & secret as headers + oauth2.SetAuthURLParam("client_id", p.clientId), + oauth2.SetAuthURLParam("client_secret", p.secret), + } + token, err := p.config.Exchange(context.Background(), params.Get("code"), opts...) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + + if idToken := token.Extra("id_token"); idToken != nil { + idToken, err := jwt.ParseWithClaims(idToken.(string), &IDTokenClaims{}, func(t *jwt.Token) (interface{}, error) { + kid := t.Header["kid"].(string) + claims := t.Claims.(*IDTokenClaims) + validator := jwt.NewValidator(jwt.WithAudience(p.clientId), jwt.WithIssuer(AppleAudOrIss)) + err := validator.Validate(claims) + if err != nil { + return nil, err + } + + // per OpenID Connect Core 1.0 §3.2.2.9, Access Token Validation + hash := sha256.Sum256([]byte(s.AccessToken)) + halfHash := hash[0:(len(hash) / 2)] + encodedHalfHash := base64.RawURLEncoding.EncodeToString(halfHash) + if encodedHalfHash != claims.AccessTokenHash { + return nil, fmt.Errorf(`identity token invalid`) + } + + // get the public key for verifying the identity token signature + set, err := jwk.Fetch(context.Background(), idTokenVerificationKeyEndpoint, jwk.WithHTTPClient(p.Client())) + if err != nil { + return nil, err + } + selectedKey, found := set.LookupKeyID(kid) + if !found { + return nil, errors.New("could not find matching public key") + } + pubKey := &rsa.PublicKey{} + err = selectedKey.Raw(pubKey) + if err != nil { + return nil, err + } + return pubKey, nil + }) + if err != nil { + return "", err + } + s.ID = ID{ + Sub: idToken.Claims.(*IDTokenClaims).Subject, + Email: idToken.Claims.(*IDTokenClaims).Email, + IsPrivateEmail: idToken.Claims.(*IDTokenClaims).IsPrivateEmail.Value(), + EmailVerified: idToken.Claims.(*IDTokenClaims).EmailVerified.Value(), + } + } + + return token.AccessToken, err +} + +func (s Session) String() string { + return s.Marshal() +} + +// BoolString is a type that can be unmarshalled from a JSON field that can be either a boolean or a string. +// It is used to unmarshal some fields in the Apple ID token that can be sent as either boolean or string. +// See https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773 +type BoolString struct { + BoolValue bool + StringValue string + IsValidBool bool +} + +func (bs *BoolString) UnmarshalJSON(data []byte) error { + var b bool + if err := json.Unmarshal(data, &b); err == nil { + bs.BoolValue = b + bs.IsValidBool = true + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err == nil { + bs.StringValue = s + return nil + } + + return errors.New("json field can be either boolean or string") +} + +func (bs *BoolString) Value() bool { + if bs.IsValidBool { + return bs.BoolValue + } + return bs.StringValue == "true" +} diff --git a/providers/apple/session_test.go b/providers/apple/session_test.go new file mode 100644 index 000000000..031b91637 --- /dev/null +++ b/providers/apple/session_test.go @@ -0,0 +1,98 @@ +package apple + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/markbates/goth" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":"","email":"","is_private_email":false,"email_verified":false}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Equal(s.String(), s.Marshal()) +} + +func TestIDTokenClaimsUnmarshal(t *testing.T) { + t.Parallel() + a := assert.New(t) + + cases := []struct { + name string + idToken string + expectedClaims IDTokenClaims + }{ + { + name: "'is_private_email' claim is a string", + idToken: `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":"","email":"test-email@privaterelay.appleid.com","is_private_email":"true", "email_verified":"true"}`, + expectedClaims: IDTokenClaims{ + Email: "test-email@privaterelay.appleid.com", + IsPrivateEmail: BoolString{ + StringValue: "true", + }, + EmailVerified: BoolString{ + StringValue: "true", + }, + }, + }, + { + name: "'is_private_email' claim is a boolean", + idToken: `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":"","email":"test-email@privaterelay.appleid.com","is_private_email":true,"email_verified":true}`, + expectedClaims: IDTokenClaims{ + Email: "test-email@privaterelay.appleid.com", + IsPrivateEmail: BoolString{ + BoolValue: true, + IsValidBool: true, + }, + EmailVerified: BoolString{ + BoolValue: true, + IsValidBool: true, + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + idTokenClaims := IDTokenClaims{} + err := json.Unmarshal([]byte(c.idToken), &idTokenClaims) + a.NoError(err) + a.Equal(idTokenClaims, c.expectedClaims) + }) + } +} diff --git a/providers/auth0/auth0.go b/providers/auth0/auth0.go new file mode 100644 index 000000000..c07b9db47 --- /dev/null +++ b/providers/auth0/auth0.go @@ -0,0 +1,183 @@ +// Package auth0 implements the OAuth2 protocol for authenticating users through uber. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package auth0 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authEndpoint string = "/authorize" + tokenEndpoint string = "/oauth/token" + endpointProfile string = "/userinfo" + protocol string = "https://" +) + +// Provider is the implementation of `goth.Provider` for accessing Auth0. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + Domain string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +type auth0UserResp struct { + Name string `json:"name"` + NickName string `json:"nickname"` + Email string `json:"email"` + UserID string `json:"sub"` + AvatarURL string `json:"picture"` +} + +// New creates a new Auth0 provider and sets up important connection details. +// You should always call `auth0.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, auth0Domain string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + Domain: auth0Domain, + providerName: "auth0", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the auth0 package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Auth0 for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Auth0 and access basic information about the user. +// the full response will be included in RawData +// https://auth0.com/docs/api/authentication#get-user-info + +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + userProfileURL := protocol + p.Domain + endpointProfile + req, err := http.NewRequest("GET", userProfileURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: protocol + provider.Domain + authEndpoint, + TokenURL: protocol + provider.Domain + tokenEndpoint, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "profile", "openid") + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + var rawData map[string]interface{} + + buf := new(bytes.Buffer) + buf.ReadFrom(r) + err := json.Unmarshal(buf.Bytes(), &rawData) + if err != nil { + return err + } + + u := auth0UserResp{} + err = json.Unmarshal(buf.Bytes(), &u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.NickName = u.NickName + user.UserID = u.UserID + user.AvatarURL = u.AvatarURL + user.RawData = rawData + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(oauth2.NoContext, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/auth0/auth0_test.go b/providers/auth0/auth0_test.go new file mode 100644 index 000000000..06be18197 --- /dev/null +++ b/providers/auth0/auth0_test.go @@ -0,0 +1,101 @@ +package auth0_test + +import ( + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/markbates/goth" + "github.com/markbates/goth/providers/auth0" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("AUTH0_KEY")) + a.Equal(p.Secret, os.Getenv("AUTH0_SECRET")) + a.Equal(p.Domain, os.Getenv("AUTH0_DOMAIN")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*auth0.Session) + a.NoError(err) + expectedAuthURL := "https://" + os.Getenv("AUTH0_DOMAIN") + "/authorize" + a.Contains(s.AuthURL, expectedAuthURL) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + sessionResp := `{"AuthURL":"https://` + p.Domain + `/oauth/authorize","AccessToken":"1234567890"}` + session, err := p.UnmarshalSession(sessionResp) + a.NoError(err) + + s := session.(*auth0.Session) + expectedAuthURL := "https://" + os.Getenv("AUTH0_DOMAIN") + "/oauth/authorize" + a.Equal(s.AuthURL, expectedAuthURL) + a.Equal(s.AccessToken, "1234567890") +} + +func Test_FetchUser(t *testing.T) { + // t.Parallel() + a := assert.New(t) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + sampleResp := `{ + "email_verified": false, + "email": "test.account@userinfo.com", + "clientID": "q2hnj2iu...", + "updated_at": "2016-12-05T15:15:40.545Z", + "name": "test.account@userinfo.com", + "picture": "https://s.gravatar.com/avatar/dummy.png", + "user_id": "auth0|58454...", + "nickname": "test.account", + "identities": [ + { + "user_id": "58454...", + "provider": "auth0", + "connection": "Username-Password-Authentication", + "isSocial": false + }], + "created_at": "2016-12-05T11:16:59.640Z", + "sub": "auth0|58454..." + }` + + httpmock.RegisterResponder("GET", "https://"+os.Getenv("AUTH0_DOMAIN")+"/userinfo", httpmock.NewStringResponder(200, sampleResp)) + + p := provider() + session, _ := p.BeginAuth("test_state") + s := session.(*auth0.Session) + s.AccessToken = "token" + u, err := p.FetchUser(s) + a.Nil(err) + a.Equal(u.Email, "test.account@userinfo.com") + a.Equal(u.UserID, "auth0|58454...") + a.Equal(u.NickName, "test.account") + a.Equal(u.Name, "test.account@userinfo.com") + a.Equal("token", u.AccessToken) + +} + +func provider() *auth0.Provider { + return auth0.New(os.Getenv("AUTH0_KEY"), os.Getenv("AUTH0_SECRET"), "/foo", os.Getenv("AUTH0_DOMAIN")) +} diff --git a/providers/auth0/session.go b/providers/auth0/session.go new file mode 100644 index 000000000..ad2f7e29b --- /dev/null +++ b/providers/auth0/session.go @@ -0,0 +1,64 @@ +package auth0 + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Auth0. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Auth0 provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Auth0 and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/auth0/session_test.go b/providers/auth0/session_test.go new file mode 100644 index 000000000..2ddaaa684 --- /dev/null +++ b/providers/auth0/session_test.go @@ -0,0 +1,48 @@ +package auth0_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/auth0" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &auth0.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &auth0.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &auth0.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &auth0.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/azuread/azuread.go b/providers/azuread/azuread.go new file mode 100644 index 000000000..8717ddf37 --- /dev/null +++ b/providers/azuread/azuread.go @@ -0,0 +1,187 @@ +// Package azuread implements the OAuth2 protocol for authenticating users through AzureAD. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +// To use microsoft personal account use microsoftonline provider +package azuread + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://login.microsoftonline.com/common/oauth2/authorize" + tokenURL string = "https://login.microsoftonline.com/common/oauth2/token" + endpointProfile string = "https://graph.windows.net/me?api-version=1.6" + graphAPIResource string = "https://graph.windows.net/" +) + +// New creates a new AzureAD provider, and sets up important connection details. +// You should always call `AzureAD.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, resources []string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "azuread", + } + + p.resources = make([]string, 0, 1+len(resources)) + p.resources = append(p.resources, graphAPIResource) + p.resources = append(p.resources, resources...) + + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing AzureAD. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + resources []string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client is HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks AzureAD for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authURL := p.config.AuthCodeURL(state) + + // Azure ad requires at least one resource + authURL += "&resource=" + url.QueryEscape(strings.Join(p.resources, " ")) + + return &Session{ + AuthURL: authURL, + }, nil +} + +// FetchUser will go to AzureAD and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + msSession := session.(*Session) + user := goth.User{ + AccessToken: msSession.AccessToken, + Provider: p.Name(), + ExpiresAt: msSession.ExpiresAt, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + + req.Header.Set(authorizationHeader(msSession)) + + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + err = userFromReader(response.Body, &user) + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "user_impersonation") + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Email string `json:"mail"` + FirstName string `json:"givenName"` + LastName string `json:"surname"` + NickName string `json:"mailNickname"` + UserPrincipalName string `json:"userPrincipalName"` + Location string `json:"usageLocation"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + user.Email = u.Email + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Name + user.Location = u.Location + user.UserID = u.UserPrincipalName // AzureAD doesn't provide separate user_id + + return nil +} + +func authorizationHeader(session *Session) (string, string) { + return "Authorization", fmt.Sprintf("Bearer %s", session.AccessToken) +} diff --git a/providers/azuread/azuread_test.go b/providers/azuread/azuread_test.go new file mode 100644 index 000000000..5608a4756 --- /dev/null +++ b/providers/azuread/azuread_test.go @@ -0,0 +1,55 @@ +package azuread_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/azuread" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := azureadProvider() + + a.Equal(provider.ClientKey, os.Getenv("AZUREAD_KEY")) + a.Equal(provider.Secret, os.Getenv("AZUREAD_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := azureadProvider() + a.Implements((*goth.Provider)(nil), p) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := azureadProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*azuread.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.microsoftonline.com/common/oauth2/authorize") + a.Contains(s.AuthURL, "https%3A%2F%2Fgraph.windows.net%2F") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := azureadProvider() + session, err := provider.UnmarshalSession(`{"AuthURL":"https://login.microsoftonline.com/common/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*azuread.Session) + a.Equal(s.AuthURL, "https://login.microsoftonline.com/common/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func azureadProvider() *azuread.Provider { + return azuread.New(os.Getenv("AZUREAD_KEY"), os.Getenv("AZUREAD_SECRET"), "/foo", nil) +} diff --git a/providers/azuread/session.go b/providers/azuread/session.go new file mode 100644 index 000000000..098a9dc6d --- /dev/null +++ b/providers/azuread/session.go @@ -0,0 +1,63 @@ +package azuread + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session is the implementation of `goth.Session` for accessing AzureAD. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + + return s.AuthURL, nil +} + +// Authorize the session with AzureAD and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + session := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(session) + return session, err +} diff --git a/providers/azuread/session_test.go b/providers/azuread/session_test.go new file mode 100644 index 000000000..4192d4a50 --- /dev/null +++ b/providers/azuread/session_test.go @@ -0,0 +1,48 @@ +package azuread_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/azuread" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azuread.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azuread.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azuread.Session{} + + data := s.Marshal() + a.Equal(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`, data) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azuread.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/azureadv2/azureadv2.go b/providers/azureadv2/azureadv2.go new file mode 100644 index 000000000..e76419f80 --- /dev/null +++ b/providers/azureadv2/azureadv2.go @@ -0,0 +1,232 @@ +package azureadv2 + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints +const ( + authURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize" + tokenURLTemplate string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + graphAPIResource string = "https://graph.microsoft.com/v1.0/" +) + +type ( + // TenantType are the well known tenant types to scope the users that can authenticate. TenantType is not an + // exclusive list of Azure Tenants which can be used. A consumer can also use their own Tenant ID to scope + // authentication to their specific Tenant either through the Tenant ID or the friendly domain name. + // + // see also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints + TenantType string + + // Provider is the implementation of `goth.Provider` for accessing AzureAD V2. + Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + } + + // ProviderOptions are the collection of optional configuration to provide when constructing a Provider + ProviderOptions struct { + Scopes []ScopeType + Tenant TenantType + } +) + +// These are the well known Azure AD Tenants. These are not an exclusive list of all Tenants +// +// See also https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints +const ( + // CommonTenant allows users with both personal Microsoft accounts and work/school accounts from Azure Active + // Directory to sign into the application. + CommonTenant TenantType = "common" + + // OrganizationsTenant allows only users with work/school accounts from Azure Active Directory to sign into the application. + OrganizationsTenant TenantType = "organizations" + + // ConsumersTenant allows only users with personal Microsoft accounts (MSA) to sign into the application. + ConsumersTenant TenantType = "consumers" +) + +// New creates a new AzureAD provider, and sets up important connection details. +// You should always call `AzureAD.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, opts ProviderOptions) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "azureadv2", + } + + p.config = newConfig(p, opts) + return p +} + +func newConfig(provider *Provider, opts ProviderOptions) *oauth2.Config { + tenant := opts.Tenant + if tenant == "" { + tenant = CommonTenant + } + + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf(authURLTemplate, tenant), + TokenURL: fmt.Sprintf(tokenURLTemplate, tenant), + }, + Scopes: []string{}, + } + + if len(opts.Scopes) > 0 { + c.Scopes = append(c.Scopes, scopesToStrings(opts.Scopes...)...) + } else { + defaultScopes := scopesToStrings(OpenIDScope, ProfileScope, EmailScope, UserReadScope) + c.Scopes = append(c.Scopes, defaultScopes...) + } + + return c +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client is HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the package +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks for an authentication end-point for AzureAD. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authURL := p.config.AuthCodeURL(state) + + return &Session{ + AuthURL: authURL, + }, nil +} + +// FetchUser will go to AzureAD and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + msSession := session.(*Session) + user := goth.User{ + AccessToken: msSession.AccessToken, + Provider: p.Name(), + ExpiresAt: msSession.ExpiresAt, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", graphAPIResource+"me", nil) + if err != nil { + return user, err + } + + req.Header.Set(authorizationHeader(msSession)) + + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + err = userFromReader(response.Body, &user) + user.AccessToken = msSession.AccessToken + user.RefreshToken = msSession.RefreshToken + user.ExpiresAt = msSession.ExpiresAt + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func authorizationHeader(session *Session) (string, string) { + return "Authorization", fmt.Sprintf("Bearer %s", session.AccessToken) +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + ID string `json:"id"` // The unique identifier for the user. + BusinessPhones []string `json:"businessPhones"` // The user's phone numbers. + DisplayName string `json:"displayName"` // The name displayed in the address book for the user. + FirstName string `json:"givenName"` // The first name of the user. + JobTitle string `json:"jobTitle"` // The user's job title. + Email string `json:"mail"` // The user's email address. + MobilePhone string `json:"mobilePhone"` // The user's cellphone number. + OfficeLocation string `json:"officeLocation"` // The user's physical office location. + PreferredLanguage string `json:"preferredLanguage"` // The user's language of preference. + LastName string `json:"surname"` // The last name of the user. + UserPrincipalName string `json:"userPrincipalName"` // The user's principal name. + }{} + + userBytes, err := io.ReadAll(r) + if err != nil { + return err + } + + if err := json.Unmarshal(userBytes, &u); err != nil { + return err + } + + user.Email = u.Email + user.Name = u.DisplayName + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.DisplayName + user.Location = u.OfficeLocation + user.UserID = u.ID + user.AvatarURL = graphAPIResource + fmt.Sprintf("users/%s/photo/$value", u.ID) + // Make sure all the information returned is available via RawData + if err := json.Unmarshal(userBytes, &user.RawData); err != nil { + return err + } + + return nil +} + +func scopesToStrings(scopes ...ScopeType) []string { + strs := make([]string, len(scopes)) + for i := 0; i < len(scopes); i++ { + strs[i] = string(scopes[i]) + } + return strs +} diff --git a/providers/azureadv2/azureadv2_test.go b/providers/azureadv2/azureadv2_test.go new file mode 100644 index 000000000..265ec90a6 --- /dev/null +++ b/providers/azureadv2/azureadv2_test.go @@ -0,0 +1,62 @@ +package azureadv2_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/azureadv2" + "github.com/stretchr/testify/assert" +) + +const ( + applicationID = "6731de76-14a6-49ae-97bc-6eba6914391e" + secret = "foo" + redirectUri = "https://localhost:3000" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := azureadProvider() + + a.Equal(provider.Name(), "azureadv2") + a.Equal(provider.ClientKey, applicationID) + a.Equal(provider.Secret, secret) + a.Equal(provider.CallbackURL, redirectUri) +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := azureadProvider() + a.Implements((*goth.Provider)(nil), p) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := azureadProvider() + session, err := provider.BeginAuth("test_state") + a.NoError(err) + s := session.(*azureadv2.Session) + a.Contains(s.AuthURL, "login.microsoftonline.com/common/oauth2/v2.0/authorize") + a.Contains(s.AuthURL, "redirect_uri=https%3A%2F%2Flocalhost%3A3000") + a.Contains(s.AuthURL, "scope=openid+profile+email") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := azureadProvider() + session, err := provider.UnmarshalSession(`{"au":"http://foo","at":"1234567890"}`) + a.NoError(err) + + s := session.(*azureadv2.Session) + a.Equal(s.AuthURL, "http://foo") + a.Equal(s.AccessToken, "1234567890") +} + +func azureadProvider() *azureadv2.Provider { + return azureadv2.New(applicationID, secret, redirectUri, azureadv2.ProviderOptions{}) +} diff --git a/providers/azureadv2/scopes.go b/providers/azureadv2/scopes.go new file mode 100644 index 000000000..fc6c6f367 --- /dev/null +++ b/providers/azureadv2/scopes.go @@ -0,0 +1,717 @@ +package azureadv2 + +type ( + // ScopeType are the well known scopes which can be requested + ScopeType string +) + +// OpenID Permissions +// +// You can use these permissions to specify artifacts that you want returned in Azure AD authorization and token +// requests. They are supported differently by the Azure AD v1.0 and v2.0 endpoints. +// +// With the Azure AD (v1.0) endpoint, only the openid permission is used. You specify it in the scope parameter in an +// authorization request to return an ID token when you use the OpenID Connect protocol to sign in a user to your app. +// For more information, see Authorize access to web applications using OpenID Connect and Azure Active Directory. To +// successfully return an ID token, you must also make sure that the User.Read permission is configured when you +// register your app. +// +// With the Azure AD v2.0 endpoint, you specify the offline_access permission in the scope parameter to explicitly +// request a refresh token when using the OAuth 2.0 or OpenID Connect protocols. With OpenID Connect, you specify the +// openid permission to request an ID token. You can also specify the email permission, profile permission, or both to +// return additional claims in the ID token. You do not need to specify User.Read to return an ID token with the v2.0 +// endpoint. For more information, see OpenID Connect scopes. +const ( + // OpenIDScope shows on the work account consent page as the "Sign you in" permission, and on the personal Microsoft + // account consent page as the "View your profile and connect to apps and services using your Microsoft account" + // permission. With this permission, an app can receive a unique identifier for the user in the form of the sub + // claim. It also gives the app access to the UserInfo endpoint. The openid scope can be used at the v2.0 token + // endpoint to acquire ID tokens, which can be used to secure HTTP calls between different components of an app. + OpenIDScope ScopeType = "openid" + + // EmailScope can be used with the openid scope and any others. It gives the app access to the user's primary + // email address in the form of the email claim. The email claim is included in a token only if an email address is + // associated with the user account, which is not always the case. If it uses the email scope, your app should be + // prepared to handle a case in which the email claim does not exist in the token. + EmailScope ScopeType = "email" + + // ProfileScope can be used with the openid scope and any others. It gives the app access to a substantial + // amount of information about the user. The information it can access includes, but is not limited to, the user's + // given name, surname, preferred username, and object ID. For a complete list of the profile claims available in + // the id_tokens parameter for a specific user, see the v2.0 tokens reference: + // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-id-and-access-tokens. + ProfileScope ScopeType = "profile" + + // OfflineAccessScope gives your app access to resources on behalf of the user for an extended time. On the work + // account consent page, this scope appears as the "Access your data anytime" permission. On the personal Microsoft + // account consent page, it appears as the "Access your info anytime" permission. When a user approves the + // offline_access scope, your app can receive refresh tokens from the v2.0 token endpoint. Refresh tokens are + // long-lived. Your app can get new access tokens as older ones expire. + // + // If your app does not request the offline_access scope, it won't receive refresh tokens. This means that when you + // redeem an authorization code in the OAuth 2.0 authorization code flow, you'll receive only an access token from + // the /token endpoint. The access token is valid for a short time. The access token usually expires in one hour. + // At that point, your app needs to redirect the user back to the /authorize endpoint to get a new authorization + // code. During this redirect, depending on the type of app, the user might need to enter their credentials again + // or consent again to permissions. + OfflineAccessScope ScopeType = "offline_access" +) + +// Calendar Permissions +// +// Calendars.Read.Shared and Calendars.ReadWrite.Shared are only valid for work or school accounts. All other +// permissions are valid for both Microsoft accounts and work or school accounts. +// +// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference +const ( + // CalendarsReadScope allows the app to read events in user calendars. + CalendarsReadScope ScopeType = "Calendars.Read" + + // CalendarsReadSharedScope allows the app to read events in all calendars that the user can access, including + // delegate and shared calendars. + CalendarsReadSharedScope ScopeType = "Calendars.Read.Shared" + + // CalendarsReadWriteScope allows the app to create, read, update, and delete events in user calendars. + CalendarsReadWriteScope ScopeType = "Calendars.ReadWrite" + + // CalendarsReadWriteSharedScope allows the app to create, read, update and delete events in all calendars the user + // has permissions to access. This includes delegate and shared calendars. + CalendarsReadWriteSharedScope ScopeType = "Calendars.ReadWrite.Shared" +) + +// Contacts Permissions +// +// Only the Contacts.Read and Contacts.ReadWrite delegated permissions are valid for Microsoft accounts. +// +// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference +const ( + // ContactsReadScope allows the app to read contacts that the user has permissions to access, including the user's + // own and shared contacts. + ContactsReadScope ScopeType = "Contacts.Read" + + // ContactsReadSharedScope allows the app to read contacts that the user has permissions to access, including the + // user's own and shared contacts. + ContactsReadSharedScope ScopeType = "Contacts.Read.Shared" + + // ContactsReadWriteScope allows the app to create, read, update, and delete user contacts. + ContactsReadWriteScope ScopeType = "Contacts.ReadWrite" + + // ContactsReadWriteSharedScope allows the app to create, read, update and delete contacts that the user has + // permissions to, including the user's own and shared contacts. + ContactsReadWriteSharedScope ScopeType = "Contacts.ReadWrite.Shared" +) + +// Device Permissions +// +// The Device.Read and Device.Command delegated permissions are valid only for personal Microsoft accounts. +// +// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference +const ( + // DeviceReadScope allows the app to read a user's list of devices on behalf of the signed-in user. + DeviceReadScope ScopeType = "Device.Read" + + // DeviceCommandScope allows the app to launch another app or communicate with another app on a user's device on + // behalf of the signed-in user. + DeviceCommandScope ScopeType = "Device.Command" +) + +// Directory Permissions +// +// Directory permissions are not supported on Microsoft accounts. +// +// Directory permissions provide the highest level of privilege for accessing directory resources such as User, Group, +// and Device in an organization. +// +// They also exclusively control access to other directory resources like: organizational contacts, schema extension +// APIs, Privileged Identity Management (PIM) APIs, as well as many of the resources and APIs listed under the Azure +// Active Directory node in the v1.0 and beta API reference documentation. These include administrative units, directory +// roles, directory settings, policy, and many more. +// +// The Directory.ReadWrite.All permission grants the following privileges: +// - Full read of all directory resources (both declared properties and navigation properties) +// - Create and update users +// - Disable and enable users (but not company administrator) +// - Set user alternative security id (but not administrators) +// - Create and update groups +// - Manage group memberships +// - Update group owner +// - Manage license assignments +// - Define schema extensions on applications +// - Note: No rights to reset user passwords +// - Note: No rights to delete resources (including users or groups) +// - Note: Specifically excludes create or update for resources not listed above. This includes: application, +// oAauth2Permissiongrant, appRoleAssignment, device, servicePrincipal, organization, domains, and so on. +// +// See also https://developer.microsoft.com/en-us/graph/docs/concepts/permissions_reference +const ( + // DirectoryReadAllScope allows the app to read data in your organization's directory, such as users, groups and + // apps. + // + // Note: Users may consent to applications that require this permission if the application is registered in their + // own organization’s tenant. + // + // requires admin consent + DirectoryReadAllScope ScopeType = "Directory.Read.All" + + // DirectoryReadWriteAllScope allows the app to read and write data in your organization's directory, such as users, + // and groups. It does not allow the app to delete users or groups, or reset user passwords. + // + // requires admin consent + DirectoryReadWriteAllScope ScopeType = "Directory.ReadWrite.All" + + // DirectoryAccessAsUserAllScope allows the app to have the same access to information in the directory as the + // signed-in user. + // + // requires admin consent + DirectoryAccessAsUserAllScope ScopeType = "Directory.AccessAsUser.All" +) + +// Education Administration Permissions +const ( + // EduAdministrationReadScope allows the app to read education app settings on behalf of the user. + // + // requires admin consent + EduAdministrationReadScope ScopeType = "EduAdministration.Read" + + // EduAdministrationReadWriteScope allows the app to manage education app settings on behalf of the user. + // + // requires admin consent + EduAdministrationReadWriteScope ScopeType = "EduAdministration.ReadWrite" + + // EduAssignmentsReadBasicScope allows the app to read assignments without grades on behalf of the user + // + // requires admin consent + EduAssignmentsReadBasicScope ScopeType = "EduAssignments.ReadBasic" + + // EduAssignmentsReadWriteBasicScope allows the app to read and write assignments without grades on behalf of the + // user + EduAssignmentsReadWriteBasicScope ScopeType = "EduAssignments.ReadWriteBasic" + + // EduAssignmentsReadScope allows the app to read assignments and their grades on behalf of the user + // + // requires admin consent + EduAssignmentsReadScope ScopeType = "EduAssignments.Read" + + // EduAssignmentsReadWriteScope allows the app to read and write assignments and their grades on behalf of the user + // + // requires admin consent + EduAssignmentsReadWriteScope ScopeType = "EduAssignments.ReadWrite" + + // EduRosteringReadBasicScope allows the app to read a limited subset of the data from the structure of schools and + // classes in an organization's roster and education-specific information about users to be read on behalf of the + // user. + // + // requires admin consent + EduRosteringReadBasicScope ScopeType = "EduRostering.ReadBasic" +) + +// Files Permissions +// +// The Files.Read, Files.ReadWrite, Files.Read.All, and Files.ReadWrite.All delegated permissions are valid on both +// personal Microsoft accounts and work or school accounts. Note that for personal accounts, Files.Read and +// Files.ReadWrite also grant access to files shared with the signed-in user. +// +// The Files.Read.Selected and Files.ReadWrite.Selected delegated permissions are only valid on work or school accounts +// and are only exposed for working with Office 365 file handlers (v1.0) +// https://msdn.microsoft.com/office/office365/howto/using-cross-suite-apps. They should not be used for directly +// calling Microsoft Graph APIs. +// +// The Files.ReadWrite.AppFolder delegated permission is only valid for personal accounts and is used for accessing the +// App Root special folder https://dev.onedrive.com/misc/appfolder.htm with the OneDrive Get special folder +// https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/drive_get_specialfolder Microsoft Graph API. +const ( + // FilesReadScope allows the app to read the signed-in user's files. + FilesReadScope ScopeType = "Files.Read" + + // FilesReadAllScope allows the app to read all files the signed-in user can access. + FilesReadAllScope ScopeType = "Files.Read.All" + + // FilesReadWrite allows the app to read, create, update, and delete the signed-in user's files. + FilesReadWriteScope ScopeType = "Files.ReadWrite" + + // FilesReadWriteAllScope allows the app to read, create, update, and delete all files the signed-in user can access. + FilesReadWriteAllScope ScopeType = "Files.ReadWrite.All" + + // FilesReadWriteAppFolderScope allows the app to read, create, update, and delete files in the application's folder. + FilesReadWriteAppFolderScope ScopeType = "Files.ReadWrite.AppFolder" + + // FilesReadSelectedScope allows the app to read files that the user selects. The app has access for several hours + // after the user selects a file. + // + // preview + FilesReadSelectedScope ScopeType = "Files.Read.Selected" + + // FilesReadWriteSelectedScope allows the app to read and write files that the user selects. The app has access for + // several hours after the user selects a file + // + // preview + FilesReadWriteSelectedScope ScopeType = "Files.ReadWrite.Selected" +) + +// Group Permissions +// +// Group functionality is not supported on personal Microsoft accounts. +// +// For Office 365 groups, Group permissions grant the app access to the contents of the group; for example, +// conversations, files, notes, and so on. +// +// For application permissions, there are some limitations for the APIs that are supported. For more information, see +// known issues. +// +// In some cases, an app may need Directory permissions to read some group properties like member and memberOf. For +// example, if a group has a one or more servicePrincipals as members, the app will need effective permissions to read +// service principals through being granted one of the Directory.* permissions, otherwise Microsoft Graph will return an +// error. (In the case of delegated permissions, the signed-in user will also need sufficient privileges in the +// organization to read service principals.) The same guidance applies for the memberOf property, which can return +// administrativeUnits. +// +// Group permissions are also used to control access to Microsoft Planner resources and APIs. Only delegated permissions +// are supported for Microsoft Planner APIs; application permissions are not supported. Personal Microsoft accounts are +// not supported. +const ( + // GroupReadAllScope allows the app to list groups, and to read their properties and all group memberships on behalf + // of the signed-in user. Also allows the app to read calendar, conversations, files, and other group content for + // all groups the signed-in user can access. + GroupReadAllScope ScopeType = "Group.Read.All" + + // GroupReadWriteAllScope allows the app to create groups and read all group properties and memberships on behalf of + // the signed-in user. Additionally allows group owners to manage their groups and allows group members to update + // group content. + GroupReadWriteAllScope ScopeType = "Group.ReadWrite.All" +) + +// Identity Risk Event Permissions +// +// IdentityRiskEvent.Read.All is valid only for work or school accounts. For an app with delegated permissions to read +// identity risk information, the signed-in user must be a member of one of the following administrator roles: Global +// Administrator, Security Administrator, or Security Reader. For more information about administrator roles, see +// Assigning administrator roles in Azure Active Directory. +const ( + // IdentityRiskEventReadAllScope allows the app to read identity risk event information for all users in your + // organization on behalf of the signed-in user. + // + // requires admin consent + IdentityRiskEventReadAllScope ScopeType = "IdentityRiskEvent.Read.All" +) + +// Identity Provider Permissions +// +// IdentityProvider.Read.All and IdentityProvider.ReadWrite.All are valid only for work or school accounts. For an app +// to read or write identity providers with delegated permissions, the signed-in user must be assigned the Global +// Administrator role. For more information about administrator roles, see Assigning administrator roles in Azure Active +// Directory. +const ( + // IdentityProviderReadAllScope allows the app to read identity providers configured in your Azure AD or Azure AD + // B2C tenant on behalf of the signed-in user. + // + // requires admin consent + IdentityProviderReadAllScope ScopeType = "IdentityProvider.Read.All" + + // IdentityProviderReadWriteAllScope allows the app to read or write identity providers configured in your Azure AD + // or Azure AD B2C tenant on behalf of the signed-in user. + // + // requires admin consent + IdentityProviderReadWriteAllScope ScopeType = "IdentityProvider.ReadWrite.All" +) + +// Device Management Permissions +// +// Using the Microsoft Graph APIs to configure Intune controls and policies still requires that the Intune service is +// correctly licensed by the customer. +// +// These permissions are only valid for work or school accounts. +const ( + // DeviceManagementAppsReadAllScope allows the app to read the properties, group assignments and status of apps, app + // configurations and app protection policies managed by Microsoft Intune. + // + // requires admin consent + DeviceManagementAppsReadAllScope ScopeType = "DeviceManagementApps.Read.All" + + // DeviceManagementAppsReadWriteAllScope allows the app to read and write the properties, group assignments and + // status of apps, app configurations and app protection policies managed by Microsoft Intune. + // + // requires admin consent + DeviceManagementAppsReadWriteAllScope ScopeType = "DeviceManagementApps.ReadWrite.All" + + // DeviceManagementConfigurationReadAllScope allows the app to read properties of Microsoft Intune-managed device + // configuration and device compliance policies and their assignment to groups. + // + // requires admin consent + DeviceManagementConfigurationReadAllScope ScopeType = "DeviceManagementConfiguration.Read.All" + + // DeviceManagementConfigurationReadWriteAllScope allows the app to read and write properties of Microsoft + // Intune-managed device configuration and device compliance policies and their assignment to groups. + // + // requires admin consent + DeviceManagementConfigurationReadWriteAllScope ScopeType = "DeviceManagementConfiguration.ReadWrite.All" + + // DeviceManagementManagedDevicesPrivilegedOperationsAllScope allows the app to perform remote high impact actions + // such as wiping the device or resetting the passcode on devices managed by Microsoft Intune. + // + // requires admin consent + DeviceManagementManagedDevicesPrivilegedOperationsAllScope ScopeType = "DeviceManagementManagedDevices.PrivilegedOperations.All" + + // DeviceManagementManagedDevicesReadAllScope allows the app to read the properties of devices managed by Microsoft + // Intune. + // + // requires admin consent + DeviceManagementManagedDevicesReadAllScope ScopeType = "DeviceManagementManagedDevices.Read.All" + + // DeviceManagementManagedDevicesReadWriteAllScope allows the app to read and write the properties of devices + // managed by Microsoft Intune. Does not allow high impact operations such as remote wipe and password reset on the + // device’s owner. + // + // requires admin consent + DeviceManagementManagedDevicesReadWriteAllScope ScopeType = "DeviceManagementManagedDevices.ReadWrite.All" + + // DeviceManagementRBACReadAllScope allows the app to read the properties relating to the Microsoft Intune + // Role-Based Access Control (RBAC) settings. + // + // requires admin consent + DeviceManagementRBACReadAllScope ScopeType = "DeviceManagementRBAC.Read.All" + + // DeviceManagementRBACReadWriteAllScope allows the app to read and write the properties relating to the Microsoft + // Intune Role-Based Access Control (RBAC) settings. + // + // requires admin consent + DeviceManagementRBACReadWriteAllScope ScopeType = "DeviceManagementRBAC.ReadWrite.All" + + // DeviceManagementServiceConfigReadAllScope allows the app to read Intune service properties including device + // enrollment and third party service connection configuration. + // + // requires admin consent + DeviceManagementServiceConfigReadAllScope ScopeType = "DeviceManagementServiceConfig.Read.All" + + // DeviceManagementServiceConfigReadWriteAllScope allows the app to read and write Microsoft Intune service + // properties including device enrollment and third party service connection configuration. + // + // requires admin consent + DeviceManagementServiceConfigReadWriteAllScope ScopeType = "DeviceManagementServiceConfig.ReadWrite.All" +) + +// Mail Permissions +// +// Mail.Read.Shared, Mail.ReadWrite.Shared, and Mail.Send.Shared are only valid for work or school accounts. All other +// permissions are valid for both Microsoft accounts and work or school accounts. +// +// With the Mail.Send or Mail.Send.Shared permission, an app can send mail and save a copy to the user's Sent Items +// folder, even if the app does not use a corresponding Mail.ReadWrite or Mail.ReadWrite.Shared permission. +const ( + // MailReadScope allows the app to read email in user mailboxes. + MailReadScope ScopeType = "Mail.Read" + + // MailReadWriteScope allows the app to create, read, update, and delete email in user mailboxes. Does not include + // permission to send mail. + MailReadWriteScope ScopeType = "Mail.ReadWrite" + + // MailReadSharedScope allows the app to read mail that the user can access, including the user's own and shared + // mail. + MailReadSharedScope ScopeType = "Mail.Read.Shared" + + // MailReadWriteSharedScope allows the app to create, read, update, and delete mail that the user has permission to + // access, including the user's own and shared mail. Does not include permission to send mail. + MailReadWriteSharedScope ScopeType = "Mail.ReadWrite.Shared" + + // MailSend allowsScope the app to send mail as users in the organization. + MailSendScope ScopeType = "Mail.Send" + + // MailSendSharedScope allows the app to send mail as the signed-in user, including sending on-behalf of others. + MailSendSharedScope ScopeType = "Mail.Send.Shared" + + // MailboxSettingsReadScope allows the app to the read user's mailbox settings. Does not include permission to send + // mail. + MailboxSettingsReadScope ScopeType = "Mailbox.Settings.Read" + + // MailboxSettingsReadWriteScope allows the app to create, read, update, and delete user's mailbox settings. Does + // not include permission to directly send mail, but allows the app to create rules that can forward or redirect + // messages. + MailboxSettingsReadWriteScope ScopeType = "MailboxSettings.ReadWrite" +) + +// Member Permissions +// +// Member.Read.Hidden is valid only on work or school accounts. +// +// Membership in some Office 365 groups can be hidden. This means that only the members of the group can view its +// members. This feature can be used to help comply with regulations that require an organization to hide group +// membership from outsiders (for example, an Office 365 group that represents students enrolled in a class). +const ( + // MemberReadHiddenScope allows the app to read the memberships of hidden groups and administrative units on behalf + // of the signed-in user, for those hidden groups and administrative units that the signed-in user has access to. + // + // requires admin consent + MemberReadHiddenScope ScopeType = "Member.Read.Hidden" +) + +// Notes Permissions +// +// Notes.Read.All and Notes.ReadWrite.All are only valid for work or school accounts. All other permissions are valid +// for both Microsoft accounts and work or school accounts. +// +// With the Notes.Create permission, an app can view the OneNote notebook hierarchy of the signed-in user and create +// OneNote content (notebooks, section groups, sections, pages, etc.). +// +// Notes.ReadWrite and Notes.ReadWrite.All also allow the app to modify the permissions on the OneNote content that can +// be accessed by the signed-in user. +// +// For work or school accounts, Notes.Read.All and Notes.ReadWrite.All allow the app to access other users' OneNote +// content that the signed-in user has permission to within the organization. +const ( + // NotesReadScope allows the app to read OneNote notebooks on behalf of the signed-in user. + NotesReadScope ScopeType = "Notes.Read" + + // NotesCreateScope allows the app to read the titles of OneNote notebooks and sections and to create new pages, + // notebooks, and sections on behalf of the signed-in user. + NotesCreateScope ScopeType = "Notes.Create" + + // NotesReadWriteScope allows the app to read, share, and modify OneNote notebooks on behalf of the signed-in user. + NotesReadWriteScope ScopeType = "Notes.ReadWrite" + + // NotesReadAllScope allows the app to read OneNote notebooks that the signed-in user has access to in the + // organization. + NotesReadAllScope ScopeType = "Notes.Read.All" + + // NotesReadWriteAllScope allows the app to read, share, and modify OneNote notebooks that the signed-in user has + // access to in the organization. + NotesReadWriteAllScope ScopeType = "Notes.ReadWrite.All" +) + +// People Permissions +// +// The People.Read.All permission is only valid for work and school accounts. +const ( + // PeopleReadScope allows the app to read a scored list of people relevant to the signed-in user. The list can + // include local contacts, contacts from social networking or your organization's directory, and people from recent + // communications (such as email and Skype). + PeopleReadScope ScopeType = "People.Read" + + // PeopleReadAllScope allows the app to read a scored list of people relevant to the signed-in user or other users + // in the signed-in user's organization. The list can include local contacts, contacts from social networking or + // your organization's directory, and people from recent communications (such as email and Skype). Also allows the + // app to search the entire directory of the signed-in user's organization. + // + // requires admin consent + PeopleReadAllScope ScopeType = "People.Read.All" +) + +// Report Permissions +// +// Reports permissions are only valid for work or school accounts. +const ( + // ReportsReadAllScope allows an app to read all service usage reports without a signed-in user. Services that + // provide usage reports include Office 365 and Azure Active Directory. + // + // requires admin consent + ReportsReadAllScope ScopeType = "Reports.Read.All" +) + +// Security Permissions +// +// Security permissions are valid only on work or school accounts. +const ( + // SecurityEventsReadAllScope allows the app to read your organization’s security events on behalf of the signed-in + // user. + // requires admin consent + SecurityEventsReadAllScope ScopeType = "SecurityEvents.Read.All" + + // SecurityEventsReadWriteAllScope allows the app to read your organization’s security events on behalf of the + // signed-in user. Also allows the app to update editable properties in security events on behalf of the signed-in + // user. + // + // requires admin consent + SecurityEventsReadWriteAllScope ScopeType = "SecurityEvents.ReadWrite.All" +) + +// Sites Permissions +// +// Sites permissions are valid only on work or school accounts. +const ( + // SitesReadAllScope allows the app to read documents and list items in all site collections on behalf of the + // signed-in user. + SitesReadAllScope ScopeType = "Sites.Read.All" + + // SitesReadWriteAllScope allows the app to edit or delete documents and list items in all site collections on + // behalf of the signed-in user. + SitesReadWriteAllScope ScopeType = "Sites.ReadWrite.All" + + // SitesManageAllScope allows the app to manage and create lists, documents, and list items in all site collections + // on behalf of the signed-in user. + SitesManageAllScope ScopeType = "Sites.Manage.All" + + // SitesFullControlAllScope allows the app to have full control to SharePoint sites in all site collections on + // behalf of the signed-in user. + // + // requires admin consent + SitesFullControlAllScope ScopeType = "Sites.FullControl.All" +) + +// Tasks Permissions +// +// Tasks permissions are used to control access for Outlook tasks. Access for Microsoft Planner tasks is controlled by +// Group permissions. +// +// Shared permissions are currently only supported for work or school accounts. Even with Shared permissions, reads and +// writes may fail if the user who owns the shared content has not granted the accessing user permissions to modify +// content within the folder. +const ( + // TasksReadScope allows the app to read user tasks. + TasksReadScope ScopeType = "Tasks.Read" + + // TasksReadSharedScope allows the app to read tasks a user has permissions to access, including their own and + // shared tasks. + TasksReadSharedScope ScopeType = "Tasks.Read.Shared" + + // TasksReadWriteScope allows the app to create, read, update and delete tasks and containers (and tasks in them) + // that are assigned to or shared with the signed-in user. + TasksReadWriteScope ScopeType = "Tasks.ReadWrite" + + // TasksReadWriteSharedScope allows the app to create, read, update, and delete tasks a user has permissions to, + // including their own and shared tasks. + TasksReadWriteSharedScope ScopeType = "Tasks.ReadWrite.Shared" +) + +// Terms of Use Permissions +// +// All the permissions above are valid only for work or school accounts. +// +// For an app to read or write all agreements or agreement acceptances with delegated permissions, the signed-in user +// must be assigned the Global Administrator, Conditional Access Administrator or Security Administrator role. For more +// information about administrator roles, see Assigning administrator roles in Azure Active Directory +// https://docs.microsoft.com/azure/active-directory/active-directory-assign-admin-roles. +const ( + // AgreementReadAllScope allows the app to read terms of use agreements on behalf of the signed-in user. + // + // requires admin consent + AgreementReadAllScope ScopeType = "Agreement.Read.All" + + // AgreementReadWriteAllScope allows the app to read and write terms of use agreements on behalf of the signed-in + // user. + // + // requires admin consent + AgreementReadWriteAllScope ScopeType = "Agreement.ReadWrite.All" + + // AgreementAcceptanceReadScope allows the app to read terms of use acceptance statuses on behalf of the signed-in + // user. + // + // requires admin consent + AgreementAcceptanceReadScope ScopeType = "AgreementAcceptance.Read" + + // AgreementAcceptanceReadAllScope allows the app to read terms of use acceptance statuses on behalf of the + // signed-in user. + // + // requires admin consent + AgreementAcceptanceReadAllScope ScopeType = "AgreementAcceptance.Read.All" +) + +// User Permissions +// +// The only permissions valid for Microsoft accounts are User.Read and User.ReadWrite. For work or school accounts, all +// permissions are valid. +// +// With the User.Read permission, an app can also read the basic company information of the signed-in user for a work or +// school account through the organization resource. The following properties are available: id, displayName, and +// verifiedDomains. +// +// For work or school accounts, the full profile includes all of the declared properties of the User resource. On reads, +// only a limited number of properties are returned by default. To read properties that are not in the default set, use +// $select. The default properties are: +// +// displayName +// givenName +// jobTitle +// mail +// mobilePhone +// officeLocation +// preferredLanguage +// surname +// userPrincipalName +// +// User.ReadWrite and User.Readwrite.All delegated permissions allow the app to update the following profile properties +// for work or school accounts: +// +// aboutMe +// birthday +// hireDate +// interests +// mobilePhone +// mySite +// pastProjects +// photo +// preferredName +// responsibilities +// schools +// skills +// +// With the User.ReadWrite.All application permission, the app can update all of the declared properties of work or +// school accounts except for password. +// +// To read or write direct reports (directReports) or the manager (manager) of a work or school account, the app must +// have either User.Read.All (read only) or User.ReadWrite.All. +// +// The User.ReadBasic.All permission constrains app access to a limited set of properties known as the basic profile. +// This is because the full profile might contain sensitive directory information. The basic profile includes only the +// following properties: +// +// displayName +// givenName +// mail +// photo +// surname +// userPrincipalName +// +// To read the group memberships of a user (memberOf), the app must have either Group.Read.All or Group.ReadWrite.All. +// However, if the user also has membership in a directoryRole or an administrativeUnit, the app will need effective +// permissions to read those resources too, or Microsoft Graph will return an error. This means the app will also need +// Directory permissions, and, for delegated permissions, the signed-in user will also need sufficient privileges in the +// organization to access directory roles and administrative units. +const ( + // UserReadScope allows users to sign-in to the app, and allows the app to read the profile of signed-in users. It + // also allows the app to read basic company information of signed-in users. + UserReadScope ScopeType = "User.Read" + + // UserReadWriteScope allows the app to read the signed-in user's full profile. It also allows the app to update the + // signed-in user's profile information on their behalf. + UserReadWriteScope ScopeType = "User.ReadWrite" + + // UserReadBasicAllScope allows the app to read a basic set of profile properties of other users in your + // organization on behalf of the signed-in user. This includes display name, first and last name, email address, + // open extensions and photo. Also allows the app to read the full profile of the signed-in user. + UserReadBasicAllScope ScopeType = "User.ReadBasic.All" + + // UserReadAllScope allows the app to read the full set of profile properties, reports, and managers of other users + // in your organization, on behalf of the signed-in user. + // + // requires admin consent + UserReadAllScope ScopeType = "User.Read.All" + + // UserReadWriteAllScope allows the app to read and write the full set of profile properties, reports, and managers + // of other users in your organization, on behalf of the signed-in user. Also allows the app to create and delete + // users as well as reset user passwords on behalf of the signed-in user. + // + // requires admin consent + UserReadWriteAllScope ScopeType = "User.ReadWrite.All" + + // UserInviteAllScope allows the app to invite guest users to your organization, on behalf of the signed-in user. + // + // requires admin consent + UserInviteAllScope ScopeType = "User.Invite.All" + + // UserExportAllScope allows the app to export an organizational user's data, when performed by a Company + // Administrator. + // + // requires admin consent + UserExportAllScope ScopeType = "User.Export.All" +) + +// User Activity Permissions +// +// UserActivity.ReadWrite.CreatedByApp is valid for both Microsoft accounts and work or school accounts. +// +// The CreatedByApp constraint associated with this permission indicates the service will apply implicit filtering to +// results based on the identity of the calling app, either the MSA app id or a set of app ids configured for a +// cross-platform application identity. +const ( + // UserActivityReadWriteCreatedByAppScope allows the app to read and report the signed-in user's activity in the + // app. + UserActivityReadWriteCreatedByAppScope ScopeType = "UserActivity.ReadWrite.CreatedByApp" +) diff --git a/providers/azureadv2/session.go b/providers/azureadv2/session.go new file mode 100644 index 000000000..f2f0cd07c --- /dev/null +++ b/providers/azureadv2/session.go @@ -0,0 +1,63 @@ +package azureadv2 + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session is the implementation of `goth.Session` +type Session struct { + AuthURL string `json:"au"` + AccessToken string `json:"at"` + RefreshToken string `json:"rt"` + ExpiresAt time.Time `json:"exp"` +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` func +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + + return s.AuthURL, nil +} + +// Authorize the session with AzureAD and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + session := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(session) + return session, err +} diff --git a/providers/azureadv2/session_test.go b/providers/azureadv2/session_test.go new file mode 100644 index 000000000..7edfde4e6 --- /dev/null +++ b/providers/azureadv2/session_test.go @@ -0,0 +1,48 @@ +package azureadv2_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/azureadv2" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azureadv2.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azureadv2.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azureadv2.Session{} + + data := s.Marshal() + a.Equal(`{"au":"","at":"","rt":"","exp":"0001-01-01T00:00:00Z"}`, data) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &azureadv2.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/battlenet/battlenet.go b/providers/battlenet/battlenet.go new file mode 100644 index 000000000..47abdaca8 --- /dev/null +++ b/providers/battlenet/battlenet.go @@ -0,0 +1,153 @@ +// Package battlenet implements the OAuth2 protocol for authenticating users through Battle.net. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package battlenet + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://us.battle.net/oauth/authorize" + tokenURL string = "https://us.battle.net/oauth/token" + endpointUser string = "https://us.battle.net/oauth/userinfo" +) + +// Provider is the implementation of `goth.Provider` for accessing Battle.net. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Battle.net provider and sets up important connection details. +// You should always call `battlenet.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "battlenet", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the battlenet package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Battle.net for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Battle.net and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // Get the userID, battlenet needs userID in order to get user profile info + c := p.Client() + req, err := http.NewRequest("GET", endpointUser, nil) + if err != nil { + return user, err + } + + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + + response, err := c.Do(req) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + u := struct { + ID int64 `json:"id"` + Battletag string `json:"battletag"` + }{} + + if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { + return user, err + } + + user.NickName = u.Battletag + user.UserID = fmt.Sprintf("%d", u.ID) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} diff --git a/providers/battlenet/battlenet_test.go b/providers/battlenet/battlenet_test.go new file mode 100644 index 000000000..618a9e8dc --- /dev/null +++ b/providers/battlenet/battlenet_test.go @@ -0,0 +1,53 @@ +package battlenet_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/battlenet" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("BATTLENET_KEY")) + a.Equal(p.Secret, os.Getenv("BATTLENET_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*battlenet.Session) + a.NoError(err) + a.Contains(s.AuthURL, "us.battle.net/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://us.battle.net/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*battlenet.Session) + a.Equal(s.AuthURL, "https://us.battle.net/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *battlenet.Provider { + return battlenet.New(os.Getenv("BATTLENET_KEY"), os.Getenv("BATTLENET_SECRET"), "/foo") +} diff --git a/providers/battlenet/session.go b/providers/battlenet/session.go new file mode 100644 index 000000000..98fff650f --- /dev/null +++ b/providers/battlenet/session.go @@ -0,0 +1,63 @@ +package battlenet + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Battle.net. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Battle.net provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Battle.net and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/battlenet/session_test.go b/providers/battlenet/session_test.go new file mode 100644 index 000000000..fd39dfcaf --- /dev/null +++ b/providers/battlenet/session_test.go @@ -0,0 +1,48 @@ +package battlenet_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/battlenet" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &battlenet.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &battlenet.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &battlenet.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &battlenet.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/bitbucket/bitbucket.go b/providers/bitbucket/bitbucket.go new file mode 100644 index 000000000..7c27a913d --- /dev/null +++ b/providers/bitbucket/bitbucket.go @@ -0,0 +1,241 @@ +// Package bitbucket implements the OAuth2 protocol for authenticating users through Bitbucket. +package bitbucket + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://bitbucket.org/site/oauth2/authorize" + tokenURL string = "https://bitbucket.org/site/oauth2/access_token" + endpointProfile string = "https://api.bitbucket.org/2.0/user" + endpointEmail string = "https://api.bitbucket.org/2.0/user/emails" +) + +type EmailAddress struct { + Type string `json:"type"` + Links Links `json:"links"` + Email string `json:"email"` + IsPrimary bool `json:"is_primary"` + IsConfirmed bool `json:"is_confirmed"` +} + +type Links struct { + Self Self `json:"self"` +} + +type Self struct { + Href string `json:"href"` +} + +type MailList struct { + Values []EmailAddress `json:"values"` + Pagelen int `json:"pagelen"` + Size int `json:"size"` + Page int `json:"page"` +} + +// New creates a new Bitbucket provider, and sets up important connection details. +// You should always call `bitbucket.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "bitbucket", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Bitbucket. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the bitbucket package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Bitbucket for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Bitbucket and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + if err := p.getUserInfo(&user, sess); err != nil { + return user, err + } + + if err := p.getEmail(&user, sess); err != nil { + return user, err + } + + return user, nil +} + +func (p *Provider) getUserInfo(user *goth.User, sess *Session) error { + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return err + } + authenticateRequest(req, sess) + response, err := p.Client().Do(req) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return err + } + + u := struct { + ID string `json:"uuid"` + Links struct { + Avatar struct { + URL string `json:"href"` + } `json:"avatar"` + } `json:"links"` + Username string `json:"username"` + Name string `json:"display_name"` + Location string `json:"location"` + }{} + + if err := json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { + return err + } + + user.Name = u.Name + user.NickName = u.Username + user.AvatarURL = u.Links.Avatar.URL + user.UserID = u.ID + user.Location = u.Location + + return nil +} + +func (p *Provider) getEmail(user *goth.User, sess *Session) error { + req, err := http.NewRequest("GET", endpointEmail, nil) + if err != nil { + return err + } + authenticateRequest(req, sess) + response, err := p.Client().Do(req) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("%s responded with a %d trying to fetch email addresses", p.providerName, response.StatusCode) + } + + var mailList MailList + err = json.NewDecoder(response.Body).Decode(&mailList) + if err != nil { + return err + } + + for _, emailAddress := range mailList.Values { + if emailAddress.IsPrimary && emailAddress.IsConfirmed { + user.Email = emailAddress.Email + return nil + } + } + + return fmt.Errorf("%s did not return any confirmed, primary email address", p.providerName) +} + +func authenticateRequest(req *http.Request, sess *Session) { + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/bitbucket/bitbucket_test.go b/providers/bitbucket/bitbucket_test.go new file mode 100644 index 000000000..22c8db3d3 --- /dev/null +++ b/providers/bitbucket/bitbucket_test.go @@ -0,0 +1,59 @@ +package bitbucket_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/bitbucket" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := bitbucketProvider() + a.Equal(provider.ClientKey, os.Getenv("BITBUCKET_KEY")) + a.Equal(provider.Secret, os.Getenv("BITBUCKET_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), bitbucketProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := bitbucketProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*bitbucket.Session) + a.NoError(err) + a.Contains(s.AuthURL, "bitbucket.org/site/oauth2/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("BITBUCKET_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=user") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := bitbucketProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://bitbucket.org/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*bitbucket.Session) + a.Equal(session.AuthURL, "http://bitbucket.org/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func bitbucketProvider() *bitbucket.Provider { + return bitbucket.New(os.Getenv("BITBUCKET_KEY"), os.Getenv("BITBUCKET_SECRET"), "/foo", "user") +} diff --git a/providers/bitbucket/session.go b/providers/bitbucket/session.go new file mode 100644 index 000000000..a65242151 --- /dev/null +++ b/providers/bitbucket/session.go @@ -0,0 +1,61 @@ +package bitbucket + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Bitbucket. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Bitbucket provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Bitbucket and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} + +func (s Session) String() string { + return s.Marshal() +} diff --git a/providers/bitbucket/session_test.go b/providers/bitbucket/session_test.go new file mode 100644 index 000000000..ac181a775 --- /dev/null +++ b/providers/bitbucket/session_test.go @@ -0,0 +1,48 @@ +package bitbucket_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/bitbucket" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &bitbucket.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &bitbucket.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &bitbucket.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &bitbucket.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/bitly/bitly.go b/providers/bitly/bitly.go new file mode 100644 index 000000000..fc1b122c0 --- /dev/null +++ b/providers/bitly/bitly.go @@ -0,0 +1,170 @@ +// Package bitly implements the OAuth2 protocol for authenticating users through Bitly. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package bitly + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authEndpoint string = "https://bitly.com/oauth/authorize" + tokenEndpoint string = "https://api-ssl.bitly.com/oauth/access_token" + profileEndpoint string = "https://api-ssl.bitly.com/v4/user" +) + +// New creates a new Bitly provider and sets up important connection details. +// You should always call `bitly.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + } + p.newConfig(scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Bitly. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Ensure `bitly.Provider` implements `goth.Provider`. +var _ goth.Provider = &Provider{} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type). +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the bitly package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Bitly for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Bitly and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + u := goth.User{ + Provider: p.Name(), + AccessToken: s.AccessToken, + } + + if u.AccessToken == "" { + return u, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", profileEndpoint, nil) + if err != nil { + return u, err + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", u.AccessToken)) + + resp, err := p.Client().Do(req) + if err != nil { + return u, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + defer resp.Body.Close() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return u, err + } + + if err := json.NewDecoder(bytes.NewReader(buf)).Decode(&u.RawData); err != nil { + return u, err + } + + return u, userFromReader(bytes.NewReader(buf), &u) +} + +// RefreshToken refresh token is not provided by bitly. +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by bitly") +} + +// RefreshTokenAvailable refresh token is not provided by bitly. +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +func (p *Provider) newConfig(scopes []string) { + conf := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authEndpoint, + TokenURL: tokenEndpoint, + }, + Scopes: make([]string, 0), + } + + conf.Scopes = append(conf.Scopes, scopes...) + + p.config = conf +} + +func userFromReader(reader io.Reader, user *goth.User) (err error) { + u := struct { + Login string `json:"login"` + Name string `json:"name"` + Emails []struct { + Email string `json:"email"` + IsPrimary bool `json:"is_primary"` + IsVerified bool `json:"is_verified"` + } `json:"emails"` + }{} + if err := json.NewDecoder(reader).Decode(&u); err != nil { + return err + } + + user.Name = u.Name + user.NickName = u.Login + user.Email, err = getEmail(u.Emails) + return err +} + +func getEmail(emails []struct { + Email string `json:"email"` + IsPrimary bool `json:"is_primary"` + IsVerified bool `json:"is_verified"` +}) (string, error) { + for _, email := range emails { + if email.IsPrimary && email.IsVerified { + return email.Email, nil + } + } + + return "", fmt.Errorf("The user does not have a verified, primary email address on Bitly") +} diff --git a/providers/bitly/bitly_test.go b/providers/bitly/bitly_test.go new file mode 100644 index 000000000..d48078a80 --- /dev/null +++ b/providers/bitly/bitly_test.go @@ -0,0 +1,52 @@ +package bitly_test + +import ( + "fmt" + "net/url" + "testing" + + "github.com/markbates/goth/providers/bitly" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := bitlyProvider() + a.Equal(p.ClientKey, "bitly_client_id") + a.Equal(p.Secret, "bitly_client_secret") + a.Equal(p.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := bitlyProvider() + s, err := p.BeginAuth("state") + s1 := s.(*bitly.Session) + + a.NoError(err) + a.Contains(s1.AuthURL, "https://bitly.com/oauth/authorize") + a.Contains(s1.AuthURL, fmt.Sprintf("client_id=%s", p.ClientKey)) + a.Contains(s1.AuthURL, "state=state") + a.Contains(s1.AuthURL, fmt.Sprintf("redirect_uri=%s", url.QueryEscape(p.CallbackURL))) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := bitlyProvider() + s, err := p.UnmarshalSession(`{"AuthURL":"https://bitly.com/oauth/authorize","AccessToken":"access_token"}`) + s1 := s.(*bitly.Session) + + a.NoError(err) + a.Equal(s1.AuthURL, "https://bitly.com/oauth/authorize") + a.Equal(s1.AccessToken, "access_token") +} + +func bitlyProvider() *bitly.Provider { + return bitly.New("bitly_client_id", "bitly_client_secret", "/foo") +} diff --git a/providers/bitly/session.go b/providers/bitly/session.go new file mode 100644 index 000000000..dbe876af7 --- /dev/null +++ b/providers/bitly/session.go @@ -0,0 +1,59 @@ +package bitly + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Bitly. +type Session struct { + AuthURL string + AccessToken string +} + +// Ensure `bitly.Session` implements `goth.Session`. +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Bitly provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Bitly and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string. +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/bitly/session_test.go b/providers/bitly/session_test.go new file mode 100644 index 000000000..c734f0ccf --- /dev/null +++ b/providers/bitly/session_test.go @@ -0,0 +1,33 @@ +package bitly_test + +import ( + "testing" + + "github.com/markbates/goth/providers/bitly" + "github.com/stretchr/testify/assert" +) + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + + s := &bitly.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/bar" + url, _ := s.GetAuthURL() + a.Equal(url, "/bar") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + s := &bitly.Session{ + AuthURL: "https://bitly.com/oauth/authorize", + AccessToken: "access_token", + } + a.Equal(s.Marshal(), `{"AuthURL":"https://bitly.com/oauth/authorize","AccessToken":"access_token"}`) +} diff --git a/providers/box/box.go b/providers/box/box.go new file mode 100644 index 000000000..92b8b730b --- /dev/null +++ b/providers/box/box.go @@ -0,0 +1,158 @@ +// Package box implements the OAuth2 protocol for authenticating users through box. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package box + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://app.box.com/api/oauth2/authorize" + tokenURL string = "https://app.box.com/api/oauth2/token" + endpointProfile string = "https://api.box.com/2.0/users/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Box. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + config *oauth2.Config + HTTPClient *http.Client + providerName string +} + +// New creates a new Box provider and sets up important connection details. +// You should always call `box.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "box", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the box package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Box for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Box and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Location string `json:"address"` + Email string `json:"login"` + AvatarURL string `json:"avatar_url"` + ID string `json:"id"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.NickName = u.Name + user.UserID = u.ID + user.Location = u.Location + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/box/box_test.go b/providers/box/box_test.go new file mode 100644 index 000000000..19bc14ca4 --- /dev/null +++ b/providers/box/box_test.go @@ -0,0 +1,53 @@ +package box_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/box" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("BOX_KEY")) + a.Equal(p.Secret, os.Getenv("BOX_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*box.Session) + a.NoError(err) + a.Contains(s.AuthURL, "app.box.com/api/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://app.box.com/api/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*box.Session) + a.Equal(s.AuthURL, "https://app.box.com/api/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *box.Provider { + return box.New(os.Getenv("BOX_KEY"), os.Getenv("BOX_SECRET"), "/foo") +} diff --git a/providers/box/session.go b/providers/box/session.go new file mode 100644 index 000000000..69925ea5c --- /dev/null +++ b/providers/box/session.go @@ -0,0 +1,63 @@ +package box + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Box. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Box provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Box and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/box/session_test.go b/providers/box/session_test.go new file mode 100644 index 000000000..0f681d5c9 --- /dev/null +++ b/providers/box/session_test.go @@ -0,0 +1,48 @@ +package box_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/box" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &box.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &box.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &box.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &box.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/classlink/provider.go b/providers/classlink/provider.go new file mode 100644 index 000000000..1dc683689 --- /dev/null +++ b/providers/classlink/provider.go @@ -0,0 +1,156 @@ +package classlink + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const infoURL = "https://nodeapi.classlink.com/v2/my/info" + +// Provider is an implementation of +type Provider struct { + ClientKey string + ClientSecret string + CallbackURL string + HTTPClient *http.Client + providerName string + config *oauth2.Config +} + +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + prov := &Provider{ + ClientKey: clientKey, + ClientSecret: secret, + CallbackURL: callbackURL, + providerName: "classlink", + } + prov.config = newConfig(prov, scopes) + return prov +} + +func (p Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +func (p Provider) Name() string { + return p.providerName +} + +func (p Provider) SetName(name string) { + p.providerName = name +} + +func (p Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + return &Session{ + AuthURL: url, + }, nil +} + +func (p Provider) UnmarshalSession(s string) (goth.Session, error) { + var sess Session + err := json.Unmarshal([]byte(s), &sess) + + if err != nil { + return nil, err + } + + return &sess, nil +} + +// classLinkUser contains all relevant fields from the ClassLink response +// to +type classLinkUser struct { + UserID int `json:"UserId"` + Email string `json:"Email"` + DisplayName string `json:"DisplayName"` + FirstName string `json:"FirstName"` + LastName string `json:"LastName"` +} + +func (p Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // Data is not yet retrieved, since accessToken is still empty. + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", infoURL, nil) + if err != nil { + return user, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sess.AccessToken)) + + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + + defer resp.Body.Close() + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + var u classLinkUser + if err := json.Unmarshal(bytes, &user.RawData); err != nil { + return user, err + } + + if err := json.Unmarshal(bytes, &u); err != nil { + return user, err + } + + user.UserID = fmt.Sprintf("%d", u.UserID) + user.FirstName = u.FirstName + user.LastName = u.LastName + user.Email = u.Email + user.Name = u.DisplayName + return user, nil +} + +func (p Provider) Debug(b bool) {} + +func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("refresh token is not provided by ClassLink") +} + +func (p Provider) RefreshTokenAvailable() bool { + return false +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.ClientSecret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://launchpad.classlink.com/oauth2/v2/auth", + TokenURL: "https://launchpad.classlink.com/oauth2/v2/token", + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } else { + c.Scopes = append(c.Scopes, "profile") + } + + return c +} diff --git a/providers/classlink/provider_test.go b/providers/classlink/provider_test.go new file mode 100644 index 000000000..d40ea3c43 --- /dev/null +++ b/providers/classlink/provider_test.go @@ -0,0 +1,59 @@ +package classlink_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/classlink" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := classLinkProvider() + a.Equal(provider.ClientKey, os.Getenv("CLASSLINK_KEY")) + a.Equal(provider.ClientSecret, os.Getenv("CLASSLINK_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := classLinkProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*classlink.Session) + a.NoError(err) + a.Contains(s.AuthURL, "launchpad.classlink.com/oauth2/v2/") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=profile") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), classLinkProvider()) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := classLinkProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://launchpad.classlink.com/oauth2/v2/","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*classlink.Session) + a.Equal(session.AuthURL, "https://launchpad.classlink.com/oauth2/v2/") + a.Equal(session.AccessToken, "1234567890") +} + +func classLinkProvider() *classlink.Provider { + return classlink.New(os.Getenv("CLASSLINK_KEY"), os.Getenv("CLASSLINK_SECRET"), "/foo") +} diff --git a/providers/classlink/session.go b/providers/classlink/session.go new file mode 100644 index 000000000..bbb2d5d05 --- /dev/null +++ b/providers/classlink/session.go @@ -0,0 +1,49 @@ +package classlink + +import ( + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" +) + +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +func (s *Session) Marshal() string { + bytes, _ := json.Marshal(s) + return string(bytes) +} + +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +func (s *Session) String() string { + return s.Marshal() +} diff --git a/providers/classlink/session_test.go b/providers/classlink/session_test.go new file mode 100644 index 000000000..6112d599b --- /dev/null +++ b/providers/classlink/session_test.go @@ -0,0 +1,48 @@ +package classlink_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/classlink" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &classlink.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &classlink.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &classlink.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &classlink.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/cloudfoundry/cf.go b/providers/cloudfoundry/cf.go new file mode 100644 index 000000000..5c06763c8 --- /dev/null +++ b/providers/cloudfoundry/cf.go @@ -0,0 +1,176 @@ +// Package cloudfoundry implements the OAuth2 protocol for authenticating users through Cloud Foundry. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package cloudfoundry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Provider is the implementation of `goth.Provider` for accessing Cloud Foundry. +type Provider struct { + AuthURL string + TokenURL string + UserInfoURL string + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Cloud Foundry provider and sets up important connection details. +// You should always call `cloudfoundry.New` to get a new provider. Never try to +// create one manually. +func New(uaaURL, clientKey, secret, callbackURL string, scopes ...string) *Provider { + uaaURL = strings.TrimSuffix(uaaURL, "/") + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + AuthURL: uaaURL + "/oauth/authorize", + TokenURL: uaaURL + "/oauth/token", + UserInfoURL: uaaURL + "/userinfo", + providerName: "cloudfoundry", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the cloudfoundry package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Cloud Foundry for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Cloud Foundry and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.UserInfoURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + bits, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: provider.AuthURL, + TokenURL: provider.TokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Email string `json:"email"` + ID string `json:"user_id"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Name + user.UserID = u.ID + user.Email = u.Email + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ctx := context.WithValue(goth.ContextForClient(p.Client()), oauth2.HTTPClient, goth.HTTPClientWithFallBack(p.Client())) + ts := p.config.TokenSource(ctx, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/cloudfoundry/cf_test.go b/providers/cloudfoundry/cf_test.go new file mode 100644 index 000000000..b20384842 --- /dev/null +++ b/providers/cloudfoundry/cf_test.go @@ -0,0 +1,53 @@ +package cloudfoundry_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/cloudfoundry" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("UAA_CLIENT_ID")) + a.Equal(p.Secret, os.Getenv("UAA_CLIENT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*cloudfoundry.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://cf.example.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://cf.example.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*cloudfoundry.Session) + a.Equal(s.AuthURL, "https://cf.example.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *cloudfoundry.Provider { + return cloudfoundry.New("https://cf.example.com/", os.Getenv("UAA_CLIENT_ID"), os.Getenv("UAA_CLIENT_SECRET"), "/foo") +} diff --git a/providers/cloudfoundry/session.go b/providers/cloudfoundry/session.go new file mode 100644 index 000000000..896d4631e --- /dev/null +++ b/providers/cloudfoundry/session.go @@ -0,0 +1,66 @@ +package cloudfoundry + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Cloud Foundry. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Cloud Foundry provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New("an AuthURL has not be set") + } + return s.AuthURL, nil +} + +// Authorize the session with Cloud Foundry and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + ctx := context.WithValue(goth.ContextForClient(p.Client()), oauth2.HTTPClient, p.Client()) + token, err := p.config.Exchange(ctx, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/cloudfoundry/session_test.go b/providers/cloudfoundry/session_test.go new file mode 100644 index 000000000..19b5c1381 --- /dev/null +++ b/providers/cloudfoundry/session_test.go @@ -0,0 +1,48 @@ +package cloudfoundry_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/cloudfoundry" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &cloudfoundry.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &cloudfoundry.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &cloudfoundry.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &cloudfoundry.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/cognito/cognito.go b/providers/cognito/cognito.go new file mode 100644 index 000000000..ea4b23509 --- /dev/null +++ b/providers/cognito/cognito.go @@ -0,0 +1,238 @@ +package cognito + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Provider is the implementation of `goth.Provider` for accessing AWS Cognito. +// New takes 3 parameters all from the Cognito console: +// - The client ID +// - The client secret +// - The base URL for your service, either a custom domain or cognito pool based URL +// You need to ensure that the source login URL is whitelisted as a login page in the client configuration in the cognito console. +// GOTH does not provide a full token logout, to do that you need to do it in your code. +// If you do not perform a full logout their existing token will be used on a login and the user won't be prompted to login until after expiry. +// To perform a logout +// - Destroy your session (or however else you handle the logout internally) +// - redirect to https://CUSTOM_DOMAIN.auth.us-east-1.amazoncognito.com/logout?client_id=clinet_id&logout_uri=http://localhost:8080/ +// (or whatever your login/start page is). +// - Note that this page needs to be white-labeled as a logout page in the cognito console as well. + +// This is based upon the implementation for okta + +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + issuerURL string + profileURL string +} + +// New creates a new AWS Cognito provider and sets up important connection details. +// You should always call `cognito.New` to get a new provider. Never try to +// create one manually. +func New(clientID, secret, baseUrl, callbackURL string, scopes ...string) *Provider { + issuerURL := baseUrl + "/oauth2/default" + authURL := baseUrl + "/oauth2/authorize" + tokenURL := baseUrl + "/oauth2/token" + profileURL := baseUrl + "/oauth2/userInfo" + return NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientID, + Secret: secret, + CallbackURL: callbackURL, + providerName: "cognito", + issuerURL: issuerURL, + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the aws package. +func (p *Provider) Debug(debug bool) { + if debug { + fmt.Println("WARNING: Debug request for goth/providers/cognito but no debug is available") + } +} + +// BeginAuth asks AWS for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to aws and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + UserID: sess.UserID, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + if response != nil { + _ = response.Body.Close() + } + return user, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(response.Body) + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +// userFromReader +// These are the standard cognito attributes +// from: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html +// all attributes are optional +// it is possible for there to be custom attributes in cognito, but they don't seem to be passed as in the claims +// all the standard claims are mapped into the raw data +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + ID string `json:"sub"` + Address string `json:"address"` + Birthdate string `json:"birthdate"` + Email string `json:"email"` + EmailVerified string `json:"email_verified"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + MiddleName string `json:"middle_name"` + Name string `json:"name"` + NickName string `json:"nickname"` + Locale string `json:"locale"` + PhoneNumber string `json:"phone_number"` + PictureURL string `json:"picture"` + ProfileURL string `json:"profile"` + Username string `json:"preferred_username"` + UpdatedAt string `json:"updated_at"` + WebSite string `json:"website"` + Zoneinfo string `json:"zoneinfo"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + // Ensure all standard claims are in the raw data + rd := make(map[string]interface{}) + rd["Address"] = u.Address + rd["Birthdate"] = u.Birthdate + rd["Locale"] = u.Locale + rd["MiddleName"] = u.MiddleName + rd["PhoneNumber"] = u.PhoneNumber + rd["PictureURL"] = u.PictureURL + rd["ProfileURL"] = u.ProfileURL + rd["UpdatedAt"] = u.UpdatedAt + rd["Username"] = u.Username + rd["WebSite"] = u.WebSite + rd["EmailVerified"] = u.EmailVerified + + user.UserID = u.ID + user.Email = u.Email + user.Name = u.Name + user.NickName = u.NickName + user.FirstName = u.FirstName + user.LastName = u.LastName + user.AvatarURL = u.PictureURL + user.RawData = rd + + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/cognito/cognito_test.go b/providers/cognito/cognito_test.go new file mode 100644 index 000000000..732c989dc --- /dev/null +++ b/providers/cognito/cognito_test.go @@ -0,0 +1,67 @@ +package cognito + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/okta" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("COGNITO_ID")) + a.Equal(p.Secret, os.Getenv("COGNITO_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*okta.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*okta.Session) + a.NoError(err) + a.Contains(s.AuthURL, os.Getenv("COGNITO_ISSUER_URL")) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"` + os.Getenv("COGNITO_ISSUER_URL") + `/oauth2/authorize", "AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*okta.Session) + a.Equal(s.AuthURL, os.Getenv("COGNITO_ISSUER_URL")+"/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *okta.Provider { + return okta.New(os.Getenv("COGNITO_ID"), os.Getenv("COGNITO_SECRET"), os.Getenv("COGNITO_ISSUER_URL"), "/foo") +} + +func urlCustomisedURLProvider() *okta.Provider { + return okta.NewCustomisedURL(os.Getenv("CLIENT_ID"), os.Getenv("CLIENT_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://issuerURL", "http://profileURL") +} diff --git a/providers/cognito/session.go b/providers/cognito/session.go new file mode 100644 index 000000000..8aebda8bb --- /dev/null +++ b/providers/cognito/session.go @@ -0,0 +1,64 @@ +package cognito + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with AWS Cognito. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + UserID string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the AWS Cognito provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with cognito and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/cognito/session_test.go b/providers/cognito/session_test.go new file mode 100644 index 000000000..e1a8179c3 --- /dev/null +++ b/providers/cognito/session_test.go @@ -0,0 +1,47 @@ +package cognito + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/dailymotion/dailymotion.go b/providers/dailymotion/dailymotion.go new file mode 100644 index 000000000..a8657a596 --- /dev/null +++ b/providers/dailymotion/dailymotion.go @@ -0,0 +1,188 @@ +// Package dailymotion implements the OAuth2 protocol for authenticating users through Dailymotion. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package dailymotion + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.dailymotion.com/oauth/authorize" + tokenURL string = "https://www.dailymotion.com/oauth/token" + endpointProfile string = "https://api.dailymotion.com/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Dailymotion. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Dailymotion provider and sets up important connection details. +// You should always call `dailymotion.New` to get a new provider. Never try to +// create one manually. +func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "dailymotion", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the dailymotion package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Dailymotion for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser goes to Dailymotion to access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +// [Private] userFromReader will decode the json user and set the +// *goth.User attributes +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"fullname"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + NickName string `json:"username"` + Description string `json:"description"` + AvatarURL string `json:"avatar_720_url"` + Location string `json:"city"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.UserID = u.ID + user.Email = u.Email + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.NickName + user.Description = u.Description + user.AvatarURL = u.AvatarURL + user.Location = u.Location + + return nil +} + +// [Private] newConfig creates a new OAuth2 config +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ + "email", + }, + } + + defaultScopes := map[string]struct{}{ + "email": {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(oauth2.NoContext, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/dailymotion/dailymotion_test.go b/providers/dailymotion/dailymotion_test.go new file mode 100644 index 000000000..9b239f6b5 --- /dev/null +++ b/providers/dailymotion/dailymotion_test.go @@ -0,0 +1,53 @@ +package dailymotion_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/dailymotion" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := dailymotionProvider() + a.Equal(provider.ClientKey, os.Getenv("DAILYMOTION_KEY")) + a.Equal(provider.Secret, os.Getenv("DAILYMOTION_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), dailymotionProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := dailymotionProvider() + session, err := p.BeginAuth("test_state") + s := session.(*dailymotion.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://www.dailymotion.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := dailymotionProvider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.dailymotion.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*dailymotion.Session) + a.Equal(s.AuthURL, "https://www.dailymotion.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func dailymotionProvider() *dailymotion.Provider { + return dailymotion.New(os.Getenv("DAILYMOTION_KEY"), os.Getenv("DAILYMOTION_SECRET"), "/foo", "email") +} diff --git a/providers/dailymotion/session.go b/providers/dailymotion/session.go new file mode 100644 index 000000000..5bf27e821 --- /dev/null +++ b/providers/dailymotion/session.go @@ -0,0 +1,62 @@ +package dailymotion + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Dailymotion. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Dailymotion provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New("an AuthURL has not be set") + } + return s.AuthURL, nil +} + +// Authorize the session with Dailymotion and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/dailymotion/session_test.go b/providers/dailymotion/session_test.go new file mode 100644 index 000000000..ad6c35035 --- /dev/null +++ b/providers/dailymotion/session_test.go @@ -0,0 +1,48 @@ +package dailymotion_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/dailymotion" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dailymotion.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dailymotion.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dailymotion.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dailymotion.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/deezer/deezer.go b/providers/deezer/deezer.go new file mode 100644 index 000000000..7f5ed1f6d --- /dev/null +++ b/providers/deezer/deezer.go @@ -0,0 +1,179 @@ +// Package deezer implements the OAuth2 protocol for authenticating users through Deezer. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package deezer + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://connect.deezer.com/oauth/auth.php" + tokenURL string = "https://connect.deezer.com/oauth/access_token.php?output=json" + endpointProfile string = "https://api.deezer.com/user/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Deezer. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Deezer provider and sets up important connection details. +// You should always call `deezer.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "deezer", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the deezer package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Deezer for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser goes to Deezer to access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +// [Private] userFromReader will decode the json user and set the +// *goth.User attributes +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID int `json:"id"` + Email string `json:"email"` + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + NickName string `json:"name"` + AvatarURL string `json:"picture"` + Location string `json:"city"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.UserID = strconv.Itoa(u.ID) + user.Email = u.Email + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.NickName + user.AvatarURL = u.AvatarURL + user.Location = u.Location + + return nil +} + +// [Private] newConfig creates a new OAuth2 config +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ + "email", + }, + } + + defaultScopes := map[string]struct{}{ + "email": {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshTokenAvailable refresh token is not provided by deezer +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken refresh token is not provided by deezer +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by deezer") +} diff --git a/providers/deezer/deezer_test.go b/providers/deezer/deezer_test.go new file mode 100644 index 000000000..e471620e5 --- /dev/null +++ b/providers/deezer/deezer_test.go @@ -0,0 +1,53 @@ +package deezer_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/deezer" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := deezerProvider() + a.Equal(provider.ClientKey, os.Getenv("DEEZER_KEY")) + a.Equal(provider.Secret, os.Getenv("DEEZER_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), deezerProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := deezerProvider() + session, err := p.BeginAuth("test_state") + s := session.(*deezer.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://connect.deezer.com/oauth/auth.php") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := deezerProvider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://connect.deezer.com/oauth/auth.php","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*deezer.Session) + a.Equal(s.AuthURL, "https://connect.deezer.com/oauth/auth.php") + a.Equal(s.AccessToken, "1234567890") +} + +func deezerProvider() *deezer.Provider { + return deezer.New(os.Getenv("DEEZER_KEY"), os.Getenv("DEEZER_SECRET"), "/foo", "email") +} diff --git a/providers/deezer/session.go b/providers/deezer/session.go new file mode 100644 index 000000000..83f8c8074 --- /dev/null +++ b/providers/deezer/session.go @@ -0,0 +1,66 @@ +package deezer + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Deezer. +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Deezer provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Deezer and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + + ctx := goth.ContextForClient(p.Client()) + token, err := p.config.Exchange(ctx, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + expires, ok := token.Extra("expires").(float64) + if ok != true { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = time.Now().Add(time.Second * time.Duration(expires)) + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/deezer/session_test.go b/providers/deezer/session_test.go new file mode 100644 index 000000000..0ea219877 --- /dev/null +++ b/providers/deezer/session_test.go @@ -0,0 +1,48 @@ +package deezer_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/deezer" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &deezer.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &deezer.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &deezer.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &deezer.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/digitalocean/digitalocean.go b/providers/digitalocean/digitalocean.go new file mode 100644 index 000000000..560952f7f --- /dev/null +++ b/providers/digitalocean/digitalocean.go @@ -0,0 +1,177 @@ +// Package digitalocean implements the OAuth2 protocol for authenticating users through Digital Ocean. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package digitalocean + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://cloud.digitalocean.com/v1/oauth/authorize" + tokenURL string = "https://cloud.digitalocean.com/v1/oauth/token" + endpointProfile string = "https://api.digitalocean.com/v2/account" +) + +// New creates a new DigitalOcean provider, and sets up important connection details. +// You should always call `digitalocean.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "digitalocean", + } + + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing DigitalOcean. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +var _ goth.Provider = &Provider{} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the digitalocean package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks DigitalOcean for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to DigitalOcean and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + bits, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + Account struct { + DropletLimit int `json:"droplet_limit"` + Email string `json:"email"` + UUID string `json:"uuid"` + EmailVerified bool `json:"email_verified"` + Status string `json:"status"` + StatusMessage string `json:"status_message"` + } `json:"account"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Email = u.Account.Email + user.UserID = u.Account.UUID + + return err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/digitalocean/digitalocean_test.go b/providers/digitalocean/digitalocean_test.go new file mode 100644 index 000000000..ff2c0daec --- /dev/null +++ b/providers/digitalocean/digitalocean_test.go @@ -0,0 +1,51 @@ +package digitalocean_test + +import ( + "fmt" + "testing" + + "github.com/markbates/goth/providers/digitalocean" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := digitaloceanProvider() + a.Equal(provider.ClientKey, "digitalocean_key") + a.Equal(provider.Secret, "digitalocean_secret") + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := digitaloceanProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*digitalocean.Session) + + a.NoError(err) + a.Contains(s.AuthURL, "cloud.digitalocean.com/v1/oauth/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", "digitalocean_key")) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=read") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := digitaloceanProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://github.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*digitalocean.Session) + a.Equal(session.AuthURL, "http://github.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func digitaloceanProvider() *digitalocean.Provider { + return digitalocean.New("digitalocean_key", "digitalocean_secret", "/foo", "read") +} diff --git a/providers/digitalocean/session.go b/providers/digitalocean/session.go new file mode 100644 index 000000000..5d0045a3b --- /dev/null +++ b/providers/digitalocean/session.go @@ -0,0 +1,63 @@ +package digitalocean + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with DigitalOcean. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the DigitalOcean provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with DigitalOcean and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/digitalocean/session_test.go b/providers/digitalocean/session_test.go new file mode 100644 index 000000000..7970440df --- /dev/null +++ b/providers/digitalocean/session_test.go @@ -0,0 +1,39 @@ +package digitalocean_test + +import ( + "testing" + + "github.com/markbates/goth/providers/digitalocean" + "github.com/stretchr/testify/assert" +) + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &digitalocean.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &digitalocean.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &digitalocean.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/dingtalk/dingtalk.go b/providers/dingtalk/dingtalk.go new file mode 100644 index 000000000..fafb0ca1d --- /dev/null +++ b/providers/dingtalk/dingtalk.go @@ -0,0 +1,400 @@ +// Package dingtalk implements the OAuth2 protocol for authenticating users through DingTalk. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +// +// # Configuration +// +// To use the DingTalk provider, you need to create an application in the DingTalk Open Platform (https://open.dingtalk.com/): +// 1. Register a corporate/organization application to get AppKey and AppSecret +// 2. Set callback URL: http://your-domain/auth/dingtalk/callback +// 3. Request these necessary API permissions: +// - Contact.User.Read (必须/Required) +// - Contact.Member.Read (必须/Required) +// +// # Example +// +// // Basic use: +// dingTalkProvider := dingtalk.New( +// os.Getenv("DINGTALK_KEY"), +// os.Getenv("DINGTALK_SECRET"), +// "http://localhost:3000/auth/dingtalk/callback", +// "", // empty string if you don't need corporate ID verification +// "openid" // minimum scope +// ) +// +// // With corporate verification (limit to specific company): +// dingTalkProvider := dingtalk.New( +// os.Getenv("DINGTALK_KEY"), +// os.Getenv("DINGTALK_SECRET"), +// "http://localhost:3000/auth/dingtalk/callback", +// os.Getenv("DINGTALK_CORP_ID"), // corporate ID for verification +// "openid", +// "corpid" // needed for corporate verification +// ) +// +// // Enable debug mode for detailed logging +// dingTalkProvider.Debug(true) +// +// goth.UseProviders(dingTalkProvider) +// +// # Environment Variables +// +// DINGTALK_KEY: Your DingTalk application's client key/app key +// DINGTALK_SECRET: Your DingTalk application's client secret/app secret +// DINGTALK_CORP_ID: (Optional) For corporate ID verification, to limit authentication to a specific company +// +// See the examples/main.go file for a working example of this provider. +package dingtalk + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the Authentication, Token, and API URLS for DingTalk. +// See: https://open.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information +var ( + AuthURL = "https://login.dingtalk.com/oauth2/auth" + TokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" + ProfileURL = "https://api.dingtalk.com/v1.0/contact/users/me" +) + +// Logger for Debug output +var logger = log.New(os.Stdout, "[DingTalk Debug] ", log.LstdFlags|log.Lshortfile) + +// New creates a new DingTalk provider, and sets up important connection details. +// You should always call `dingtalk.New` to get a new Provider. Never try to create +// one manually. +// +// When using with "corpid" scope, include "openid" and "corpid" in scopes parameter. +func New(clientKey, secret, callbackURL string, expectedCorpID string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, expectedCorpID, scopes...) +} + +// NewWithCorpID creates a new DingTalk provider with company ID verification. +// If expectedCorpID is non-empty, the provider will verify that authenticated users +// belong to the specified company. Authentication will fail if the user's corpID doesn't match. +// +// Use this constructor when you need to restrict access to users from a specific company. +// Be sure to include "openid" and "corpid" in the scopes parameter. +func NewWithCorpID(clientKey, secret, callbackURL, expectedCorpID string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, expectedCorpID, "openid", "corpid") +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +// If expectedCorpID is non-empty, the provider will verify that authenticated users +// belong to the specified company. +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL, expectedCorpID string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "dingtalk", + profileURL: profileURL, + debug: false, + expectedCorpID: expectedCorpID, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing DingTalk. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + profileURL string + debug bool + expectedCorpID string // Corporate ID to validate against for company-specific authentication +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug sets the debug mode +func (p *Provider) Debug(debug bool) { + p.debug = debug +} + +// logDebug prints debug information +func (p *Provider) logDebug(format string, v ...interface{}) { + if p.debug { + logger.Printf(format, v...) + } +} + +// BeginAuth asks DingTalk for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + + // Add prompt=consent parameter to force showing the consent screen every time + if !strings.Contains(url, "prompt=") { + if strings.Contains(url, "?") { + url += "&prompt=consent" + } else { + url += "?prompt=consent" + } + } + + p.logDebug("Authorization URL with consent prompt: %s", url) + session := &Session{ + AuthURL: url, + ExpectedCorpID: p.expectedCorpID, + } + return session, nil +} + +// FetchUser will go to DingTalk and access basic information about the user. +// If expectedCorpID is set and the user's corpID doesn't match, an error will be returned. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + p.logDebug("Starting to fetch user info, AccessToken: %s", user.AccessToken[:10]+"...") + + // Get user information + reqProfile, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + p.logDebug("Failed to create request: %v", err) + return user, err + } + + reqProfile.Header.Add("x-acs-dingtalk-access-token", sess.AccessToken) + reqProfile.Header.Add("Content-Type", "application/json") + + p.logDebug("Sending request for user info: %s", p.profileURL) + p.logDebug("Request headers: %v", reqProfile.Header) + + response, err := p.Client().Do(reqProfile) + if err != nil { + p.logDebug("Failed to send request: %v", err) + return user, err + } + defer response.Body.Close() + + p.logDebug("Received response status code: %d", response.StatusCode) + + if response.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(response.Body) + p.logDebug("API error response: %s", string(respBody)) + return user, fmt.Errorf("DingTalk API responded with a %d trying to fetch user information: %s", + response.StatusCode, string(respBody)) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + p.logDebug("Failed to read response body: %v", err) + return user, err + } + + p.logDebug("Response content: %s", string(bits)) + + // Parse user information directly from the profile response + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + p.logDebug("Failed to parse JSON response: %v", err) + return user, err + } + + // Extract user fields directly + userInfo := struct { + UnionID string `json:"unionId"` + Email string `json:"email"` + Mobile string `json:"mobile"` + AvatarURL string `json:"avatarUrl"` + Nick string `json:"nick"` + OpenID string `json:"openId"` + }{} + + if err := json.NewDecoder(bytes.NewReader(bits)).Decode(&userInfo); err != nil { + p.logDebug("Failed to extract user fields: %v", err) + return user, err + } + + // Populate user struct + user.Name = userInfo.Nick + user.NickName = userInfo.Nick + user.Email = userInfo.Email + user.UserID = userInfo.UnionID + user.AvatarURL = userInfo.AvatarURL + + // Add corpID from session to user data + if sess.CorpID != "" && user.RawData != nil { + user.RawData["corpId"] = sess.CorpID + } + + p.logDebug("Successfully retrieved user info: Name=%s, Email=%s", user.Name, user.Email) + return user, nil +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } else { + // If no scope is provided, add the default "openid" + c.Scopes = []string{"openid"} + } + + return c +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + if refreshToken == "" { + return nil, errors.New("refresh token is required") + } + + p.logDebug("Attempting to refresh token with refreshToken: %s...", refreshToken[:10]) + + data := struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + RefreshToken string `json:"refreshToken"` + GrantType string `json:"grantType"` + }{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RefreshToken: refreshToken, + GrantType: "refresh_token", + } + + payload, err := json.Marshal(data) + if err != nil { + p.logDebug("Failed to marshal refresh token request: %v", err) + return nil, err + } + + req, err := http.NewRequest("POST", TokenURL, bytes.NewBuffer(payload)) + if err != nil { + p.logDebug("Failed to create refresh token request: %v", err) + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + + p.logDebug("Sending refresh token request") + p.logDebug("Request body: %s", string(payload)) + + resp, err := p.Client().Do(req) + if err != nil { + p.logDebug("Failed to send refresh token request: %v", err) + return nil, err + } + defer resp.Body.Close() + + p.logDebug("Refresh token response status code: %d", resp.StatusCode) + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + p.logDebug("Refresh token error response: %s", string(respBody)) + return nil, fmt.Errorf("DingTalk API responded with a %d trying to refresh token: %s", + resp.StatusCode, string(respBody)) + } + + var tokenResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int `json:"expireIn"` + CorpID string `json:"corpId"` // Corporate ID from token response + } + + respBody, _ := io.ReadAll(resp.Body) + p.logDebug("Refresh token response: %s", string(respBody)) + + err = json.NewDecoder(bytes.NewReader(respBody)).Decode(&tokenResponse) + if err != nil { + p.logDebug("Failed to parse refresh token response: %v", err) + return nil, err + } + + // Verify corporate ID if expected is set + if p.expectedCorpID != "" && tokenResponse.CorpID != "" { + if tokenResponse.CorpID != p.expectedCorpID { + p.logDebug("Corporate ID verification failed during token refresh. Expected: %s, Got: %s", + p.expectedCorpID, tokenResponse.CorpID) + return nil, fmt.Errorf("user does not belong to the expected company (corpid mismatch)") + } + p.logDebug("Corporate ID verification succeeded during token refresh") + } + + p.logDebug("Successfully refreshed token. New token: %s...", tokenResponse.AccessToken[:10]) + + token := &oauth2.Token{ + AccessToken: tokenResponse.AccessToken, + RefreshToken: tokenResponse.RefreshToken, + TokenType: "Bearer", + } + + // Add corpID as extra data + if tokenResponse.CorpID != "" { + extraData := map[string]interface{}{ + "corpId": tokenResponse.CorpID, + } + token = token.WithExtra(extraData) + } + + return token, nil +} + +// RefreshTokenAvailable refresh token is provided by DingTalk +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// GetCorpID retrieves the company ID from user data +// Returns the corpID and whether it was found +func GetCorpID(user goth.User) (string, bool) { + if user.RawData == nil { + return "", false + } + + if corpID, ok := user.RawData["corpId"].(string); ok { + return corpID, true + } + + return "", false +} diff --git a/providers/dingtalk/dingtalk_test.go b/providers/dingtalk/dingtalk_test.go new file mode 100644 index 000000000..f27229978 --- /dev/null +++ b/providers/dingtalk/dingtalk_test.go @@ -0,0 +1,53 @@ +package dingtalk_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/dingtalk" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("DINGTALK_KEY")) + a.Equal(p.Secret, os.Getenv("DINGTALK_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*dingtalk.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.dingtalk.com/oauth2/auth") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://login.dingtalk.com/oauth2/auth","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*dingtalk.Session) + a.Equal(s.AuthURL, "https://login.dingtalk.com/oauth2/auth") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *dingtalk.Provider { + return dingtalk.New(os.Getenv("DINGTALK_KEY"), os.Getenv("DINGTALK_SECRET"), "/foo", os.Getenv("DINGTALK_CORP_ID")) +} diff --git a/providers/dingtalk/session.go b/providers/dingtalk/session.go new file mode 100644 index 000000000..bb6a55083 --- /dev/null +++ b/providers/dingtalk/session.go @@ -0,0 +1,139 @@ +package dingtalk + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with DingTalk. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + CorpID string // Corporate ID of the authenticated user + ExpectedCorpID string // Expected Corporate ID for validation +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the DingTalk provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with DingTalk and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + + // DingTalk uses a non-standard OAuth2 flow, using JSON request to get the token + code := params.Get("code") + if code == "" { + return "", errors.New("no code received") + } + + p.logDebug("Authorizing with code: %s", code) + + data := struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Code string `json:"code"` + GrantType string `json:"grantType"` + }{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + Code: code, + GrantType: "authorization_code", + } + + payload, err := json.Marshal(data) + if err != nil { + p.logDebug("Failed to marshal authorization data: %v", err) + return "", err + } + + client := p.Client() + req, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", strings.NewReader(string(payload))) + if err != nil { + p.logDebug("Failed to create authorization request: %v", err) + return "", err + } + + req.Header.Add("Content-Type", "application/json") + + p.logDebug("Sending authorization request") + p.logDebug("Request body: %s", string(payload)) + + resp, err := client.Do(req) + if err != nil { + p.logDebug("Failed to send authorization request: %v", err) + return "", err + } + defer resp.Body.Close() + + p.logDebug("Authorization response status code: %d", resp.StatusCode) + + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + p.logDebug("Authorization error response: %s", string(respBody)) + return "", fmt.Errorf("DingTalk auth error (status %d): %s", resp.StatusCode, string(respBody)) + } + + respBody, _ := io.ReadAll(resp.Body) + p.logDebug("Authorization response: %s", string(respBody)) + + var tokenResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int `json:"expireIn"` + CorpID string `json:"corpId"` // Corporate ID field from token response + } + + if err = json.NewDecoder(strings.NewReader(string(respBody))).Decode(&tokenResponse); err != nil { + p.logDebug("Failed to parse authorization response: %v", err) + return "", err + } + + s.AccessToken = tokenResponse.AccessToken + s.RefreshToken = tokenResponse.RefreshToken + s.ExpiresAt = time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)) + s.CorpID = tokenResponse.CorpID + + p.logDebug("Successfully authorized. Access token: %s...", s.AccessToken[:10]) + + // Verify Corporate ID if expected is set + if s.ExpectedCorpID != "" && s.CorpID != "" { + if s.CorpID != s.ExpectedCorpID { + p.logDebug("Corporate ID verification failed. Expected: %s, Got: %s", s.ExpectedCorpID, s.CorpID) + return "", fmt.Errorf("user does not belong to the expected company (corpid mismatch)") + } + p.logDebug("Corporate ID verification succeeded") + } + + return s.AccessToken, nil +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/dingtalk/session_test.go b/providers/dingtalk/session_test.go new file mode 100644 index 000000000..38a71596a --- /dev/null +++ b/providers/dingtalk/session_test.go @@ -0,0 +1,57 @@ +package dingtalk_test + +import ( + "testing" + "time" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/dingtalk" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dingtalk.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dingtalk.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dingtalk.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","CorpID":"","ExpectedCorpID":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dingtalk.Session{} + + a.Equal(s.String(), s.Marshal()) +} + +func Test_GetExpiresAt(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &dingtalk.Session{} + + a.Equal(s.ExpiresAt, time.Time{}) +} diff --git a/providers/discord/discord.go b/providers/discord/discord.go new file mode 100644 index 000000000..118f5e476 --- /dev/null +++ b/providers/discord/discord.go @@ -0,0 +1,236 @@ +// Package discord implements the OAuth2 protocol for authenticating users through Discord. +// This package can be used as a reference implementation of an OAuth2 provider for Discord. +package discord + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://discord.com/api/oauth2/authorize" + tokenURL string = "https://discord.com/api/oauth2/token" + userEndpoint string = "https://discord.com/api/users/@me" +) + +const ( + // ScopeIdentify allows /users/@me without email + ScopeIdentify string = "identify" + // ScopeEmail enables /users/@me to return an email + ScopeEmail string = "email" + // ScopeConnections allows /users/@me/connections to return linked Twitch and YouTube accounts + ScopeConnections string = "connections" + // ScopeGuilds allows /users/@me/guilds to return basic information about all of a user's guilds + ScopeGuilds string = "guilds" + // ScopeJoinGuild allows /invites/{invite.id} to be used for joining a user's guild + ScopeJoinGuild string = "guilds.join" + // ScopeGroupDMjoin allows your app to join users to a group dm + ScopeGroupDMjoin string = "gdm.join" + // ScopeBot is for oauth2 bots, this puts the bot in the user's selected guild by default + ScopeBot string = "bot" + // ScopeWebhook generates a webhook that is returned in the oauth token response for authorization code grants + ScopeWebhook string = "webhook.incoming" + // ScopeReadGuilds allows /users/@me/guilds/{guild.id}/member to return a user's member information in a guild + ScopeReadGuilds string = "guilds.members.read" +) + +// New creates a new Discord provider, and sets up important connection details. +// You should always call `discord.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "discord", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Discord +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + permissions string +} + +// Name gets the name used to retrieve this provider. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// SetPermissions is to update the bot permissions (used for when ScopeBot is set) +func (p *Provider) SetPermissions(permissions string) { + p.permissions = permissions +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is no-op for the Discord package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Discord for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + + opts := []oauth2.AuthCodeOption{ + oauth2.AccessTypeOnline, + oauth2.SetAuthURLParam("prompt", "none"), + } + + if p.permissions != "" { + opts = append(opts, oauth2.SetAuthURLParam("permissions", p.permissions)) + } + + url := p.config.AuthCodeURL(state, opts...) + + s := &Session{ + AuthURL: url, + } + return s, nil +} + +// FetchUser will go to Discord and access basic info about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return user, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + bits, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + if err != nil { + return user, err + } + + return user, err +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"username"` + Email string `json:"email"` + AvatarID string `json:"avatar"` + MFAEnabled bool `json:"mfa_enabled"` + Discriminator string `json:"discriminator"` + Verified bool `json:"verified"` + ID string `json:"id"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + // If this prefix is present, the image should be available as a gif, + // See : https://discord.com/developers/docs/reference#image-formatting + // Introduced by : Yyewolf + + if u.AvatarID != "" { + avatarExtension := ".png" + prefix := "a_" + if len(u.AvatarID) >= len(prefix) && u.AvatarID[0:len(prefix)] == prefix { + avatarExtension = ".gif" + } + user.AvatarURL = "https://media.discordapp.net/avatars/" + u.ID + "/" + u.AvatarID + avatarExtension + } + + user.Name = u.Name + user.Email = u.Email + user.UserID = u.ID + + return nil +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = []string{ScopeIdentify} + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(oauth2.NoContext, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/discord/discord_test.go b/providers/discord/discord_test.go new file mode 100644 index 000000000..8bc077f0b --- /dev/null +++ b/providers/discord/discord_test.go @@ -0,0 +1,54 @@ +package discord + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func provider() *Provider { + return New(os.Getenv("DISCORD_KEY"), + os.Getenv("DISCORD_SECRET"), "/foo", "user") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("DISCORD_KEY")) + a.Equal(p.Secret, os.Getenv("DISCORD_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_ImplementsProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "discord.com/api/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://discord.com/api/oauth2/authorize", "AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*Session) + a.Equal(s.AuthURL, "https://discord.com/api/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} diff --git a/providers/discord/session.go b/providers/discord/session.go new file mode 100644 index 000000000..228237e8d --- /dev/null +++ b/providers/discord/session.go @@ -0,0 +1,66 @@ +package discord + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Discord +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on +// the Discord provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize completes the authorization with Discord and returns the access +// token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal marshals a session into a JSON string. +func (s Session) Marshal() string { + j, _ := json.Marshal(s) + return string(j) +} + +// String is equivalent to Marshal. It returns a JSON representation of the +// session. +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/discord/session_test.go b/providers/discord/session_test.go new file mode 100644 index 000000000..ee412e7e9 --- /dev/null +++ b/providers/discord/session_test.go @@ -0,0 +1,38 @@ +package discord + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} diff --git a/providers/dropbox/dropbox.go b/providers/dropbox/dropbox.go new file mode 100644 index 000000000..e02dce853 --- /dev/null +++ b/providers/dropbox/dropbox.go @@ -0,0 +1,211 @@ +// Package dropbox implements the OAuth2 protocol for authenticating users through Dropbox. +package dropbox + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL = "https://www.dropbox.com/oauth2/authorize" + tokenURL = "https://api.dropbox.com/oauth2/token" + accountURL = "https://api.dropbox.com/2/users/get_current_account" +) + +// Provider is the implementation of `goth.Provider` for accessing Dropbox. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + AccountURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Session stores data during the auth process with Dropbox. +type Session struct { + AuthURL string + Token string +} + +// New creates a new Dropbox provider and sets up important connection details. +// You should always call `dropbox.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + AccountURL: accountURL, + providerName: "dropbox", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the dropbox package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Dropbox for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Dropbox and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.Token, + Provider: p.Name(), + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("POST", p.AccountURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.Token) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + bits, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} + +// GetAuthURL gets the URL set by calling the `BeginAuth` function on the Dropbox provider. +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New("dropbox: missing AuthURL") + } + return s.AuthURL, nil +} + +// Authorize the session with Dropbox and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.Token = token.AccessToken + return token.AccessToken, nil +} + +// Marshal the session into a string +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + AccountID string `json:"account_id"` + Name struct { + GivenName string `json:"given_name"` + Surname string `json:"surname"` + DisplayName string `json:"display_name"` + } `json:"name"` + Country string `json:"country"` + Email string `json:"email"` + ProfilePhotoURL string `json:"profile_photo_url"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.UserID = u.AccountID // The user's unique Dropbox ID. + user.FirstName = u.Name.GivenName + user.LastName = u.Name.Surname + user.Name = strings.TrimSpace(fmt.Sprintf("%s %s", u.Name.GivenName, u.Name.Surname)) + user.Description = u.Name.DisplayName // Full name plus parenthetical team name + user.Email = u.Email + user.NickName = u.Email // Email is the dropbox username + user.Location = u.Country + user.AvatarURL = u.ProfilePhotoURL // May be blank + return nil +} + +// RefreshToken refresh token is not provided by dropbox +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by dropbox") +} + +// RefreshTokenAvailable refresh token is not provided by dropbox +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/dropbox/dropbox_test.go b/providers/dropbox/dropbox_test.go new file mode 100644 index 000000000..fe69232cb --- /dev/null +++ b/providers/dropbox/dropbox_test.go @@ -0,0 +1,166 @@ +package dropbox + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func provider() *Provider { + return New(os.Getenv("DROPBOX_KEY"), os.Getenv("DROPBOX_SECRET"), "/foo", "email") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("DROPBOX_KEY")) + a.Equal(p.Secret, os.Getenv("DROPBOX_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.dropbox.com/oauth2/authorize") +} + +func Test_FetchUser(t *testing.T) { + accountPath := "/2/users/get_current_account" + + t.Parallel() + a := assert.New(t) + p := provider() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.Equal(r.Header.Get("Authorization"), "Bearer 1234567890") + a.Equal(r.Method, "POST") + a.Equal(r.URL.Path, accountPath) + w.Write([]byte(testAccountResponse)) + })) + p.AccountURL = ts.URL + accountPath + + // AuthURL is superfluous for this test but ok + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.dropbox.com/oauth2/authorize","Token":"1234567890"}`) + a.NoError(err) + user, err := p.FetchUser(session) + a.NoError(err) + a.Equal(user.UserID, "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc") + a.Equal(user.FirstName, "Franz") + a.Equal(user.LastName, "Ferdinand") + a.Equal(user.Name, "Franz Ferdinand") + a.Equal(user.Description, "Franz Ferdinand (Personal)") + a.Equal(user.NickName, "franz@dropbox.com") + a.Equal(user.Email, "franz@dropbox.com") + a.Equal(user.Location, "US") + a.Equal(user.AccessToken, "1234567890") + a.Equal(user.AccessTokenSecret, "") + a.Equal(user.AvatarURL, "https://dl-web.dropbox.com/account_photo/get/dbid%3AAAH4f99T0taONIb-OurWxbNQ6ywGRopQngc?vers=1453416673259\u0026size=128x128") + a.Equal(user.Provider, "dropbox") + a.Len(user.RawData, 14) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.dropbox.com/oauth2/authorize","Token":"1234567890"}`) + a.NoError(err) + + s := session.(*Session) + a.Equal(s.AuthURL, "https://www.dropbox.com/oauth2/authorize") + a.Equal(s.Token, "1234567890") +} + +func Test_SessionToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","Token":""}`) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +var testAccountResponse = ` +{ + "account_id": "dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc", + "name": { + "given_name": "Franz", + "surname": "Ferdinand", + "familiar_name": "Franz", + "display_name": "Franz Ferdinand (Personal)", + "abbreviated_name": "FF" + }, + "email": "franz@dropbox.com", + "email_verified": true, + "disabled": false, + "locale": "en", + "referral_link": "https://db.tt/ZITNuhtI", + "is_paired": true, + "account_type": { + ".tag": "business" + }, + "root_info": { + ".tag": "user", + "root_namespace_id": "3235641", + "home_namespace_id": "3235641" + }, + "country": "US", + "team": { + "id": "dbtid:AAFdgehTzw7WlXhZJsbGCLePe8RvQGYDr-I", + "name": "Acme, Inc.", + "sharing_policies": { + "shared_folder_member_policy": { + ".tag": "team" + }, + "shared_folder_join_policy": { + ".tag": "from_anyone" + }, + "shared_link_create_policy": { + ".tag": "team_only" + } + }, + "office_addin_policy": { + ".tag": "disabled" + } + }, + "profile_photo_url": "https://dl-web.dropbox.com/account_photo/get/dbid%3AAAH4f99T0taONIb-OurWxbNQ6ywGRopQngc?vers=1453416673259\u0026size=128x128", + "team_member_id": "dbmid:AAHhy7WsR0x-u4ZCqiDl5Fz5zvuL3kmspwU" +} +` diff --git a/providers/eveonline/eveonline.go b/providers/eveonline/eveonline.go new file mode 100644 index 000000000..6bf156321 --- /dev/null +++ b/providers/eveonline/eveonline.go @@ -0,0 +1,162 @@ +// Package eveonline implements the OAuth2 protocol for authenticating users through eveonline. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package eveonline + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authPath string = "https://login.eveonline.com/oauth/authorize/" + tokenPath string = "https://login.eveonline.com/oauth/token" + verifyPath string = "https://login.eveonline.com/oauth/verify" +) + +// Provider is the implementation of `goth.Provider` for accessing eveonline. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Eve Online provider and sets up important connection details. +// You should always call `eveonline.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "eveonline", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns the default http.client +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the eveonline package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Eve Online for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Eve Online and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // Get the userID, eveonline needs userID in order to get user profile info + req, err := http.NewRequest("GET", verifyPath, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+user.AccessToken) + + response, err := p.Client().Do(req) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + u := struct { + CharacterID int64 + CharacterName string + ExpiresOn string + Scopes string + TokenType string + CharacterOwnerHash string + }{} + + if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { + return user, err + } + + user.NickName = u.CharacterName + user.UserID = fmt.Sprintf("%d", u.CharacterID) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authPath, + TokenURL: tokenPath, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/eveonline/eveonline_test.go b/providers/eveonline/eveonline_test.go new file mode 100644 index 000000000..50181ad82 --- /dev/null +++ b/providers/eveonline/eveonline_test.go @@ -0,0 +1,53 @@ +package eveonline_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/eveonline" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("EVEONLINE_KEY")) + a.Equal(p.Secret, os.Getenv("EVEONLINE_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*eveonline.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.eveonline.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://login.eveonline.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*eveonline.Session) + a.Equal(s.AuthURL, "https://login.eveonline.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *eveonline.Provider { + return eveonline.New(os.Getenv("EVEONLINE_KEY"), os.Getenv("EVEONLINE_SECRET"), "/foo") +} diff --git a/providers/eveonline/session.go b/providers/eveonline/session.go new file mode 100644 index 000000000..d07d0ec47 --- /dev/null +++ b/providers/eveonline/session.go @@ -0,0 +1,63 @@ +package eveonline + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Eve Online. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Eve Online provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Eve Online and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/eveonline/session_test.go b/providers/eveonline/session_test.go new file mode 100644 index 000000000..3e1f4f213 --- /dev/null +++ b/providers/eveonline/session_test.go @@ -0,0 +1,48 @@ +package eveonline_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/eveonline" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &eveonline.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &eveonline.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &eveonline.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &eveonline.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/facebook/facebook.go b/providers/facebook/facebook.go new file mode 100644 index 000000000..f740f46f5 --- /dev/null +++ b/providers/facebook/facebook.go @@ -0,0 +1,215 @@ +// Package facebook implements the OAuth2 protocol for authenticating users through Facebook. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package facebook + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.facebook.com/dialog/oauth" + tokenURL string = "https://graph.facebook.com/oauth/access_token" + endpointProfile string = "https://graph.facebook.com/me?fields=" +) + +// New creates a new Facebook provider, and sets up important connection details. +// You should always call `facebook.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "facebook", + } + p.config = newConfig(p, scopes) + p.Fields = "email,first_name,last_name,link,about,id,name,picture,location" + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Facebook. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + Fields string + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// SetCustomFields sets the fields used to return information +// for a user. +// +// A list of available field values can be found at +// https://developers.facebook.com/docs/graph-api/reference/user +func (p *Provider) SetCustomFields(fields []string) *Provider { + p.Fields = strings.Join(fields, ",") + return p +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the facebook package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Facebook for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authUrl := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: authUrl, + } + return session, nil +} + +// FetchUser will go to Facebook and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // always add appsecretProof to make calls more protected + // https://github.com/markbates/goth/issues/96 + // https://developers.facebook.com/docs/graph-api/securing-requests + hash := hmac.New(sha256.New, []byte(p.Secret)) + hash.Write([]byte(sess.AccessToken)) + appsecretProof := hex.EncodeToString(hash.Sum(nil)) + + reqUrl := fmt.Sprint( + endpointProfile, + p.Fields, + "&access_token=", + url.QueryEscape(sess.AccessToken), + "&appsecret_proof=", + appsecretProof, + ) + response, err := p.Client().Get(reqUrl) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID string `json:"id"` + Email string `json:"email"` + About string `json:"about"` + Name string `json:"name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Link string `json:"link"` + Picture struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } `json:"picture"` + Location struct { + Name string `json:"name"` + } `json:"location"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Name + user.Email = u.Email + user.Description = u.About + user.AvatarURL = u.Picture.Data.URL + user.UserID = u.ID + user.Location = u.Location.Name + + return err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ + "email", + }, + } + + defaultScopes := map[string]struct{}{ + "email": {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshToken refresh token is not provided by facebook +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by facebook") +} + +// RefreshTokenAvailable refresh token is not provided by facebook +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/facebook/facebook_test.go b/providers/facebook/facebook_test.go new file mode 100644 index 000000000..df9dd1cf5 --- /dev/null +++ b/providers/facebook/facebook_test.go @@ -0,0 +1,72 @@ +package facebook_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/facebook" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := facebookProvider() + a.Equal(provider.ClientKey, os.Getenv("FACEBOOK_KEY")) + a.Equal(provider.Secret, os.Getenv("FACEBOOK_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), facebookProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := facebookProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*facebook.Session) + a.NoError(err) + a.Contains(s.AuthURL, "facebook.com/dialog/oauth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("FACEBOOK_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=email") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := facebookProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://facebook.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*facebook.Session) + a.Equal(session.AuthURL, "http://facebook.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func Test_SetCustomFields(t *testing.T) { + t.Parallel() + defaultFields := "email,first_name,last_name,link,about,id,name,picture,location" + cf := []string{"email", "picture.type(large)"} + a := assert.New(t) + + provider := facebookProvider() + a.Equal(provider.Fields, defaultFields) + provider.SetCustomFields(cf) + a.Equal(provider.Fields, strings.Join(cf, ",")) +} + +func facebookProvider() *facebook.Provider { + return facebook.New(os.Getenv("FACEBOOK_KEY"), os.Getenv("FACEBOOK_SECRET"), "/foo", "email") +} diff --git a/providers/facebook/session.go b/providers/facebook/session.go new file mode 100644 index 000000000..5cdcca443 --- /dev/null +++ b/providers/facebook/session.go @@ -0,0 +1,59 @@ +package facebook + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Facebook. +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Facebook and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/facebook/session_test.go b/providers/facebook/session_test.go new file mode 100644 index 000000000..0b709a16a --- /dev/null +++ b/providers/facebook/session_test.go @@ -0,0 +1,48 @@ +package facebook_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/facebook" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &facebook.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/faux/README.md b/providers/faux/README.md new file mode 100644 index 000000000..6654a6178 --- /dev/null +++ b/providers/faux/README.md @@ -0,0 +1,3 @@ +# FauxProvider + +This provider is merely here to help with testing other parts of these packages. I wouldn't recommend using it. :) diff --git a/providers/faux/faux.go b/providers/faux/faux.go new file mode 100644 index 000000000..4b0cc5dd1 --- /dev/null +++ b/providers/faux/faux.go @@ -0,0 +1,110 @@ +// Package faux is used exclusively for testing purposes. I would strongly suggest you move along +// as there's nothing to see here. +package faux + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Provider is used only for testing. +type Provider struct { + HTTPClient *http.Client + providerName string +} + +// Session is used only for testing. +type Session struct { + ID string + Name string + Email string + AuthURL string + AccessToken string +} + +// Name is used only for testing. +func (p *Provider) Name() string { + return "faux" +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// BeginAuth is used only for testing. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + c := &oauth2.Config{ + Endpoint: oauth2.Endpoint{ + AuthURL: "http://example.com/auth", + }, + } + url := c.AuthCodeURL(state) + return &Session{ + ID: "id", + AuthURL: url, + }, nil +} + +// FetchUser is used only for testing. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + UserID: sess.ID, + Name: sess.Name, + Email: sess.Email, + Provider: p.Name(), + AccessToken: sess.AccessToken, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + return user, nil +} + +// UnmarshalSession is used only for testing. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is used only for testing. +func (p *Provider) Debug(debug bool) {} + +// RefreshTokenAvailable is used only for testing +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken is used only for testing +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} + +// Authorize is used only for testing. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + s.AccessToken = "access" + return s.AccessToken, nil +} + +// Marshal is used only for testing. +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +// GetAuthURL is used only for testing. +func (s *Session) GetAuthURL() (string, error) { + return s.AuthURL, nil +} diff --git a/providers/fitbit/fitbit.go b/providers/fitbit/fitbit.go new file mode 100644 index 000000000..8f402ada7 --- /dev/null +++ b/providers/fitbit/fitbit.go @@ -0,0 +1,195 @@ +// Package fitbit implements the OAuth protocol for authenticating users through Fitbit. +// This package can be used as a reference implementation of an OAuth provider for Goth. +package fitbit + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.fitbit.com/oauth2/authorize" + tokenURL string = "https://api.fitbit.com/oauth2/token" + endpointProfile string = "https://api.fitbit.com/1/user/-/profile.json" // '-' for logged in user +) + +const ( + // ScopeActivity includes activity data and exercise log related features, such as steps, distance, calories burned, and active minutes + ScopeActivity = "activity" + // ScopeHeartRate includes the continuous heart rate data and related analysis + ScopeHeartRate = "heartrate" + // ScopeLocation includes the GPS and other location data + ScopeLocation = "location" + // ScopeNutrition includes calorie consumption and nutrition related features, such as food/water logging, goals, and plans + ScopeNutrition = "nutrition" + // ScopeProfile is the basic user information + ScopeProfile = "profile" + // ScopeSettings includes user account and device settings, such as alarms + ScopeSettings = "settings" + // ScopeSleep includes sleep logs and related sleep analysis + ScopeSleep = "sleep" + // ScopeSocial includes friend-related features, such as friend list, invitations, and leaderboard + ScopeSocial = "social" + // ScopeWeight includes weight and related information, such as body mass index, body fat percentage, and goals + ScopeWeight = "weight" +) + +// New creates a new Fitbit provider, and sets up important connection details. +// You should always call `fitbit.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "fitbit", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Fitbit. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the fitbit package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Fitbit for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Fitbit and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + UserID: s.UserID, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + // err = userFromReader(io.TeeReader(resp.Body, os.Stdout), &user) + err = userFromReader(resp.Body, &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + User struct { + Avatar string `json:"avatar"` + Country string `json:"country"` + FullName string `json:"fullName"` + DisplayName string `json:"displayName"` + } `json:"user"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Location = u.User.Country + user.Name = u.User.FullName + user.NickName = u.User.DisplayName + user.AvatarURL = u.User.Avatar + + return err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ + ScopeProfile, + }, + } + + defaultScopes := map[string]struct{}{ + ScopeProfile: {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(oauth2.NoContext, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// RefreshTokenAvailable refresh token is not provided by fitbit +func (p *Provider) RefreshTokenAvailable() bool { + return true +} diff --git a/providers/fitbit/fitbit_test.go b/providers/fitbit/fitbit_test.go new file mode 100644 index 000000000..bf7f30153 --- /dev/null +++ b/providers/fitbit/fitbit_test.go @@ -0,0 +1,55 @@ +package fitbit_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/fitbit" + "github.com/stretchr/testify/assert" +) + +func provider() *fitbit.Provider { + return fitbit.New(os.Getenv("FITBIT_KEY"), os.Getenv("FITBIT_SECRET"), "/foo", "user") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("FITBIT_KEY")) + a.Equal(p.Secret, os.Getenv("FITBIT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_ImplementsProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*fitbit.Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.fitbit.com/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.fitbit.com/oauth2/authorize","AccessToken":"1234567890","UserID":"abc"}`) + a.NoError(err) + + s := session.(*fitbit.Session) + a.Equal(s.AuthURL, "https://www.fitbit.com/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") + a.Equal(s.UserID, "abc") +} diff --git a/providers/fitbit/session.go b/providers/fitbit/session.go new file mode 100644 index 000000000..4a499d2dd --- /dev/null +++ b/providers/fitbit/session.go @@ -0,0 +1,61 @@ +package fitbit + +import ( + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Fitbit. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + UserID string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the +// Fitbit provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize completes the authorization with Fitbit and returns the access +// token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code"), oauth2.SetAuthURLParam("code_verifier", params.Get("code_verifier"))) + if err != nil { + return "", err + } + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + s.UserID = token.Extra("user_id").(string) + return token.AccessToken, err +} + +// Marshal marshals a session into a JSON string. +func (s Session) Marshal() string { + j, _ := json.Marshal(s) + return string(j) +} + +// String is equivalent to Marshal. It returns a JSON representation of the session. +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := Session{} + err := json.Unmarshal([]byte(data), &s) + return &s, err +} diff --git a/providers/fitbit/session_test.go b/providers/fitbit/session_test.go new file mode 100644 index 000000000..0c30636c6 --- /dev/null +++ b/providers/fitbit/session_test.go @@ -0,0 +1,38 @@ +package fitbit_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/fitbit" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &fitbit.Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &fitbit.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &fitbit.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) +} diff --git a/providers/gitea/gitea.go b/providers/gitea/gitea.go new file mode 100644 index 000000000..d04f2046c --- /dev/null +++ b/providers/gitea/gitea.go @@ -0,0 +1,186 @@ +// Package gitea implements the OAuth2 protocol for authenticating users through gitea. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package gitea + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the default Authentication, Token, and Profile URLS for Gitea. +// +// Examples: +// +// gitea.AuthURL = "https://gitea.acme.com/oauth/authorize +// gitea.TokenURL = "https://gitea.acme.com/oauth/token +// gitea.ProfileURL = "https://gitea.acme.com/api/v3/user +var ( + AuthURL = "https://gitea.com/login/oauth/authorize" + TokenURL = "https://gitea.com/login/oauth/access_token" + ProfileURL = "https://gitea.com/api/v1/user" +) + +// Provider is the implementation of `goth.Provider` for accessing Gitea. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + authURL string + tokenURL string + profileURL string +} + +// New creates a new Gitea provider and sets up important connection details. +// You should always call `gitea.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "gitea", + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the gitea package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Gitea for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Gitea and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(p.profileURL + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"full_name"` + Email string `json:"email"` + NickName string `json:"login"` + ID int `json:"id"` + AvatarURL string `json:"avatar_url"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.NickName = u.NickName + user.UserID = strconv.Itoa(u.ID) + user.AvatarURL = u.AvatarURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/gitea/gitea_test.go b/providers/gitea/gitea_test.go new file mode 100644 index 000000000..c5f1d399c --- /dev/null +++ b/providers/gitea/gitea_test.go @@ -0,0 +1,67 @@ +package gitea_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/gitea" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("GITEA_KEY")) + a.Equal(p.Secret, os.Getenv("GITEA_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*gitea.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*gitea.Session) + a.NoError(err) + a.Contains(s.AuthURL, "gitea.com/login/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://gitea.com/login/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*gitea.Session) + a.Equal(s.AuthURL, "https://gitea.com/login/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *gitea.Provider { + return gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "/foo") +} + +func urlCustomisedURLProvider() *gitea.Provider { + return gitea.NewCustomisedURL(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") +} diff --git a/providers/gitea/session.go b/providers/gitea/session.go new file mode 100644 index 000000000..18c3fff7e --- /dev/null +++ b/providers/gitea/session.go @@ -0,0 +1,63 @@ +package gitea + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Gitea. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Gitea provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Gitea and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/gitea/session_test.go b/providers/gitea/session_test.go new file mode 100644 index 000000000..565b76653 --- /dev/null +++ b/providers/gitea/session_test.go @@ -0,0 +1,48 @@ +package gitea_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/gitea" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitea.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitea.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitea.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitea.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/github/github.go b/providers/github/github.go new file mode 100644 index 000000000..37efff9de --- /dev/null +++ b/providers/github/github.go @@ -0,0 +1,244 @@ +// Package github implements the OAuth2 protocol for authenticating users through Github. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package github + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the Authentication, Token, and API URLS for GitHub. If +// using GitHub enterprise you should change these values before calling New. +// +// Examples: +// +// github.AuthURL = "https://github.acme.com/login/oauth/authorize +// github.TokenURL = "https://github.acme.com/login/oauth/access_token +// github.ProfileURL = "https://github.acme.com/api/v3/user +// github.EmailURL = "https://github.acme.com/api/v3/user/emails +var ( + AuthURL = "https://github.com/login/oauth/authorize" + TokenURL = "https://github.com/login/oauth/access_token" + ProfileURL = "https://api.github.com/user" + EmailURL = "https://api.github.com/user/emails" +) + +var ( + // ErrNoVerifiedGitHubPrimaryEmail user doesn't have verified primary email on GitHub + ErrNoVerifiedGitHubPrimaryEmail = errors.New("The user does not have a verified, primary email address on GitHub") +) + +// New creates a new Github provider, and sets up important connection details. +// You should always call `github.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, EmailURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL, emailURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "github", + profileURL: profileURL, + emailURL: emailURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Github. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + profileURL string + emailURL string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the github package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Github for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Github and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + return user, err + } + + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("GitHub API responded with a %d trying to fetch user information", response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + if err != nil { + return user, err + } + + if user.Email == "" { + for _, scope := range p.config.Scopes { + if strings.TrimSpace(scope) == "user" || strings.TrimSpace(scope) == "user:email" { + user.Email, err = getPrivateMail(p, sess) + if err != nil { + return user, err + } + break + } + } + } + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID int `json:"id"` + Email string `json:"email"` + Bio string `json:"bio"` + Name string `json:"name"` + Login string `json:"login"` + Picture string `json:"avatar_url"` + Location string `json:"location"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Name = u.Name + user.NickName = u.Login + user.Email = u.Email + user.Description = u.Bio + user.AvatarURL = u.Picture + user.UserID = strconv.Itoa(u.ID) + user.Location = u.Location + + return err +} + +func getPrivateMail(p *Provider, sess *Session) (email string, err error) { + req, err := http.NewRequest("GET", p.emailURL, nil) + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + if response != nil { + response.Body.Close() + } + return email, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return email, fmt.Errorf("GitHub API responded with a %d trying to fetch user email", response.StatusCode) + } + + var mailList []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + err = json.NewDecoder(response.Body).Decode(&mailList) + if err != nil { + return email, err + } + for _, v := range mailList { + if v.Primary && v.Verified { + return v.Email, nil + } + } + return email, ErrNoVerifiedGitHubPrimaryEmail +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + + return c +} + +// RefreshToken refresh token is not provided by github +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by github") +} + +// RefreshTokenAvailable refresh token is not provided by github +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/github/github_test.go b/providers/github/github_test.go new file mode 100644 index 000000000..3bf9289b1 --- /dev/null +++ b/providers/github/github_test.go @@ -0,0 +1,73 @@ +package github_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/github" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := githubProvider() + a.Equal(provider.ClientKey, os.Getenv("GITHUB_KEY")) + a.Equal(provider.Secret, os.Getenv("GITHUB_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*github.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), githubProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := githubProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*github.Session) + a.NoError(err) + a.Contains(s.AuthURL, "github.com/login/oauth/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GITHUB_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=user") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := githubProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://github.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*github.Session) + a.Equal(session.AuthURL, "http://github.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func githubProvider() *github.Provider { + return github.New(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "/foo", "user") +} + +func urlCustomisedURLProvider() *github.Provider { + return github.NewCustomisedURL(os.Getenv("GITHUB_KEY"), os.Getenv("GITHUB_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL", "http://emailURL") +} diff --git a/providers/github/session.go b/providers/github/session.go new file mode 100644 index 000000000..cd19e8705 --- /dev/null +++ b/providers/github/session.go @@ -0,0 +1,56 @@ +package github + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with GitHub. +type Session struct { + AuthURL string + AccessToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the GitHub provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with GitHub and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/github/session_test.go b/providers/github/session_test.go new file mode 100644 index 000000000..5241f9d43 --- /dev/null +++ b/providers/github/session_test.go @@ -0,0 +1,48 @@ +package github_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/github" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &github.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &github.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &github.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &github.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/gitlab/gitlab.go b/providers/gitlab/gitlab.go new file mode 100644 index 000000000..e3561eb8b --- /dev/null +++ b/providers/gitlab/gitlab.go @@ -0,0 +1,187 @@ +// Package gitlab implements the OAuth2 protocol for authenticating users through gitlab. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package gitlab + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the Authentication, Token, and Profile URLS for Gitlab. If +// using Gitlab CE or EE, you should change these values before calling New. +// +// Examples: +// +// gitlab.AuthURL = "https://gitlab.acme.com/oauth/authorize +// gitlab.TokenURL = "https://gitlab.acme.com/oauth/token +// gitlab.ProfileURL = "https://gitlab.acme.com/api/v3/user +var ( + AuthURL = "https://gitlab.com/oauth/authorize" + TokenURL = "https://gitlab.com/oauth/token" + ProfileURL = "https://gitlab.com/api/v3/user" +) + +// Provider is the implementation of `goth.Provider` for accessing Gitlab. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + authURL string + tokenURL string + profileURL string +} + +// New creates a new Gitlab provider and sets up important connection details. +// You should always call `gitlab.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "gitlab", + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the gitlab package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Gitlab for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Gitlab and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(p.profileURL + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Email string `json:"email"` + NickName string `json:"username"` + ID int `json:"id"` + AvatarURL string `json:"avatar_url"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.NickName = u.NickName + user.UserID = strconv.Itoa(u.ID) + user.AvatarURL = u.AvatarURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/gitlab/gitlab_test.go b/providers/gitlab/gitlab_test.go new file mode 100644 index 000000000..9d0cebf69 --- /dev/null +++ b/providers/gitlab/gitlab_test.go @@ -0,0 +1,67 @@ +package gitlab_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/gitlab" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("GITLAB_KEY")) + a.Equal(p.Secret, os.Getenv("GITLAB_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*gitlab.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*gitlab.Session) + a.NoError(err) + a.Contains(s.AuthURL, "gitlab.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://gitlab.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*gitlab.Session) + a.Equal(s.AuthURL, "https://gitlab.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *gitlab.Provider { + return gitlab.New(os.Getenv("GITLAB_KEY"), os.Getenv("GITLAB_SECRET"), "/foo") +} + +func urlCustomisedURLProvider() *gitlab.Provider { + return gitlab.NewCustomisedURL(os.Getenv("GITLAB_KEY"), os.Getenv("GITLAB_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") +} diff --git a/providers/gitlab/session.go b/providers/gitlab/session.go new file mode 100644 index 000000000..a2f90647c --- /dev/null +++ b/providers/gitlab/session.go @@ -0,0 +1,63 @@ +package gitlab + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Gitlab. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Gitlab provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Gitlab and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/gitlab/session_test.go b/providers/gitlab/session_test.go new file mode 100644 index 000000000..23682d2e2 --- /dev/null +++ b/providers/gitlab/session_test.go @@ -0,0 +1,48 @@ +package gitlab_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/gitlab" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitlab.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitlab.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitlab.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &gitlab.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/google/endpoint.go b/providers/google/endpoint.go new file mode 100644 index 000000000..9e3a7e353 --- /dev/null +++ b/providers/google/endpoint.go @@ -0,0 +1,11 @@ +//go:build go1.9 +// +build go1.9 + +package google + +import ( + goog "golang.org/x/oauth2/google" +) + +// Endpoint is Google's OAuth 2.0 endpoint. +var Endpoint = goog.Endpoint diff --git a/providers/google/endpoint_legacy.go b/providers/google/endpoint_legacy.go new file mode 100644 index 000000000..9dcc4360e --- /dev/null +++ b/providers/google/endpoint_legacy.go @@ -0,0 +1,14 @@ +//go:build !go1.9 +// +build !go1.9 + +package google + +import ( + "golang.org/x/oauth2" +) + +// Endpoint is Google's OAuth 2.0 endpoint. +var Endpoint = oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", +} diff --git a/providers/google/google.go b/providers/google/google.go new file mode 100644 index 000000000..ca0695463 --- /dev/null +++ b/providers/google/google.go @@ -0,0 +1,223 @@ +// Package google implements the OAuth2 protocol for authenticating users +// through Google. +package google + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const endpointProfile string = "https://openidconnect.googleapis.com/v1/userinfo" + +// New creates a new Google provider, and sets up important connection details. +// You should always call `google.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "google", + + // We can get a refresh token from Google by this option. + // See https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param + authCodeOptions: []oauth2.AuthCodeOption{ + oauth2.AccessTypeOffline, + }, + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Google. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + authCodeOptions []oauth2.AuthCodeOption + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns an HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the google package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Google for an authentication endpoint. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state, p.authCodeOptions...) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +type googleUser struct { + ID string `json:"id"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Link string `json:"link"` + Picture string `json:"picture"` +} + +// FetchUser will go to Google and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + IDToken: sess.IDToken, + } + + if user.AccessToken == "" { + // Data is not yet retrieved, since accessToken is still empty. + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + var u googleUser + if err := json.Unmarshal(responseBytes, &u); err != nil { + return user, err + } + + // Extract the user data we got from Google into our goth.User. + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Name + user.Email = u.Email + user.AvatarURL = u.Picture + + if u.ID != "" { + user.UserID = u.ID + } else { + user.UserID = u.Sub + } + + // Google provides other useful fields such as 'hd'; get them from RawData + if err := json.Unmarshal(responseBytes, &user.RawData); err != nil { + return user, err + } + + return user, nil +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: Endpoint, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } else { + c.Scopes = []string{"openid", "email", "profile"} + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// SetPrompt sets the prompt values for the google OAuth call. Use this to +// force users to choose and account every time by passing "select_account", +// for example. +// See https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters +func (p *Provider) SetPrompt(prompt ...string) { + if len(prompt) == 0 { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("prompt", strings.Join(prompt, " "))) +} + +// SetHostedDomain sets the hd parameter for google OAuth call. +// Use this to force user to pick user from specific hosted domain. +// See https://developers.google.com/identity/protocols/oauth2/openid-connect#hd-param +func (p *Provider) SetHostedDomain(hd string) { + if hd == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("hd", hd)) +} + +// SetLoginHint sets the login_hint parameter for the Google OAuth call. +// Use this to prompt the user to log in with a specific account. +// See https://developers.google.com/identity/protocols/oauth2/openid-connect#login-hint +func (p *Provider) SetLoginHint(loginHint string) { + if loginHint == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("login_hint", loginHint)) +} + +// SetAccessType sets the access_type parameter for the Google OAuth call. +// If an access token is being requested, the client does not receive a refresh token unless a value of offline is specified. +// See https://developers.google.com/identity/protocols/oauth2/openid-connect#access-type-param +func (p *Provider) SetAccessType(at string) { + if at == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("access_type", at)) +} diff --git a/providers/google/google_test.go b/providers/google/google_test.go new file mode 100644 index 000000000..20aa1081c --- /dev/null +++ b/providers/google/google_test.go @@ -0,0 +1,152 @@ +package google_test + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/google" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + a.Equal(provider.ClientKey, os.Getenv("GOOGLE_KEY")) + a.Equal(provider.Secret, os.Getenv("GOOGLE_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*google.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=openid+email+profile") + a.Contains(s.AuthURL, "access_type=offline") +} + +func Test_BeginAuthWithPrompt(t *testing.T) { + // This exists because there was a panic caused by the oauth2 package when + // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does + // not, to ensure both cases are covered. + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + provider.SetPrompt("test", "prompts") + session, err := provider.BeginAuth("test_state") + s := session.(*google.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=openid+email+profile") + a.Contains(s.AuthURL, "access_type=offline") + a.Contains(s.AuthURL, "prompt=test+prompts") +} + +func Test_BeginAuthWithHostedDomain(t *testing.T) { + // This exists because there was a panic caused by the oauth2 package when + // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does + // not, to ensure both cases are covered. + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + provider.SetHostedDomain("example.com") + session, err := provider.BeginAuth("test_state") + s := session.(*google.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=openid+email+profile") + a.Contains(s.AuthURL, "access_type=offline") + a.Contains(s.AuthURL, "hd=example.com") +} + +func Test_BeginAuthWithLoginHint(t *testing.T) { + // This exists because there was a panic caused by the oauth2 package when + // the AuthCodeOption passed was nil. This test uses it, Test_BeginAuth does + // not, to ensure both cases are covered. + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + provider.SetLoginHint("john@example.com") + session, err := provider.BeginAuth("test_state") + s := session.(*google.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.google.com/o/oauth2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("GOOGLE_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=openid+email+profile") + a.Contains(s.AuthURL, "access_type=offline") + a.Contains(s.AuthURL, "login_hint=john%40example.com") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), googleProvider()) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := googleProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://accounts.google.com/o/oauth2/auth","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*google.Session) + a.Equal(session.AuthURL, "https://accounts.google.com/o/oauth2/auth") + a.Equal(session.AccessToken, "1234567890") +} + +func Test_UserIDHandling(t *testing.T) { + t.Parallel() + a := assert.New(t) + + // Test v2 endpoint response format (uses 'id' field) + v2Response := `{"id":"123456789","email":"test@example.com","name":"Test User"}` + var userV2 struct { + ID string `json:"id"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + err := json.Unmarshal([]byte(v2Response), &userV2) + a.NoError(err) + a.Equal("123456789", userV2.ID) + a.Equal("", userV2.Sub) + + // Test OpenID Connect response format (uses 'sub' field) + oidcResponse := `{"sub":"123456789","email":"test@example.com","name":"Test User"}` + var userOIDC struct { + ID string `json:"id"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + err = json.Unmarshal([]byte(oidcResponse), &userOIDC) + a.NoError(err) + a.Equal("", userOIDC.ID) + a.Equal("123456789", userOIDC.Sub) +} + +func googleProvider() *google.Provider { + return google.New(os.Getenv("GOOGLE_KEY"), os.Getenv("GOOGEL_SECRET"), "/foo") +} diff --git a/providers/google/session.go b/providers/google/session.go new file mode 100644 index 000000000..0206dfa5a --- /dev/null +++ b/providers/google/session.go @@ -0,0 +1,65 @@ +package google + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Google. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + IDToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Google and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + if idToken := token.Extra("id_token"); idToken != nil { + s.IDToken = idToken.(string) + } + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/google/session_test.go b/providers/google/session_test.go new file mode 100644 index 000000000..30cef9565 --- /dev/null +++ b/providers/google/session_test.go @@ -0,0 +1,48 @@ +package google_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/google" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &google.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &google.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &google.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &google.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/heroku/heroku.go b/providers/heroku/heroku.go new file mode 100644 index 000000000..3df805463 --- /dev/null +++ b/providers/heroku/heroku.go @@ -0,0 +1,157 @@ +// Package heroku implements the OAuth2 protocol for authenticating users through heroku. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package heroku + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://id.heroku.com/oauth/authorize" + tokenURL string = "https://id.heroku.com/oauth/token" + endpointProfile string = "https://api.heroku.com/account" +) + +// Provider is the implementation of `goth.Provider` for accessing Heroku. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Heroku provider and sets up important connection details. +// You should always call `heroku.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "heroku", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the heroku package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Heroku for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Heroku and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + req.Header.Set("Accept", "application/vnd.heroku+json; version=3") + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Email string `json:"email"` + ID string `json:"id"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.UserID = u.ID + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/heroku/heroku_test.go b/providers/heroku/heroku_test.go new file mode 100644 index 000000000..e50b846fd --- /dev/null +++ b/providers/heroku/heroku_test.go @@ -0,0 +1,53 @@ +package heroku_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/heroku" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("HEROKU_KEY")) + a.Equal(p.Secret, os.Getenv("HEROKU_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*heroku.Session) + a.NoError(err) + a.Contains(s.AuthURL, "id.heroku.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://id.heroku.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*heroku.Session) + a.Equal(s.AuthURL, "https://id.heroku.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *heroku.Provider { + return heroku.New(os.Getenv("HEROKU_KEY"), os.Getenv("HEROKU_SECRET"), "/foo") +} diff --git a/providers/heroku/session.go b/providers/heroku/session.go new file mode 100644 index 000000000..2cd9ec9bc --- /dev/null +++ b/providers/heroku/session.go @@ -0,0 +1,63 @@ +package heroku + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Heroku. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Heroku provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Heroku and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/heroku/session_test.go b/providers/heroku/session_test.go new file mode 100644 index 000000000..abaf50e97 --- /dev/null +++ b/providers/heroku/session_test.go @@ -0,0 +1,48 @@ +package heroku_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/heroku" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &heroku.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &heroku.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &heroku.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &heroku.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/hubspot/hubspot.go b/providers/hubspot/hubspot.go new file mode 100644 index 000000000..36b5dd843 --- /dev/null +++ b/providers/hubspot/hubspot.go @@ -0,0 +1,174 @@ +package hubspot + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the Authentication and Token URLS for Hubspot. +var ( + AuthURL = "https://app.hubspot.com/oauth/authorize" + TokenURL = "https://api.hubapi.com/oauth/v1/token" +) + +const ( + userEndpoint = "https://api.hubapi.com/oauth/v1/access-tokens/" +) + +type hubspotUser struct { + Token string `json:"token"` + User string `json:"user"` + HubDomain string `json:"hub_domain"` + Scopes []string `json:"scopes"` + ScopeToScopeGroupPKs []int `json:"scope_to_scope_group_pks"` + TrialScopes []string `json:"trial_scopes"` + TrialScopeToScopeGroupPKs []int `json:"trial_scope_to_scope_group_pks"` + HubID int `json:"hub_id"` + AppID int `json:"app_id"` + ExpiresIn int `json:"expires_in"` + UserID int `json:"user_id"` + TokenType string `json:"token_type"` +} + +// Provider is the implementation of `goth.Provider` for accessing Hubspot. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Hubspot provider and sets up important connection details. +// You should always call `hubspot.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "hubspot", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the hubspot package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Hubspot for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Hubspot and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(userEndpoint + url.QueryEscape(user.AccessToken)) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + var u hubspotUser + if err := json.Unmarshal(responseBytes, &u); err != nil { + return user, err + } + + // Extract the user data we got from Google into our goth.User. + user.Email = u.User + user.UserID = strconv.Itoa(u.UserID) + accessTokenExpiration := time.Now() + if u.ExpiresIn > 0 { + accessTokenExpiration = accessTokenExpiration.Add(time.Duration(u.ExpiresIn) * time.Second) + } else { + accessTokenExpiration = accessTokenExpiration.Add(30 * time.Minute) + } + user.ExpiresAt = accessTokenExpiration + // Google provides other useful fields such as 'hd'; get them from RawData + if err := json.Unmarshal(responseBytes, &user.RawData); err != nil { + return user, err + } + + return user, nil +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: AuthURL, + TokenURL: TokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/hubspot/hubspot_test.go b/providers/hubspot/hubspot_test.go new file mode 100644 index 000000000..c8ecb6ae5 --- /dev/null +++ b/providers/hubspot/hubspot_test.go @@ -0,0 +1,53 @@ +package hubspot_test + +import ( + "github.com/markbates/goth/providers/hubspot" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("HUBSPOT_KEY")) + a.Equal(p.Secret, os.Getenv("HUBSPOT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*hubspot.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://app.hubspot.com/oauth/authoriz") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://app.hubspot.com/oauth/authoriz","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*hubspot.Session) + a.Equal(s.AuthURL, "https://app.hubspot.com/oauth/authoriz") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *hubspot.Provider { + return hubspot.New(os.Getenv("HUBSPOT_KEY"), os.Getenv("HUBSPOT_SECRET"), "/foo") +} diff --git a/providers/hubspot/session.go b/providers/hubspot/session.go new file mode 100644 index 000000000..8cb3361f4 --- /dev/null +++ b/providers/hubspot/session.go @@ -0,0 +1,60 @@ +package hubspot + +import ( + "encoding/json" + "errors" + "github.com/markbates/goth" + "strings" +) + +// Session stores data during the auth process with Hubspot. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Hubspot provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Hubspot and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/hubspot/session_test.go b/providers/hubspot/session_test.go new file mode 100644 index 000000000..bdc6f08d0 --- /dev/null +++ b/providers/hubspot/session_test.go @@ -0,0 +1,48 @@ +package hubspot_test + +import ( + "github.com/markbates/goth/providers/hubspot" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &hubspot.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/influxcloud/influxcloud.go b/providers/influxcloud/influxcloud.go new file mode 100644 index 000000000..220cb103c --- /dev/null +++ b/providers/influxcloud/influxcloud.go @@ -0,0 +1,180 @@ +// Package influxdata implements the OAuth2 protocol for authenticating users through InfluxCloud. +// It is based off of the github implementation. +package influxcloud + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + // The hard coded domain is difficult here because influx cloud has an acceptance + // domain that is different, and we will need that for enterprise development. + defaultDomain string = "cloud.influxdata.com" + userAPIPath string = "/api/v1/user" + domainEnvKey string = "INFLUXCLOUD_OAUTH_DOMAIN" + authPath string = "/oauth/authorize" + tokenPath string = "/oauth/token" +) + +// New creates a new influx provider, and sets up important connection details. +// You should always call `influxcloud.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + domain := os.Getenv(domainEnvKey) + if domain == "" { + domain = defaultDomain + } + tokenURL := fmt.Sprintf("https://%s%s", domain, tokenPath) + authURL := fmt.Sprintf("https://%s%s", domain, authPath) + userAPIEndpoint := fmt.Sprintf("https://%s%s", domain, userAPIPath) + + return NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, userAPIEndpoint, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, userAPIEndpoint string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + UserAPIEndpoint: userAPIEndpoint, + Config: &oauth2.Config{ + ClientID: clientKey, + ClientSecret: secret, + RedirectURL: callbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: scopes, + }, + providerName: "influxcloud", + } + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Influx. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + UserAPIEndpoint string + HTTPClient *http.Client + Config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the influxcloud package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Influx for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.Config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Influx and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(p.UserAPIEndpoint + "?access_token=" + url.QueryEscape(sess.AccessToken)) + + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID int `json:"id"` + Email string `json:"email"` + Bio string `json:"bio"` + Name string `json:"name"` + Login string `json:"login"` + Picture string `json:"avatar_url"` + Location string `json:"location"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Name = u.Name + user.NickName = u.Login + user.Email = u.Email + user.Description = u.Bio + user.AvatarURL = u.Picture + user.UserID = strconv.Itoa(u.ID) + user.Location = u.Location + + return err +} + +// RefreshToken refresh token is not provided by influxcloud +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by influxcloud") +} + +// RefreshTokenAvailable refresh token is not provided by influxcloud +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/influxcloud/influxcloud_test.go b/providers/influxcloud/influxcloud_test.go new file mode 100644 index 000000000..e50d00aa6 --- /dev/null +++ b/providers/influxcloud/influxcloud_test.go @@ -0,0 +1,89 @@ +package influxcloud + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := influxcloudProvider() + a.Equal(provider.ClientKey, "testkey") + a.Equal(provider.Secret, "testsecret") + a.Equal(provider.CallbackURL, "/callback") + a.Equal(provider.UserAPIEndpoint, "https://cloud.influxdata.com/api/v1/user") +} + +func TestNewConfigDefaults(t *testing.T) { + t.Parallel() + a := assert.New(t) + config := influxcloudProvider().Config + a.NotNil(config) + a.Equal("testkey", config.ClientID) + a.Equal("testsecret", config.ClientSecret) + a.Equal("https://cloud.influxdata.com/oauth/authorize", config.Endpoint.AuthURL) + a.Equal("https://cloud.influxdata.com/oauth/token", config.Endpoint.TokenURL) + a.Equal("/callback", config.RedirectURL) + a.Equal("userscope", config.Scopes[0]) + a.Equal("adminscope", config.Scopes[1]) + a.Equal(2, len(config.Scopes)) +} + +func TestUrlsConfigurableWithEnvVars(t *testing.T) { + oldEnvVar := os.Getenv(domainEnvKey) + defer os.Setenv(domainEnvKey, oldEnvVar) + + a := assert.New(t) + os.Setenv(domainEnvKey, "example.com") + p := influxcloudProvider() + a.Equal("https://example.com/api/v1/user", p.UserAPIEndpoint) + c := p.Config + a.Equal("https://example.com/oauth/authorize", c.Endpoint.AuthURL) + a.Equal("https://example.com/oauth/token", c.Endpoint.TokenURL) +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), influxcloudProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := influxcloudProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + // FIXME: we really need to be able to run this against the acceptance server, too. + // How should we do this? Maybe a test envvar switch? + a.Contains(s.AuthURL, "cloud.influxdata.com/oauth/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("INFLUXCLOUD_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=user") +} +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := influxcloudProvider() + + // FIXME: What is this testing exactly? + s, err := provider.UnmarshalSession(`{"AuthURL":"http://github.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*Session) + a.Equal(session.AuthURL, "http://github.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func influxcloudProvider() *Provider { + return New("testkey", "testsecret", "/callback", "userscope", "adminscope") +} diff --git a/providers/influxcloud/session.go b/providers/influxcloud/session.go new file mode 100644 index 000000000..ce1420650 --- /dev/null +++ b/providers/influxcloud/session.go @@ -0,0 +1,58 @@ +package influxcloud + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Influxcloud. +type Session struct { + AuthURL string + AccessToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Influxcloud provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Influxcloud and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + + token, err := p.Config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/influxcloud/session_test.go b/providers/influxcloud/session_test.go new file mode 100644 index 000000000..bf7eed84c --- /dev/null +++ b/providers/influxcloud/session_test.go @@ -0,0 +1,48 @@ +package influxcloud_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/influxcloud" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &influxcloud.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &influxcloud.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &influxcloud.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &influxcloud.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/instagram/instagram.go b/providers/instagram/instagram.go new file mode 100644 index 000000000..0d1c9cc79 --- /dev/null +++ b/providers/instagram/instagram.go @@ -0,0 +1,173 @@ +// Package instagram implements the OAuth2 protocol for authenticating users through Instagram. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package instagram + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +var ( + authURL = "https://api.instagram.com/oauth/authorize/" + tokenURL = "https://api.instagram.com/oauth/access_token" + endPointProfile = "https://api.instagram.com/v1/users/self/" +) + +// New creates a new Instagram provider, and sets up important connection details. +// You should always call `instagram.New` to get a new Provider. Never try to craete +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "instagram", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Instagram +type Provider struct { + ClientKey string + Secret string + CallbackURL string + UserAgent string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug TODO +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Instagram for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Instagram and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(endPointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + Data struct { + ID string `json:"id"` + UserName string `json:"username"` + FullName string `json:"full_name"` + ProfilePicture string `json:"profile_picture"` + Bio string `json:"bio"` + Website string `json:"website"` + Counts struct { + Media int `json:"media"` + Follows int `json:"follows"` + FollowedBy int `json:"followed_by"` + } `json:"counts"` + } `json:"data"` + }{} + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + user.UserID = u.Data.ID + user.Name = u.Data.FullName + user.NickName = u.Data.UserName + user.AvatarURL = u.Data.ProfilePicture + user.Description = u.Data.Bio + return err +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ + "basic", + }, + } + defaultScopes := map[string]struct{}{ + "basic": {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshToken refresh token is not provided by instagram +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by instagram") +} + +// RefreshTokenAvailable refresh token is not provided by instagram +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/instagram/instagram_test.go b/providers/instagram/instagram_test.go new file mode 100644 index 000000000..c95b62ddd --- /dev/null +++ b/providers/instagram/instagram_test.go @@ -0,0 +1,56 @@ +package instagram_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/instagram" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := instagramProvider() + a.Equal(provider.ClientKey, os.Getenv("INSTAGRAM_KEY")) + a.Equal(provider.Secret, os.Getenv("INSTAGRAM_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), instagramProvider()) +} +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := instagramProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*instagram.Session) + a.NoError(err) + a.Contains(s.AuthURL, "api.instagram.com/oauth/authorize/") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("INSTAGRAM_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=basic") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := instagramProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://api.instagram.com/oauth/authorize/","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*instagram.Session) + a.Equal(session.AuthURL, "https://api.instagram.com/oauth/authorize/") + a.Equal(session.AccessToken, "1234567890") +} + +func instagramProvider() *instagram.Provider { + return instagram.New(os.Getenv("INSTAGRAM_KEY"), os.Getenv("INSTAGRAM_SECRET"), "/foo", "basic") +} diff --git a/providers/instagram/session.go b/providers/instagram/session.go new file mode 100644 index 000000000..f6cfffe9f --- /dev/null +++ b/providers/instagram/session.go @@ -0,0 +1,56 @@ +package instagram + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Instagram +type Session struct { + AuthURL string + AccessToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Instagram provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Instagram and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/instagram/session_test.go b/providers/instagram/session_test.go new file mode 100644 index 000000000..174e7c703 --- /dev/null +++ b/providers/instagram/session_test.go @@ -0,0 +1,48 @@ +package instagram_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/instagram" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &instagram.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &instagram.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &instagram.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &instagram.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/intercom/intercom.go b/providers/intercom/intercom.go new file mode 100644 index 000000000..4d2e27834 --- /dev/null +++ b/providers/intercom/intercom.go @@ -0,0 +1,181 @@ +// Package intercom implements the OAuth protocol for authenticating users through Intercom. +package intercom + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +var ( + authURL = "https://app.intercom.io/oauth" + tokenURL = "https://api.intercom.io/auth/eagle/token?client_secret=%s" + UserURL = "https://api.intercom.io/me" +) + +// New creates the new Intercom provider +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "intercom", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Intercom +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the intercom package +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Intercom for an authentication end-point +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will fetch basic information about Intercom admin +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + request, err := http.NewRequest("GET", UserURL, nil) + if err != nil { + return user, err + } + request.Header.Add("Accept", "application/json") + request.Header.Add("User-Agent", "goth-intercom") + request.SetBasicAuth(sess.AccessToken, "") + + response, err := p.Client().Do(request) + + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Link string `json:"link"` + EmailVerified bool `json:"email_verified"` + Avatar struct { + URL string `json:"image_url"` + } `json:"avatar"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.Name = u.Name + user.FirstName, user.LastName = splitName(u.Name) + user.Email = u.Email + user.AvatarURL = u.Avatar.URL + user.UserID = u.ID + + return err +} + +func splitName(name string) (string, string) { + nameSplit := strings.SplitN(name, " ", 2) + firstName := nameSplit[0] + + var lastName string + if len(nameSplit) == 2 { + lastName = nameSplit[1] + } + + return firstName, lastName +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: fmt.Sprintf(tokenURL, provider.Secret), + }, + } + + return c +} + +// RefreshToken refresh token is not provided by Intercom +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by Intercom") +} + +// RefreshTokenAvailable refresh token is not provided by Intercom +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/intercom/intercom_test.go b/providers/intercom/intercom_test.go new file mode 100644 index 000000000..2fa3e98e7 --- /dev/null +++ b/providers/intercom/intercom_test.go @@ -0,0 +1,143 @@ +package intercom_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gorilla/pat" + "github.com/markbates/goth" + "github.com/markbates/goth/providers/intercom" + "github.com/stretchr/testify/assert" +) + +type fetchUserPayload struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Link string `json:"link"` + EmailVerified bool `json:"email_verified"` + Avatar struct { + URL string `json:"image_url"` + } `json:"avatar"` +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := intercomProvider() + a.Equal(provider.ClientKey, os.Getenv("INTERCOM_KEY")) + a.Equal(provider.Secret, os.Getenv("INTERCOM_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), intercomProvider()) +} +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := intercomProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*intercom.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://app.intercom.io/oauth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("INTERCOM_KEY"))) + a.Contains(s.AuthURL, "state=test_state") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := intercomProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://app.intercom.io/oauth","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*intercom.Session) + a.Equal(session.AuthURL, "https://app.intercom.io/oauth") + a.Equal(session.AccessToken, "1234567890") +} + +func intercomProvider() *intercom.Provider { + return intercom.New(os.Getenv("INTERCOM_KEY"), os.Getenv("INTERCOM_SECRET"), "/foo", "basic") +} + +func Test_FetchUser(t *testing.T) { + a := assert.New(t) + + u := fetchUserPayload{} + u.ID = "1" + u.Email = "wash@serenity.now" + u.Name = "Hoban Washburne" + u.EmailVerified = true + u.Avatar.URL = "http://avatarURL" + + mockIntercomFetchUser(&u, func(ts *httptest.Server) { + provider := intercomProvider() + session := &intercom.Session{AccessToken: "token"} + + user, err := provider.FetchUser(session) + a.NoError(err) + + a.Equal("1", user.UserID) + a.Equal("wash@serenity.now", user.Email) + a.Equal("Hoban Washburne", user.Name) + a.Equal("Hoban", user.FirstName) + a.Equal("Washburne", user.LastName) + a.Equal("http://avatarURL", user.AvatarURL) + a.Equal(true, user.RawData["email_verified"]) + a.Equal("token", user.AccessToken) + }) +} + +func Test_FetchUnverifiedUser(t *testing.T) { + a := assert.New(t) + + u := fetchUserPayload{} + u.ID = "1" + u.Email = "wash@serenity.now" + u.Name = "Hoban Washburne" + u.EmailVerified = false + u.Avatar.URL = "http://avatarURL" + + mockIntercomFetchUser(&u, func(ts *httptest.Server) { + provider := intercomProvider() + session := &intercom.Session{AccessToken: "token"} + + user, err := provider.FetchUser(session) + a.NoError(err) + + a.Equal("1", user.UserID) + a.Equal("wash@serenity.now", user.Email) + a.Equal("Hoban Washburne", user.Name) + a.Equal("Hoban", user.FirstName) + a.Equal("Washburne", user.LastName) + a.Equal("http://avatarURL", user.AvatarURL) + a.Equal(false, user.RawData["email_verified"]) + a.Equal("token", user.AccessToken) + }) +} + +func mockIntercomFetchUser(fetchUserPayload *fetchUserPayload, f func(*httptest.Server)) { + p := pat.New() + p.Get("/me", func(res http.ResponseWriter, req *http.Request) { + json.NewEncoder(res).Encode(fetchUserPayload) + }) + ts := httptest.NewServer(p) + defer ts.Close() + + originalUserURL := intercom.UserURL + + intercom.UserURL = ts.URL + "/me" + + f(ts) + + intercom.UserURL = originalUserURL +} diff --git a/providers/intercom/session.go b/providers/intercom/session.go new file mode 100644 index 000000000..c7a954663 --- /dev/null +++ b/providers/intercom/session.go @@ -0,0 +1,60 @@ +package intercom + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with intercom. +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the intercom provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with intercom and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/intercom/session_test.go b/providers/intercom/session_test.go new file mode 100644 index 000000000..81c68d9b5 --- /dev/null +++ b/providers/intercom/session_test.go @@ -0,0 +1,48 @@ +package intercom_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/intercom" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &intercom.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &intercom.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &intercom.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &intercom.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/kakao/kakao.go b/providers/kakao/kakao.go new file mode 100644 index 000000000..15d97c43f --- /dev/null +++ b/providers/kakao/kakao.go @@ -0,0 +1,162 @@ +// Package kakao implements the OAuth2 protocol for authenticating users through kakao. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package kakao + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://kauth.kakao.com/oauth/authorize" + tokenURL string = "https://kauth.kakao.com/oauth/token" + endpointUser string = "https://kapi.kakao.com/v2/user/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Kakao. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Kakao provider and sets up important connection details. +// You should always call `kakao.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "kakao", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns a pointer to http.Client setting some client fallback. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the kakao package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks kakao for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to kakao and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // Get the userID, kakao needs userID in order to get user profile info + c := p.Client() + req, err := http.NewRequest("GET", endpointUser, nil) + if err != nil { + return user, err + } + + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + + response, err := c.Do(req) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + u := struct { + ID int `json:"id"` + Properties struct { + Nickname string `json:"nickname"` + ThumbnailImage string `json:"thumbnail_image"` + ProfileImage string `json:"profile_image"` + } `json:"properties"` + }{} + + if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { + return user, err + } + + id := strconv.Itoa(u.ID) + + user.NickName = u.Properties.Nickname + user.AvatarURL = u.Properties.ProfileImage + user.UserID = id + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} diff --git a/providers/kakao/kakao_test.go b/providers/kakao/kakao_test.go new file mode 100644 index 000000000..2221ab68f --- /dev/null +++ b/providers/kakao/kakao_test.go @@ -0,0 +1,53 @@ +package kakao_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/kakao" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("KAKAO_CLIENT_ID")) + a.Equal(p.Secret, os.Getenv("KAKAO_CLIENT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*kakao.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://kauth.kakao.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://kauth.kakao.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*kakao.Session) + a.Equal(s.AuthURL, "https://kauth.kakao.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *kakao.Provider { + return kakao.New(os.Getenv("KAKAO_CLIENT_ID"), os.Getenv("KAKAO_CLIENT_SECRET"), "/foo") +} diff --git a/providers/kakao/session.go b/providers/kakao/session.go new file mode 100644 index 000000000..3eb401e77 --- /dev/null +++ b/providers/kakao/session.go @@ -0,0 +1,65 @@ +package kakao + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Kakao. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Kakao provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Kakao and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + oauth2.RegisterBrokenAuthHeaderProvider(tokenURL) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/kakao/session_test.go b/providers/kakao/session_test.go new file mode 100644 index 000000000..90d3f3cd6 --- /dev/null +++ b/providers/kakao/session_test.go @@ -0,0 +1,48 @@ +package kakao_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/line" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + data := s.Marshal() + a.Equal(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`, data) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/lark/lark.go b/providers/lark/lark.go new file mode 100644 index 000000000..d9900b9ce --- /dev/null +++ b/providers/lark/lark.go @@ -0,0 +1,307 @@ +package lark + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + appAccessTokenURL string = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal/" // get app_access_token + + authURL string = "https://open.feishu.cn/open-apis/authen/v1/authorize" // obtain authorization code + tokenURL string = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token" // get user_access_token + refreshTokenURL string = "https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token" // refresh user_access_token + endpointProfile string = "https://open.feishu.cn/open-apis/authen/v1/user_info" // get user info +) + +// Lark is the implementation of `goth.Provider` for accessing Lark +type Lark interface { + GetAppAccessToken() error // get app access token +} + +// Provider is the implementation of `goth.Provider` for accessing Lark +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + + appAccessToken *appAccessToken +} + +// New creates a new Lark provider and sets up important connection details. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "lark", + appAccessToken: &appAccessToken{}, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } + return c +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +func (p *Provider) Name() string { + return p.providerName +} + +func (p *Provider) SetName(name string) { + p.providerName = name +} + +type appAccessToken struct { + Token string + ExpiresAt time.Time + rMutex sync.RWMutex +} + +type appAccessTokenReq struct { + AppID string `json:"app_id"` // 自建应用的 app_id + AppSecret string `json:"app_secret"` // 自建应用的 app_secret +} + +type appAccessTokenResp struct { + Code int `json:"code"` // 错误码 + Msg string `json:"msg"` // 错误信息 + AppAccessToken string `json:"app_access_token"` // 用于调用应用级接口的 app_access_token + Expire int64 `json:"expire"` // app_access_token 的过期时间 +} + +// GetAppAccessToken get lark app access token +func (p *Provider) GetAppAccessToken() error { + // get from cache app access token + p.appAccessToken.rMutex.RLock() + if time.Now().Before(p.appAccessToken.ExpiresAt) { + p.appAccessToken.rMutex.RUnlock() + return nil + } + p.appAccessToken.rMutex.RUnlock() + + reqBody, err := json.Marshal(&appAccessTokenReq{ + AppID: p.ClientKey, + AppSecret: p.Secret, + }) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, appAccessTokenURL, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("failed to create app access token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.Client().Do(req) + if err != nil { + return fmt.Errorf("failed to send app access token request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code while fetching app access token: %d", resp.StatusCode) + } + + tokenResp := new(appAccessTokenResp) + if err = json.NewDecoder(resp.Body).Decode(tokenResp); err != nil { + return fmt.Errorf("failed to decode app access token response: %w", err) + } + + if tokenResp.Code != 0 { + return fmt.Errorf("failed to get app access token: code:%v msg: %s", tokenResp.Code, tokenResp.Msg) + } + + // update local cache + expirationDuration := time.Duration(tokenResp.Expire) * time.Second + p.appAccessToken.rMutex.Lock() + p.appAccessToken.Token = tokenResp.AppAccessToken + p.appAccessToken.ExpiresAt = time.Now().Add(expirationDuration) + p.appAccessToken.rMutex.Unlock() + + return nil +} + +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + // build lark auth url + u, err := url.Parse(p.config.AuthCodeURL(state)) + if err != nil { + panic(err) + } + query := u.Query() + query.Del("response_type") + query.Del("client_id") + query.Add("app_id", p.ClientKey) + u.RawQuery = query.Encode() + + return &Session{ + AuthURL: u.String(), + }, nil +} + +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} + +func (p *Provider) Debug(b bool) { +} + +type getUserAccessTokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + Scope string `json:"scope"` +} + +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + if err := p.GetAppAccessToken(); err != nil { + return nil, fmt.Errorf("failed to get app access token: %w", err) + } + reqBody := strings.NewReader(`{"grant_type":"refresh_token","refresh_token":"` + refreshToken + `"}`) + + req, err := http.NewRequest(http.MethodPost, refreshTokenURL, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create refresh token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.appAccessToken.Token)) + + resp, err := p.Client().Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send refresh token request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code while refreshing token: %d", resp.StatusCode) + } + + var oauthResp commResponse[getUserAccessTokenResp] + err = json.NewDecoder(resp.Body).Decode(&oauthResp) + if err != nil { + return nil, fmt.Errorf("failed to decode refreshed token: %w", err) + } + if oauthResp.Code != 0 { + return nil, fmt.Errorf("failed to refresh token: code:%v msg: %s", oauthResp.Code, oauthResp.Msg) + } + + token := oauth2.Token{ + AccessToken: oauthResp.Data.AccessToken, + RefreshToken: oauthResp.Data.RefreshToken, + Expiry: time.Now().Add(time.Duration(oauthResp.Data.ExpiresIn) * time.Second), + } + + return &token, nil +} + +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +type commResponse[T any] struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data T `json:"data"` +} + +type larkUser struct { + OpenID string `json:"open_id"` + UnionID string `json:"union_id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Email string `json:"enterprise_email"` + AvatarURL string `json:"avatar_url"` + Mobile string `json:"mobile,omitempty"` +} + +// FetchUser will go to Lark and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, fmt.Errorf("%s failed to create request: %w", p.providerName, err) + } + req.Header.Set("Authorization", "Bearer "+user.AccessToken) + + resp, err := p.Client().Do(req) + if err != nil { + return user, fmt.Errorf("%s failed to get user information: %w", p.providerName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + responseBytes, err := io.ReadAll(resp.Body) + if err != nil { + return user, fmt.Errorf("failed to read response body: %w", err) + } + + var oauthResp commResponse[larkUser] + if err = json.Unmarshal(responseBytes, &oauthResp); err != nil { + return user, fmt.Errorf("failed to decode user info: %w", err) + } + if oauthResp.Code != 0 { + return user, fmt.Errorf("failed to get user info: code:%v msg: %s", oauthResp.Code, oauthResp.Msg) + } + + u := oauthResp.Data + user.UserID = u.UserID + user.Name = u.Name + user.Email = u.Email + user.AvatarURL = u.AvatarURL + user.NickName = u.Name + + if err = json.Unmarshal(responseBytes, &user.RawData); err != nil { + return user, err + } + return user, nil +} diff --git a/providers/lark/lark_test.go b/providers/lark/lark_test.go new file mode 100644 index 000000000..cda49e522 --- /dev/null +++ b/providers/lark/lark_test.go @@ -0,0 +1,185 @@ +package lark_test + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + "testing" + + "github.com/markbates/goth/providers/lark" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type MockedHTTPClient struct { + mock.Mock +} + +func (m *MockedHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { + args := m.Mock.Called(req) + return args.Get(0).(*http.Response), args.Error(1) +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := larkProvider() + + a.Equal(p.ClientKey, os.Getenv("LARK_APP_ID")) + a.Equal(p.Secret, os.Getenv("LARK_APP_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := larkProvider() + session, err := p.BeginAuth("test_state") + s := session.(*lark.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://open.feishu.cn/open-apis/authen/v1/authorize") + a.Contains(s.AuthURL, "app_id="+os.Getenv("LARK_APP_ID")) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, fmt.Sprintf("redirect_uri=%s", url.QueryEscape("/foo"))) +} + +func Test_GetAppAccessToken(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","app_access_token":"test_token","expire":3600}`)), + }, nil) + + err := p.GetAppAccessToken() + assert.NoError(t, err) + }) + + t.Run("error on request", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{}, errors.New("request error")) + + err := p.GetAppAccessToken() + assert.Error(t, err) + }) + + t.Run("non-200 status code", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusForbidden, + Body: ioutil.NopCloser(strings.NewReader(``)), + }, nil) + + err := p.GetAppAccessToken() + assert.Error(t, err) + }) + + t.Run("error on response decode", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`not a json`)), + }, nil) + + err := p.GetAppAccessToken() + assert.Error(t, err) + }) + + t.Run("error code in response", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`{"code":1,"msg":"error message"}`)), + }, nil) + + err := p.GetAppAccessToken() + assert.Error(t, err) + }) +} + +func Test_FetchUser(t *testing.T) { + session := &lark.Session{ + AccessToken: "user_access_token", + } + + t.Run("happy path", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","data":{"user_id":"test_user_id","name":"test_name","avatar_url":"test_avatar_url","enterprise_email":"test_email"}}`)), + }, nil) + user, err := p.FetchUser(session) + require.NoError(t, err) + assert.Equal(t, user.UserID, "test_user_id") + assert.Equal(t, user.Name, "test_name") + assert.Equal(t, user.AvatarURL, "test_avatar_url") + assert.Equal(t, user.Email, "test_email") + }) + t.Run("error on request", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{}, errors.New("request error")) + _, err := p.FetchUser(session) + require.Error(t, err) + }) + t.Run("non-200 status code", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusForbidden, + Body: ioutil.NopCloser(strings.NewReader(``)), + }, nil) + _, err := p.FetchUser(session) + require.Error(t, err) + }) + t.Run("error on response decode", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`not a json`)), + }, nil) + _, err := p.FetchUser(session) + require.Error(t, err) + }) + t.Run("error code in response", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`{"code":1,"msg":"error message"}`)), + }, nil) + _, err := p.FetchUser(session) + require.Error(t, err) + }) +} + +func larkProvider() *lark.Provider { + return lark.New(os.Getenv("LARK_APP_ID"), os.Getenv("LARK_APP_SECRET"), "/foo") +} diff --git a/providers/lark/session.go b/providers/lark/session.go new file mode 100644 index 000000000..2fdf260ce --- /dev/null +++ b/providers/lark/session.go @@ -0,0 +1,71 @@ +package lark + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/markbates/goth" +) + +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + RefreshTokenExpiresAt time.Time +} + +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New("lark: missing AuthURL") + } + return s.AuthURL, nil +} + +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + reqBody := strings.NewReader(`{"grant_type":"authorization_code","code":"` + params.Get("code") + `"}`) + req, err := http.NewRequest(http.MethodPost, tokenURL, reqBody) + if err != nil { + return "", fmt.Errorf("failed to create refresh token request: %w", err) + } + if err = p.GetAppAccessToken(); err != nil { + return "", fmt.Errorf("failed to get app access token: %w", err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", p.appAccessToken.Token)) + req.Header.Add("Content-Type", "application/json; charset=utf-8") + + resp, err := p.Client().Do(req) + if err != nil { + return "", fmt.Errorf("failed to send refresh token request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code while authorizing: %d", resp.StatusCode) + } + + var larkCommResp commResponse[getUserAccessTokenResp] + err = json.NewDecoder(resp.Body).Decode(&larkCommResp) + if err != nil { + return "", fmt.Errorf("failed to decode commResponse: %w", err) + } + if larkCommResp.Code != 0 { + return "", fmt.Errorf("failed to get accessToken: code:%v msg: %s", larkCommResp.Code, larkCommResp.Msg) + } + + s.AccessToken = larkCommResp.Data.AccessToken + s.RefreshToken = larkCommResp.Data.RefreshToken + s.ExpiresAt = time.Now().Add(time.Duration(larkCommResp.Data.ExpiresIn) * time.Second) + s.RefreshTokenExpiresAt = time.Now().Add(time.Duration(larkCommResp.Data.RefreshExpiresIn) * time.Second) + return s.AccessToken, nil +} diff --git a/providers/lark/session_test.go b/providers/lark/session_test.go new file mode 100644 index 000000000..59dc53f2b --- /dev/null +++ b/providers/lark/session_test.go @@ -0,0 +1,112 @@ +package lark_test + +import ( + "errors" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/lark" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type MockParams struct { + params map[string]string +} + +func (m *MockParams) Get(key string) string { + return m.params[key] +} + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &lark.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + session := &lark.Session{ + AuthURL: "https://auth.url", + } + url, err := session.GetAuthURL() + assert.NoError(t, err) + assert.Equal(t, "https://auth.url", url) + }) + + t.Run("missing AuthURL", func(t *testing.T) { + session := &lark.Session{} + _, err := session.GetAuthURL() + assert.Error(t, err) + }) +} + +func Test_Marshal(t *testing.T) { + session := &lark.Session{ + AuthURL: "https://auth.url", + AccessToken: "access_token", + } + marshaled := session.Marshal() + assert.Contains(t, marshaled, "https://auth.url") + assert.Contains(t, marshaled, "access_token") +} + +func Test_Authorize(t *testing.T) { + session := &lark.Session{} + params := &MockParams{ + params: map[string]string{ + "code": "authorization_code", + }, + } + + t.Run("error on request", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{}, errors.New("request error")) + _, err := session.Authorize(p, params) + require.Error(t, err) + }) + + t.Run("non-200 status code", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusForbidden, + Body: ioutil.NopCloser(strings.NewReader(``)), + }, nil) + _, err := session.Authorize(p, params) + require.Error(t, err) + }) + + t.Run("error on response decode", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`not a json`)), + }, nil) + _, err := session.Authorize(p, params) + require.Error(t, err) + }) + + t.Run("error code in response", func(t *testing.T) { + mockClient := new(MockedHTTPClient) + p := larkProvider() + p.HTTPClient = &http.Client{Transport: mockClient} + mockClient.On("RoundTrip", mock.Anything).Return(&http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(strings.NewReader(`{"code":1,"msg":"error message"}`)), + }, nil) + _, err := session.Authorize(p, params) + require.Error(t, err) + }) +} diff --git a/providers/lastfm/lastfm.go b/providers/lastfm/lastfm.go new file mode 100644 index 000000000..3d844455a --- /dev/null +++ b/providers/lastfm/lastfm.go @@ -0,0 +1,230 @@ +// Package lastfm implements the OAuth protocol for authenticating users through LastFM. +// This package can be used as a reference impleentation of an OAuth provider for Goth. +package lastfm + +import ( + "crypto/md5" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +var ( + authURL = "http://www.lastfm.com/api/auth" + endpointProfile = "http://ws.audioscrobbler.com/2.0/" +) + +// New creates a new LastFM provider, and sets up important connection details. +// You should always call `lastfm.New` to get a new Provider. Never try to craete +// one manullay. +func New(clientKey string, secret string, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "lastfm", + } + return p +} + +// Provider is the implementation of `goth.Provider` for accessing LastFM +type Provider struct { + ClientKey string + Secret string + CallbackURL string + UserAgent string + HTTPClient *http.Client + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the lastfm package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks LastFm for an authentication end-point +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + urlParams := url.Values{} + urlParams.Add("api_key", p.ClientKey) + urlParams.Add("cb", p.CallbackURL) + + session := &Session{ + AuthURL: authURL + "?" + urlParams.Encode(), + } + + return session, nil +} + +// FetchUser will go to LastFM and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s has no user information available (yet)", p.providerName) + } + + u := struct { + XMLName xml.Name `xml:"user"` + ID string `xml:"id"` + Name string `xml:"name"` + RealName string `xml:"realname"` + URL string `xml:"url"` + Country string `xml:"country"` + Age string `xml:"age"` + Gender string `xml:"gender"` + Subscriber string `xml:"subscriber"` + PlayCount string `xml:"playcount"` + Playlists string `xml:"playlists"` + Bootstrap string `xml:"bootstrap"` + Registered struct { + Unixtime string `xml:"unixtime,attr"` + Time string `xml:",chardata"` + } `xml:"registered"` + Images []struct { + Size string `xml:"size,attr"` + URL string `xml:",chardata"` + } `xml:"image"` + }{} + + login := session.(*Session).Login + err := p.request(false, map[string]string{"method": "user.getinfo", "user": login}, &u) + + if err == nil { + user.Name = u.RealName + user.NickName = u.Name + user.AvatarURL = u.Images[3].URL + user.UserID = u.ID + user.Location = u.Country + } + + return user, err +} + +// GetSession token from LastFM +func (p *Provider) GetSession(token string) (map[string]string, error) { + sess := struct { + Name string `xml:"name"` + Key string `xml:"key"` + Subscriber bool `xml:"subscriber"` + }{} + + err := p.request(true, map[string]string{"method": "auth.getSession", "token": token}, &sess) + return map[string]string{"login": sess.Name, "token": sess.Key}, err +} + +func (p *Provider) request(sign bool, params map[string]string, result interface{}) error { + urlParams := url.Values{} + urlParams.Add("method", params["method"]) + + params["api_key"] = p.ClientKey + for k, v := range params { + urlParams.Add(k, v) + } + + if sign { + urlParams.Add("api_sig", signRequest(p.Secret, params)) + } + + uri := endpointProfile + "?" + urlParams.Encode() + + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", p.UserAgent) + + res, err := p.Client().Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode/100 == 5 { // only 5xx class errros + err = errors.New(fmt.Errorf("Request error(%v) %v", res.StatusCode, res.Status).Error()) + return err + } + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + base := struct { + XMLName xml.Name `xml:"lfm"` + Status string `xml:"status,attr"` + Inner []byte `xml:",innerxml"` + }{} + + err = xml.Unmarshal(body, &base) + if err != nil { + return err + } + + if base.Status != "ok" { + errorDetail := struct { + Code int `xml:"code,attr"` + Message string `xml:",chardata"` + }{} + + err = xml.Unmarshal(base.Inner, &errorDetail) + if err != nil { + return err + } + + return errors.New(fmt.Errorf("Request Error(%v): %v", errorDetail.Code, errorDetail.Message).Error()) + } + + return xml.Unmarshal(base.Inner, result) +} + +func signRequest(secret string, params map[string]string) string { + var keys []string + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + var sigPlain string + for _, k := range keys { + sigPlain += k + params[k] + } + sigPlain += secret + + hasher := md5.New() + hasher.Write([]byte(sigPlain)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// RefreshToken refresh token is not provided by lastfm +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by lastfm") +} + +// RefreshTokenAvailable refresh token is not provided by lastfm +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/lastfm/lastfm_test.go b/providers/lastfm/lastfm_test.go new file mode 100644 index 000000000..5af851d6f --- /dev/null +++ b/providers/lastfm/lastfm_test.go @@ -0,0 +1,59 @@ +package lastfm + +import ( + "fmt" + "net/url" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := lastfmProvider() + a.Equal(provider.ClientKey, os.Getenv("LASTFM_KEY")) + a.Equal(provider.Secret, os.Getenv("LASTFM_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), lastfmProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := lastfmProvider() + session, err := provider.BeginAuth("") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.lastfm.com/api/auth") + a.Contains(s.AuthURL, fmt.Sprintf("api_key=%s", os.Getenv("LASTFM_KEY"))) + a.Contains(s.AuthURL, fmt.Sprintf("cb=%s", url.QueryEscape("/foo"))) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := lastfmProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":"123456", "Login":"Quin"}`) + a.NoError(err) + session := s.(*Session) + a.Equal(session.AuthURL, "http://com/auth_url") + a.Equal(session.AccessToken, "123456") + a.Equal(session.Login, "Quin") +} + +func lastfmProvider() *Provider { + return New(os.Getenv("LASTFM_KEY"), os.Getenv("LASTFM_SECRET"), "/foo") +} diff --git a/providers/lastfm/session.go b/providers/lastfm/session.go new file mode 100644 index 000000000..43cb70b43 --- /dev/null +++ b/providers/lastfm/session.go @@ -0,0 +1,54 @@ +package lastfm + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Lastfm. +type Session struct { + AuthURL string + AccessToken string + Login string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the LastFM provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with LastFM and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + sess, err := p.GetSession(params.Get("token")) + if err != nil { + return "", err + } + + s.AccessToken = sess["token"] + s.Login = sess["login"] + return sess["token"], err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/lastfm/session_test.go b/providers/lastfm/session_test.go new file mode 100644 index 000000000..f90ec61c3 --- /dev/null +++ b/providers/lastfm/session_test.go @@ -0,0 +1,47 @@ +package lastfm + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","Login":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/line/line.go b/providers/line/line.go new file mode 100644 index 000000000..00f689a30 --- /dev/null +++ b/providers/line/line.go @@ -0,0 +1,196 @@ +// Package line implements the OAuth2 protocol for authenticating users through line. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package line + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/golang-jwt/jwt/v5" + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://access.line.me/oauth2/v2.1/authorize" + tokenURL string = "https://api.line.me/oauth2/v2.1/token" + endpointUser string = "https://api.line.me/v2/profile" + issuerURL string = "https://access.line.me" +) + +type IDTokenClaims struct { + jwt.RegisteredClaims + Email string `json:"email"` +} + +// Provider is the implementation of `goth.Provider` for accessing Line.me. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + authCodeOptions []oauth2.AuthCodeOption + providerName string +} + +// New creates a new Line provider and sets up important connection details. +// You should always call `line.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "line", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns a pointer to http.Client setting some client fallback. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the line package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks line.me for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state, p.authCodeOptions...), + }, nil +} + +// FetchUser will go to line.me and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // Get the userID, line needs userID in order to get user profile info + c := p.Client() + req, err := http.NewRequest("GET", endpointUser, nil) + if err != nil { + return user, err + } + + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + + response, err := c.Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + u := struct { + Name string `json:"name"` + UserID string `json:"userId"` + DisplayName string `json:"displayName"` + PictureURL string `json:"pictureUrl"` + StatusMessage string `json:"statusMessage"` + }{} + + if err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u); err != nil { + return user, err + } + + user.NickName = u.DisplayName + user.AvatarURL = u.PictureURL + user.UserID = u.UserID + + if sess.IDToken != "" { + if err = p.addDataFromIdToken(sess.IDToken, &user); err != nil { + return user, err + } + } + + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = append(c.Scopes, scopes...) + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} + +// SetBotPrompt sets the bot_prompt parameter for the line OAuth call. +// Use this to display the option to add your LINE Official Account as a friend. +// See https://developers.line.biz/en/docs/line-login/link-a-bot/#redirect-users +func (p *Provider) SetBotPrompt(botPrompt string) { + if botPrompt == "" { + return + } + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam("bot_prompt", botPrompt)) +} + +func (p *Provider) addDataFromIdToken(idToken string, user *goth.User) error { + token, err := jwt.ParseWithClaims(idToken, &IDTokenClaims{}, func(t *jwt.Token) (interface{}, error) { + return []byte(p.Secret), nil + }, + jwt.WithAudience(p.ClientKey), + jwt.WithIssuer(issuerURL), + jwt.WithExpirationRequired(), + ) + if err != nil { + return err + } + + user.Email = token.Claims.(*IDTokenClaims).Email + + return nil +} diff --git a/providers/line/line_test.go b/providers/line/line_test.go new file mode 100644 index 000000000..832e2afd4 --- /dev/null +++ b/providers/line/line_test.go @@ -0,0 +1,65 @@ +package line_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/line" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("LINE_CLIENT_ID")) + a.Equal(p.Secret, os.Getenv("LINE_CLIENT_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*line.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://access.line.me/oauth2/v2.1/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://access.line.me/oauth2/v2.1/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*line.Session) + a.Equal(s.AuthURL, "https://access.line.me/oauth2/v2.1/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func Test_SetBotPrompt(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + p.SetBotPrompt("normal") + session, err := p.BeginAuth("test_state") + s := session.(*line.Session) + a.NoError(err) + a.Contains(s.AuthURL, "bot_prompt=normal") +} + +func provider() *line.Provider { + return line.New(os.Getenv("LINE_CLIENT_ID"), os.Getenv("LINE_CLIENT_SECRET"), "/foo") +} diff --git a/providers/line/session.go b/providers/line/session.go new file mode 100644 index 000000000..0213bc15f --- /dev/null +++ b/providers/line/session.go @@ -0,0 +1,69 @@ +package line + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Line. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + IDToken string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Line provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Line and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + + if idToken := token.Extra("id_token"); idToken != nil { + s.IDToken = idToken.(string) + } + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/line/session_test.go b/providers/line/session_test.go new file mode 100644 index 000000000..827565d42 --- /dev/null +++ b/providers/line/session_test.go @@ -0,0 +1,48 @@ +package line_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/line" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &line.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/linkedin/linkedin.go b/providers/linkedin/linkedin.go new file mode 100644 index 000000000..5719911d7 --- /dev/null +++ b/providers/linkedin/linkedin.go @@ -0,0 +1,278 @@ +// Package linkedin implements the OAuth2 protocol for authenticating users through Linkedin. +package linkedin + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// more details about linkedin fields: +// User Profile and Email Address - https://docs.microsoft.com/en-gb/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin +// User Avatar - https://docs.microsoft.com/en-gb/linkedin/shared/references/v2/digital-media-asset + +const ( + authURL string = "https://www.linkedin.com/oauth/v2/authorization" + tokenURL string = "https://www.linkedin.com/oauth/v2/accessToken" + + // userEndpoint requires scope "r_liteprofile" + userEndpoint string = "//api.linkedin.com/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~:playableStreams))" + // emailEndpoint requires scope "r_emailaddress" + emailEndpoint string = "//api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))" +) + +// New creates a new linkedin provider, and sets up important connection details. +// You should always call `linkedin.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "linkedin", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Linkedin. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns an HTTPClientWithFallback +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the linkedin package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Linkedin for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Linkedin and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // create request for user r_liteprofile + req, err := http.NewRequest("GET", "", nil) + if err != nil { + return user, err + } + + // add url as opaque to avoid escaping of "(" + req.URL = &url.URL{ + Scheme: "https", + Host: "api.linkedin.com", + Opaque: userEndpoint, + } + + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user profile", p.providerName, resp.StatusCode) + } + + // read r_liteprofile information + err = userFromReader(resp.Body, &user) + if err != nil { + return user, err + } + + // create request for user r_emailaddress + reqEmail, err := http.NewRequest("GET", "", nil) + if err != nil { + return user, err + } + + // add url as opaque to avoid escaping of "(" + reqEmail.URL = &url.URL{ + Scheme: "https", + Host: "api.linkedin.com", + Opaque: emailEndpoint, + } + + reqEmail.Header.Set("Authorization", "Bearer "+s.AccessToken) + respEmail, err := p.Client().Do(reqEmail) + if err != nil { + return user, err + } + defer respEmail.Body.Close() + + if respEmail.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user email", p.providerName, respEmail.StatusCode) + } + + // read r_emailaddress information + err = emailFromReader(respEmail.Body, &user) + + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID string `json:"id"` + FirstName struct { + PreferredLocale struct { + Country string `json:"country"` + Language string `json:"language"` + } `json:"preferredLocale"` + Localized map[string]string `json:"localized"` + } `json:"firstName"` + LastName struct { + Localized map[string]string + PreferredLocale struct { + Country string `json:"country"` + Language string `json:"language"` + } `json:"preferredLocale"` + } `json:"lastName"` + ProfilePicture struct { + DisplayImage struct { + Elements []struct { + AuthorizationMethod string `json:"authorizationMethod"` + Identifiers []struct { + Identifier string `json:"identifier"` + IdentifierType string `json:"identifierType"` + } `json:"identifiers"` + } `json:"elements"` + } `json:"displayImage~"` + } `json:"profilePicture"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.FirstName = u.FirstName.Localized[u.FirstName.PreferredLocale.Language+"_"+u.FirstName.PreferredLocale.Country] + user.LastName = u.LastName.Localized[u.LastName.PreferredLocale.Language+"_"+u.LastName.PreferredLocale.Country] + user.Name = user.FirstName + " " + user.LastName + user.NickName = user.FirstName + user.UserID = u.ID + + avatarURL := "" + // loop all displayimage elements + for _, element := range u.ProfilePicture.DisplayImage.Elements { + // only retrieve data where the authorization method allows public (unauthorized) access + if element.AuthorizationMethod == "PUBLIC" { + for _, identifier := range element.Identifiers { + // check to ensure the identifier type is a url linking to the image + if identifier.IdentifierType == "EXTERNAL_URL" { + avatarURL = identifier.Identifier + // we only need the first image url + break + } + } + } + // if we have a valid image, exit the loop as we only support a single avatar image + if len(avatarURL) > 0 { + break + } + } + + user.AvatarURL = avatarURL + + return err +} + +func emailFromReader(reader io.Reader, user *goth.User) error { + e := struct { + Elements []struct { + Handle struct { + EmailAddress string `json:"emailAddress"` + } `json:"handle~"` + } `json:"elements"` + }{} + + err := json.NewDecoder(reader).Decode(&e) + if err != nil { + return err + } + + if len(e.Elements) > 0 { + user.Email = e.Elements[0].Handle.EmailAddress + } + + if len(user.Email) == 0 { + return errors.New("Unable to retrieve email address") + } + + return err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) == 0 { + // add helper as new API requires the scope to be specified and these are the minimum to retrieve profile information and user's email address + scopes = append(scopes, "r_liteprofile", "r_emailaddress") + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + + return c +} + +// RefreshToken refresh token is not provided by linkedin +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by linkedin") +} + +// RefreshTokenAvailable refresh token is not provided by linkedin +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/linkedin/linkedin_test.go b/providers/linkedin/linkedin_test.go new file mode 100644 index 000000000..a67644eca --- /dev/null +++ b/providers/linkedin/linkedin_test.go @@ -0,0 +1,59 @@ +package linkedin_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/linkedin" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := linkedinProvider() + a.Equal(provider.ClientKey, os.Getenv("LINKEDIN_KEY")) + a.Equal(provider.Secret, os.Getenv("LINKEDIN_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), linkedinProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := linkedinProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*linkedin.Session) + a.NoError(err) + a.Contains(s.AuthURL, "linkedin.com/oauth/v2/authorization") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("LINKEDIN_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=r_liteprofile+r_emailaddress&state") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := linkedinProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://linkedin.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*linkedin.Session) + a.Equal(session.AuthURL, "http://linkedin.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func linkedinProvider() *linkedin.Provider { + return linkedin.New(os.Getenv("LINKEDIN_KEY"), os.Getenv("LINKEDIN_SECRET"), "/foo", "r_liteprofile", "r_emailaddress") +} diff --git a/providers/linkedin/session.go b/providers/linkedin/session.go new file mode 100644 index 000000000..51dee95d1 --- /dev/null +++ b/providers/linkedin/session.go @@ -0,0 +1,58 @@ +package linkedin + +import ( + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with LinkedIn. +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the LinkedIn provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with LinkedIn and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := Session{} + err := json.Unmarshal([]byte(data), &s) + return &s, err +} diff --git a/providers/linkedin/session_test.go b/providers/linkedin/session_test.go new file mode 100644 index 000000000..4cd49c22a --- /dev/null +++ b/providers/linkedin/session_test.go @@ -0,0 +1,48 @@ +package linkedin_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/linkedin" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &linkedin.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &linkedin.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &linkedin.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &linkedin.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/mailru/mailru.go b/providers/mailru/mailru.go new file mode 100644 index 000000000..f1d15ea0d --- /dev/null +++ b/providers/mailru/mailru.go @@ -0,0 +1,138 @@ +// Package mailru implements the OAuth2 protocol for authenticating users through mailru.com. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package mailru + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL = "https://oauth.mail.ru/login" + tokenURL = "https://oauth.mail.ru/token" + endpointUser = "https://oauth.mail.ru/userinfo" +) + +// New creates a new MAILRU provider and sets up important connection details. +// You should always call `mailru.New` to get a new provider. Never try to +// create one manually. +func New(clientID, clientSecret, redirectURL string, scopes ...string) *Provider { + var c = &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + c.Scopes = append(c.Scopes, scopes...) + + return &Provider{ + name: "mailru", + oauthConfig: c, + } +} + +// Provider is the implementation of `goth.Provider` for accessing MAILRU. +type Provider struct { + name string + httpClient *http.Client + oauthConfig *oauth2.Config +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.name +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.name = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.httpClient) +} + +// BeginAuth asks MAILRU for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.oauthConfig.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to MAILRU and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (_ goth.User, err error) { + var ( + sess = session.(*Session) + user = goth.User{ + AccessToken: sess.AccessToken, + RefreshToken: sess.RefreshToken, + Provider: p.Name(), + ExpiresAt: sess.ExpiresAt, + } + ) + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without access token", p.name) + } + + var reqURL = fmt.Sprintf( + "%s?access_token=%s", + endpointUser, sess.AccessToken, + ) + + res, err := p.Client().Get(reqURL) + if err != nil { + return user, err + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.name, res.StatusCode) + } + + buf, err := io.ReadAll(res.Body) + if err != nil { + return user, err + } + + if err = json.Unmarshal(buf, &user.RawData); err != nil { + return user, err + } + + // extract and ignore all errors + user.UserID, _ = user.RawData["id"].(string) + user.FirstName, _ = user.RawData["first_name"].(string) + user.LastName, _ = user.RawData["last_name"].(string) + user.NickName, _ = user.RawData["nickname"].(string) + user.Email, _ = user.RawData["email"].(string) + user.AvatarURL, _ = user.RawData["image"].(string) + + return user, err +} + +// Debug is a no-op for the mailru package. +func (p *Provider) Debug(debug bool) {} + +// RefreshToken refresh token is not provided by mailru. +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + t := &oauth2.Token{RefreshToken: refreshToken} + ts := p.oauthConfig.TokenSource(goth.ContextForClient(p.Client()), t) + + return ts.Token() +} + +// RefreshTokenAvailable refresh token is not provided by mailru +func (p *Provider) RefreshTokenAvailable() bool { + return true +} diff --git a/providers/mailru/mailru_test.go b/providers/mailru/mailru_test.go new file mode 100644 index 000000000..e7b6d8838 --- /dev/null +++ b/providers/mailru/mailru_test.go @@ -0,0 +1,66 @@ +package mailru_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/mailru" + "github.com/stretchr/testify/assert" +) + +func Test_Name(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := mailruProvider() + a.Equal(provider.Name(), "mailru") +} + +func Test_SetName(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := mailruProvider() + provider.SetName("foo") + a.Equal(provider.Name(), "foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), mailruProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := mailruProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*mailru.Session) + a.NoError(err) + a.Contains(s.AuthURL, "oauth.mail.ru/login") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("MAILRU_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=photos") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := mailruProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://mailru.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*mailru.Session) + a.Equal(session.AuthURL, "http://mailru.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func mailruProvider() *mailru.Provider { + return mailru.New(os.Getenv("MAILRU_KEY"), os.Getenv("MAILRU_SECRET"), "/foo", "photos") +} diff --git a/providers/mailru/session.go b/providers/mailru/session.go new file mode 100644 index 000000000..0487d3efc --- /dev/null +++ b/providers/mailru/session.go @@ -0,0 +1,59 @@ +package mailru + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with MAILRU. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL returns the URL for the authentication end-point for the provider. +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + + return s.AuthURL, nil +} + +// Marshal the session into a string +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +// Authorize the session with MAILRU and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.oauthConfig.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + + return s.AccessToken, err +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := new(Session) + err := json.NewDecoder(strings.NewReader(data)).Decode(&sess) + return sess, err +} diff --git a/providers/mailru/session_test.go b/providers/mailru/session_test.go new file mode 100644 index 000000000..5d8bff63d --- /dev/null +++ b/providers/mailru/session_test.go @@ -0,0 +1,40 @@ +package mailru_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/mailru" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mailru.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mailru.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mailru.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} diff --git a/providers/mastodon/mastodon.go b/providers/mastodon/mastodon.go new file mode 100644 index 000000000..58c018cec --- /dev/null +++ b/providers/mastodon/mastodon.go @@ -0,0 +1,184 @@ +// Package mastodon implements the OAuth2 protocol for authenticating users through Mastodon. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package mastodon + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Mastodon.social is the flagship instance of mastodon +var ( + InstanceURL = "https://mastodon.social/" +) + +// Provider is the implementation of `goth.Provider` for accessing Mastodon. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + authURL string + tokenURL string + profileURL string +} + +// New creates a new Mastodon provider and sets up important connection details. +// You should always call `mastodon.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, InstanceURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, instanceURL string, scopes ...string) *Provider { + instanceURL = fmt.Sprintf("%s/", strings.TrimSuffix(instanceURL, "/")) + profileURL := fmt.Sprintf("%sapi/v1/accounts/verify_credentials", instanceURL) + authURL := fmt.Sprintf("%soauth/authorize", instanceURL) + tokenURL := fmt.Sprintf("%soauth/token", instanceURL) + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "mastodon", + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the Mastodon package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Mastodon for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Mastodon and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + return user, err + } + + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"display_name"` + NickName string `json:"username"` + ID string `json:"id"` + AvatarURL string `json:"avatar"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Name = u.Name + if len(user.Name) == 0 { + user.Name = u.NickName + } + user.NickName = u.NickName + user.UserID = u.ID + user.AvatarURL = u.AvatarURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/mastodon/mastodon_test.go b/providers/mastodon/mastodon_test.go new file mode 100644 index 000000000..508ca7286 --- /dev/null +++ b/providers/mastodon/mastodon_test.go @@ -0,0 +1,67 @@ +package mastodon_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/mastodon" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("MASTODON_KEY")) + a.Equal(p.Secret, os.Getenv("MASTODON_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*mastodon.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*mastodon.Session) + a.NoError(err) + a.Contains(s.AuthURL, "mastodon.social/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://mastodon.social/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*mastodon.Session) + a.Equal(s.AuthURL, "https://mastodon.social/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *mastodon.Provider { + return mastodon.New(os.Getenv("MASTODON_KEY"), os.Getenv("MASTODON_SECRET"), "/foo") +} + +func urlCustomisedURLProvider() *mastodon.Provider { + return mastodon.NewCustomisedURL(os.Getenv("MASTODON_KEY"), os.Getenv("MASTODON_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") +} diff --git a/providers/mastodon/session.go b/providers/mastodon/session.go new file mode 100644 index 000000000..b975c0805 --- /dev/null +++ b/providers/mastodon/session.go @@ -0,0 +1,63 @@ +package mastodon + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Gitea. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Gitea provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Gitea and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/mastodon/session_test.go b/providers/mastodon/session_test.go new file mode 100644 index 000000000..04caf36b3 --- /dev/null +++ b/providers/mastodon/session_test.go @@ -0,0 +1,48 @@ +package mastodon_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/mastodon" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mastodon.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mastodon.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mastodon.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &mastodon.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/meetup/meetup.go b/providers/meetup/meetup.go new file mode 100644 index 000000000..94aa053b5 --- /dev/null +++ b/providers/meetup/meetup.go @@ -0,0 +1,196 @@ +// Package meetup implements the OAuth2 protocol for authenticating users through meetup.com . +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package meetup + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://secure.meetup.com/oauth2/authorize" + tokenURL string = "https://secure.meetup.com/oauth2/access" + endpointProfile string = "https://api.meetup.com/2/member/self" +) + +// New creates a new Meetup provider, and sets up important connection details. +// You should always call `meetup.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "meetup", + } + // register this meetup.com provider as broken for oauth2 RetrieveToken + oauth2.RegisterBrokenAuthHeaderProvider(tokenURL) + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing meetup.com . +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the meetup package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks meetup.com for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to meetup.com and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + request, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + + request.Header.Set("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(request) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Picture string `json:"photo_url"` + Country string `json:"country"` + City string `json:"city"` + State string `json:"state"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.UserID = strconv.FormatUint(u.ID, 10) + user.Name = u.Name + user.NickName = u.Name + + var location string + if len(u.City) > 0 { + location = u.City + } + if len(u.State) > 0 { + if len(location) > 0 { + location = location + ", " + u.State + } else { + location = u.State + } + } + if len(u.Country) > 0 { + if len(location) > 0 { + location = location + ", " + u.Country + } else { + location = u.Country + } + } + + user.Location = location + user.AvatarURL = u.Picture + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/meetup/meetup_test.go b/providers/meetup/meetup_test.go new file mode 100644 index 000000000..c3874ba3c --- /dev/null +++ b/providers/meetup/meetup_test.go @@ -0,0 +1,53 @@ +package meetup_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/meetup" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("MEETUP_KEY")) + a.Equal(p.Secret, os.Getenv("MEETUP_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*meetup.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://secure.meetup.com/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"ttps://secure.meetup.com/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*meetup.Session) + a.Equal(s.AuthURL, "ttps://secure.meetup.com/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *meetup.Provider { + return meetup.New(os.Getenv("MEETUP_KEY"), os.Getenv("MEETUP_SECRET"), "/foo") +} diff --git a/providers/meetup/session.go b/providers/meetup/session.go new file mode 100644 index 000000000..611339592 --- /dev/null +++ b/providers/meetup/session.go @@ -0,0 +1,63 @@ +package meetup + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with meetup.com . +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the meetup.com provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with meetup.com and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/meetup/session_test.go b/providers/meetup/session_test.go new file mode 100644 index 000000000..af12d412f --- /dev/null +++ b/providers/meetup/session_test.go @@ -0,0 +1,48 @@ +package meetup_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/meetup" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &meetup.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &meetup.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &meetup.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &meetup.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/microsoftonline/microsoftonline.go b/providers/microsoftonline/microsoftonline.go new file mode 100644 index 000000000..abf5db21c --- /dev/null +++ b/providers/microsoftonline/microsoftonline.go @@ -0,0 +1,190 @@ +// Package microsoftonline implements the OAuth2 protocol for authenticating users through microsoftonline. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +// To use this package, your application need to be registered in [Application Registration Portal](https://apps.dev.microsoft.com/) +package microsoftonline + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/going/defaults" + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + tokenURL string = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + endpointProfile string = "https://graph.microsoft.com/v1.0/me" +) + +var defaultScopes = []string{"openid", "offline_access", "user.read"} + +// New creates a new microsoftonline provider, and sets up important connection details. +// You should always call `microsoftonline.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "microsoftonline", + } + + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing microsoftonline. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + tenant string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client is HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the facebook package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks MicrosoftOnline for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authURL := p.config.AuthCodeURL(state) + return &Session{ + AuthURL: authURL, + }, nil +} + +// FetchUser will go to MicrosoftOnline and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + msSession := session.(*Session) + user := goth.User{ + AccessToken: msSession.AccessToken, + Provider: p.Name(), + ExpiresAt: msSession.ExpiresAt, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + + req.Header.Set(authorizationHeader(msSession)) + + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + user.AccessToken = msSession.AccessToken + + err = userFromReader(response.Body, &user) + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +// available for microsoft online as session size hit the limit of max cookie size +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + if refreshToken == "" { + return nil, fmt.Errorf("No refresh token provided") + } + + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + c.Scopes = append(c.Scopes, scopes...) + if len(scopes) == 0 { + c.Scopes = append(c.Scopes, defaultScopes...) + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + buf := &bytes.Buffer{} + tee := io.TeeReader(r, buf) + + u := struct { + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"mail"` + FirstName string `json:"givenName"` + LastName string `json:"surname"` + UserPrincipalName string `json:"userPrincipalName"` + }{} + + if err := json.NewDecoder(tee).Decode(&u); err != nil { + return err + } + + raw := map[string]interface{}{} + if err := json.NewDecoder(buf).Decode(&raw); err != nil { + return err + } + + user.UserID = u.ID + user.Email = defaults.String(u.Email, u.UserPrincipalName) + user.Name = u.Name + user.NickName = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.RawData = raw + + return nil +} + +func authorizationHeader(session *Session) (string, string) { + return "Authorization", fmt.Sprintf("Bearer %s", session.AccessToken) +} diff --git a/providers/microsoftonline/microsoftonline_test.go b/providers/microsoftonline/microsoftonline_test.go new file mode 100644 index 000000000..366080cfa --- /dev/null +++ b/providers/microsoftonline/microsoftonline_test.go @@ -0,0 +1,54 @@ +package microsoftonline_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/microsoftonline" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := microsoftonlineProvider() + + a.Equal(provider.ClientKey, os.Getenv("MICROSOFTONLINE_KEY")) + a.Equal(provider.Secret, os.Getenv("MICROSOFTONLINE_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := microsoftonlineProvider() + a.Implements((*goth.Provider)(nil), p) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := microsoftonlineProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*microsoftonline.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.microsoftonline.com/common/oauth2/v2.0/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := microsoftonlineProvider() + session, err := provider.UnmarshalSession(`{"AuthURL":"https://login.microsoftonline.com/common/oauth2/v2.0/authorize","AccessToken":"1234567890","ExpiresAt":"0001-01-01T00:00:00Z"}`) + a.NoError(err) + + s := session.(*microsoftonline.Session) + a.Equal(s.AuthURL, "https://login.microsoftonline.com/common/oauth2/v2.0/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func microsoftonlineProvider() *microsoftonline.Provider { + return microsoftonline.New(os.Getenv("MICROSOFTONLINE_KEY"), os.Getenv("MICROSOFTONLINE_SECRET"), "/foo") +} diff --git a/providers/microsoftonline/session.go b/providers/microsoftonline/session.go new file mode 100644 index 000000000..0747ab523 --- /dev/null +++ b/providers/microsoftonline/session.go @@ -0,0 +1,62 @@ +package microsoftonline + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session is the implementation of `goth.Session` for accessing microsoftonline. +// Refresh token not available for microsoft online: session size hit the limit of max cookie size +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Facebook provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + + return s.AuthURL, nil +} + +// Authorize the session with Facebook and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + session := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(session) + return session, err +} diff --git a/providers/microsoftonline/session_test.go b/providers/microsoftonline/session_test.go new file mode 100644 index 000000000..0db5816ca --- /dev/null +++ b/providers/microsoftonline/session_test.go @@ -0,0 +1,54 @@ +package microsoftonline_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/microsoftonline" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := µsoftonline.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := µsoftonline.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := µsoftonline.Session{ + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + AccessToken: "1234567890", + } + + data := s.Marshal() + a.Equal(`{"AuthURL":"https://login.microsoftonline.com/common/oauth2/v2.0/authorize","AccessToken":"1234567890","ExpiresAt":"0001-01-01T00:00:00Z"}`, data) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := µsoftonline.Session{ + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + AccessToken: "1234567890", + } + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/naver/naver.go b/providers/naver/naver.go new file mode 100644 index 000000000..2ebce639a --- /dev/null +++ b/providers/naver/naver.go @@ -0,0 +1,172 @@ +package naver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL = "https://nid.naver.com/oauth2.0/authorize" + tokenURL = "https://nid.naver.com/oauth2.0/token" + profileURL = "https://openapi.naver.com/v1/nid/me" +) + +// Provider is the implementation of `goth.Provider` for accessing naver.com. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// FetchUser will go to navercom and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + request, err := http.NewRequest("GET", profileURL, nil) + if err != nil { + return user, err + } + + request.Header.Set("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(request) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +// Debug is a no-op for the naver package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks naver.com for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// RefreshTokenAvailable refresh token is provided by naver +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// New creates a New provider and sets up important connection details. +// You should always call `naver.New` to get a new Provider. Never try to craete +// one manually. +// Currently Naver only supports pre-defined scopes. +// You should visit Naver Developer page in order to define your application's oauth scope. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "naver", + } + p.config = newConfig(p) + return p +} + +func newConfig(p *Provider) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + return c +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + Response struct { + ID string + Nickname string + Name string + Email string + Gender string + Age string + Birthday string + ProfileImage string `json:"profile_image"` + } + }{} + + if err := json.NewDecoder(reader).Decode(&u); err != nil { + return err + } + r := u.Response + user.Email = r.Email + user.Name = r.Name + user.NickName = r.Nickname + user.AvatarURL = r.ProfileImage + user.UserID = r.ID + user.Description = fmt.Sprintf(`{"gender":"%s","age":"%s","birthday":"%s"}`, r.Gender, r.Age, r.Birthday) + + return nil +} diff --git a/providers/naver/naver_test.go b/providers/naver/naver_test.go new file mode 100644 index 000000000..ee5385503 --- /dev/null +++ b/providers/naver/naver_test.go @@ -0,0 +1,56 @@ +package naver_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/naver" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("NAVER_KEY")) + a.Equal(p.Secret, os.Getenv("NAVER_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*naver.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://nid.naver.com/oauth2.0/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("NAVER_KEY"))) + a.Contains(s.AuthURL, "state=test_state") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"ttps://nid.naver.com/oauth2.0/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*naver.Session) + a.Equal(s.AuthURL, "ttps://nid.naver.com/oauth2.0/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *naver.Provider { + return naver.New(os.Getenv("NAVER_KEY"), os.Getenv("NAVER_SECRET"), "/foo") +} diff --git a/providers/naver/session.go b/providers/naver/session.go new file mode 100644 index 000000000..01ad71359 --- /dev/null +++ b/providers/naver/session.go @@ -0,0 +1,61 @@ +package naver + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with naver.com. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the meetup.com provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with naver.com and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/naver/session_test.go b/providers/naver/session_test.go new file mode 100644 index 000000000..194878a8f --- /dev/null +++ b/providers/naver/session_test.go @@ -0,0 +1,53 @@ +package naver_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/naver" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &naver.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &naver.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &naver.Session{ + AuthURL: "https://nid.naver.com/oauth2.0/authorize", + AccessToken: "1234567890", + } + data := s.Marshal() + a.Equal(`{"AuthURL":"https://nid.naver.com/oauth2.0/authorize","AccessToken":"1234567890","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`, data) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &naver.Session{ + AuthURL: "https://nid.naver.com/oauth2.0/authorize", + AccessToken: "1234567890", + } + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/nextcloud/README.md b/providers/nextcloud/README.md new file mode 100644 index 000000000..ae7d5668d --- /dev/null +++ b/providers/nextcloud/README.md @@ -0,0 +1,85 @@ +# Nextcloud OAuth2 + +For this backend, you need to have an OAuth2 enabled Nextcloud Instance, e.g. +on your own private server. + +## Setting up Nextcloud Test Environment + +To test, you only need a working Docker image of Nextcloud running on a public +URL, e.g. through [traefik](https://traefik.io) + +```docker-compose.yml +version: '2' + +networks: + traefik-web: + external: true + +services: + app: + image: nextcloud + restart: always + networks: + - traefik-web + labels: + - traefik.enable=true + - traefik.frontend.rule=Host:${NEXTCLOUD_DNS} + - traefik.docker.network=traefik-web + environment: + SQLITE_DATABASE: "database.sqlite3" + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: admin + NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_DNS} +``` + +and start it up via + +``` +NEXTCLOUD_DNS=goth.my.server.name docker-compose up -d +``` + +afterwards, you will have a running Nextcloud instance with credentials + +``` +admin / admin +``` + +Then add a new OAuth 2.0 Client by going to + +``` +Settings -> Security -> OAuth 2.0 client +``` + +![Nextcloud Setup](nextcloud_setup.png) + +and add a new client with the name `goth` and redirection uri `http://localhost:3000/auth/nextcloud/callback`. The imporant part here the +two cryptic entries `Client Identifier` and `Secret`, which needs to be +used in your application. + +## Running Login Example + +If you want to run the default example in `/examples`, you have to +retrieve the keys described in the previous section and run the example +as follows: + +``` +NEXTCLOUD_URL=https://goth.my.server.name \ +NEXTCLOUD_KEY= \ +NEXTCLOUD_SECRET= \ +SESSION_SECRET=1 \ +./examples +``` + +Afterwards, you should be able to log in via Nextcloud in the examples app. + +## Running the Provider Test + +The test has the same arguments as the login example test, but starts the test itself + +``` +NEXTCLOUD_URL=https://goth.my.server.name \ +NEXTCLOUD_KEY= \ +NEXTCLOUD_SECRET= \ +SESSION_SECRET=1 \ +go test -v +``` diff --git a/providers/nextcloud/nextcloud.go b/providers/nextcloud/nextcloud.go new file mode 100644 index 000000000..43bc85976 --- /dev/null +++ b/providers/nextcloud/nextcloud.go @@ -0,0 +1,205 @@ +// Package nextcloud implements the OAuth2 protocol for authenticating users through nextcloud. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package nextcloud + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the Authentication, Token, and Profile URLS for Nextcloud. +// You have to set these values to something useful, because nextcloud is always +// hosted somewhere. +var ( + AuthURL = "https:///apps/oauth2/authorize" + TokenURL = "https:///apps/oauth2/api/v1/token" + ProfileURL = "https:///ocs/v2.php/cloud/user?format=json" +) + +// Provider is the implementation of `goth.Provider` for accessing Nextcloud. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + authURL string + tokenURL string + profileURL string +} + +// New is only here to fulfill the interface requirements and does not work properly without +// setting your own Nextcloud connect parameters, more precisely AuthURL, TokenURL and ProfileURL. +// Please use NewCustomisedDNS with the beginning of your URL or NewCustomiseURL. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...) +} + +// NewCustomisedURL create a working connection to your Nextcloud server given by the values +// authURL, tokenURL and profileURL. +// If you want to use a simpler method, please have a look at NewCustomisedDNS, which gets only +// on parameter instead of three. +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "nextcloud", + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// NewCustomisedDNS is the simplest method to create a provider based only on your key/secret +// and the beginning of the URL to your server, e.g. https://my.server.name/ +func NewCustomisedDNS(clientKey, secret, callbackURL, nextcloudURL string, scopes ...string) *Provider { + return NewCustomisedURL( + clientKey, + secret, + callbackURL, + nextcloudURL+"/apps/oauth2/authorize", + nextcloudURL+"/apps/oauth2/api/v1/token", + nextcloudURL+"/ocs/v2.php/cloud/user?format=json", + scopes..., + ) +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the nextcloud package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Nextcloud for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Nextcloud and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Ocs struct { + Data struct { + EMail string `json:"email"` + DisplayName string `json:"display-name"` + ID string `json:"id"` + Address string `json:"address"` + } + } `json:"ocs"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Ocs.Data.EMail + user.Name = u.Ocs.Data.DisplayName + user.UserID = u.Ocs.Data.ID + user.Location = u.Ocs.Data.Address + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/nextcloud/nextcloud_setup.png b/providers/nextcloud/nextcloud_setup.png new file mode 100644 index 0000000000000000000000000000000000000000..8f457dd2a31650fd33e44126db684aaf847c92c4 GIT binary patch literal 85944 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 z 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 Q;ea1`X=SN$38R4j17;<;Z2$lO literal 0 HcmV?d00001 diff --git a/providers/nextcloud/nextcloud_test.go b/providers/nextcloud/nextcloud_test.go new file mode 100644 index 000000000..e17266b7e --- /dev/null +++ b/providers/nextcloud/nextcloud_test.go @@ -0,0 +1,72 @@ +package nextcloud_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/nextcloud" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("NEXTCLOUD_KEY")) + a.Equal(p.Secret, os.Getenv("NEXTCLOUD_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*nextcloud.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*nextcloud.Session) + a.NoError(err) + a.Contains(s.AuthURL, "/apps/oauth2/authorize?client_id=") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://nextcloud.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*nextcloud.Session) + a.Equal(s.AuthURL, "https://nextcloud.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *nextcloud.Provider { + return nextcloud.NewCustomisedDNS( + os.Getenv("NEXTCLOUD_KEY"), + os.Getenv("NEXTCLOUD_SECRET"), + "/foo", + os.Getenv("NEXTCLOUD_DNS"), + ) +} + +func urlCustomisedURLProvider() *nextcloud.Provider { + return nextcloud.NewCustomisedURL(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") +} diff --git a/providers/nextcloud/session.go b/providers/nextcloud/session.go new file mode 100644 index 000000000..568f3d6d4 --- /dev/null +++ b/providers/nextcloud/session.go @@ -0,0 +1,63 @@ +package nextcloud + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Nextcloud. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Nextcloud provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Nextcloud and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/nextcloud/session_test.go b/providers/nextcloud/session_test.go new file mode 100644 index 000000000..c53294ce8 --- /dev/null +++ b/providers/nextcloud/session_test.go @@ -0,0 +1,48 @@ +package nextcloud_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/nextcloud" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &nextcloud.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &nextcloud.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &nextcloud.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &nextcloud.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/okta/okta.go b/providers/okta/okta.go new file mode 100644 index 000000000..b871b3d38 --- /dev/null +++ b/providers/okta/okta.go @@ -0,0 +1,197 @@ +// Package okta implements the OAuth2 protocol for authenticating users through okta. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package okta + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Provider is the implementation of `goth.Provider` for accessing okta. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + issuerURL string + profileURL string +} + +// New creates a new Okta provider and sets up important connection details. +// You should always call `okta.New` to get a new provider. Never try to +// create one manually. +func New(clientID, secret, orgURL, callbackURL string, scopes ...string) *Provider { + issuerURL := orgURL + "/oauth2/default" + authURL := issuerURL + "/v1/authorize" + tokenURL := issuerURL + "/v1/token" + profileURL := issuerURL + "/v1/userinfo" + return NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientID, secret, callbackURL, authURL, tokenURL, issuerURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientID, + Secret: secret, + CallbackURL: callbackURL, + providerName: "okta", + issuerURL: issuerURL, + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the okta package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks okta for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to okta and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + UserID: sess.UserID, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Email string `json:"email"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + NickName string `json:"nickname"` + ID string `json:"sub"` + Locale string `json:"locale"` + ProfileURL string `json:"profile"` + Username string `json:"preferred_username"` + Zoneinfo string `json:"zoneinfo"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + rd := make(map[string]interface{}) + rd["ProfileURL"] = u.ProfileURL + rd["Locale"] = u.Locale + rd["Username"] = u.Username + rd["Zoneinfo"] = u.Zoneinfo + + user.UserID = u.ID + user.Email = u.Email + user.Name = u.Name + user.NickName = u.NickName + user.FirstName = u.FirstName + user.LastName = u.LastName + + user.RawData = rd + + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/okta/okta_test.go b/providers/okta/okta_test.go new file mode 100644 index 000000000..0dc430830 --- /dev/null +++ b/providers/okta/okta_test.go @@ -0,0 +1,67 @@ +package okta_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/okta" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("OKTA_ID")) + a.Equal(p.Secret, os.Getenv("OKTA_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*okta.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*okta.Session) + a.NoError(err) + a.Contains(s.AuthURL, os.Getenv("OKTA_ORG_URL")) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"` + os.Getenv("OKTA_ORG_URL") + `/oauth2/v1/authorize", "AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*okta.Session) + a.Equal(s.AuthURL, os.Getenv("OKTA_ORG_URL")+"/oauth2/v1/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *okta.Provider { + return okta.New(os.Getenv("OKTA_ID"), os.Getenv("OKTA_SECRET"), os.Getenv("OKTA_ORG_URL"), "/foo") +} + +func urlCustomisedURLProvider() *okta.Provider { + return okta.NewCustomisedURL(os.Getenv("CLIENT_ID"), os.Getenv("CLIENT_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://issuerURL", "http://profileURL") +} diff --git a/providers/okta/session.go b/providers/okta/session.go new file mode 100644 index 000000000..e0951fe0f --- /dev/null +++ b/providers/okta/session.go @@ -0,0 +1,64 @@ +package okta + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Okta. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + UserID string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Okta provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Okta and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/okta/session_test.go b/providers/okta/session_test.go new file mode 100644 index 000000000..36f1add33 --- /dev/null +++ b/providers/okta/session_test.go @@ -0,0 +1,48 @@ +package okta_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/okta" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &okta.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &okta.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &okta.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &okta.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/onedrive/onedrive.go b/providers/onedrive/onedrive.go new file mode 100644 index 000000000..877894f83 --- /dev/null +++ b/providers/onedrive/onedrive.go @@ -0,0 +1,163 @@ +// Package onedrive implements the OAuth2 protocol for authenticating users through onedrive. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package onedrive + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://login.live.com/oauth20_authorize.srf" + tokenURL string = "https://login.live.com/oauth20_token.srf" + endpointProfile string = "https://apis.live.net/v5.0/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Onedrive. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Onedrive provider and sets up important connection details. +// You should always call `onedrive.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "onedrive", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the onedrive package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Onedrive for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Onedrive and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "wl.signin", "wl.emails", "wl.offline_access") + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Email map[string]string `json:"emails"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email["account"] + user.Name = u.Name + user.NickName = u.Name + user.UserID = u.Email["account"] // onedrive doesn't provide separate user_id + + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/onedrive/onedrive_test.go b/providers/onedrive/onedrive_test.go new file mode 100644 index 000000000..68c992e06 --- /dev/null +++ b/providers/onedrive/onedrive_test.go @@ -0,0 +1,53 @@ +package onedrive_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/onedrive" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("ONEDRIVE_KEY")) + a.Equal(p.Secret, os.Getenv("ONEDRIVE_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*onedrive.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.live.com/oauth20_authorize.srf") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://login.live.com/oauth20_authorize.srf","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*onedrive.Session) + a.Equal(s.AuthURL, "https://login.live.com/oauth20_authorize.srf") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *onedrive.Provider { + return onedrive.New(os.Getenv("ONEDRIVE_KEY"), os.Getenv("ONEDRIVE_SECRET"), "/foo") +} diff --git a/providers/onedrive/session.go b/providers/onedrive/session.go new file mode 100644 index 000000000..fec401d4c --- /dev/null +++ b/providers/onedrive/session.go @@ -0,0 +1,63 @@ +package onedrive + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Onedrive. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Onedrive provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Onedrive and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/onedrive/session_test.go b/providers/onedrive/session_test.go new file mode 100644 index 000000000..344377a25 --- /dev/null +++ b/providers/onedrive/session_test.go @@ -0,0 +1,48 @@ +package onedrive_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/onedrive" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &onedrive.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &onedrive.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &onedrive.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &onedrive.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/openidConnect/openidConnect.go b/providers/openidConnect/openidConnect.go new file mode 100644 index 000000000..dea40d2dc --- /dev/null +++ b/providers/openidConnect/openidConnect.go @@ -0,0 +1,529 @@ +package openidConnect + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + // Standard Claims http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + // fixed, cannot be changed + subjectClaim = "sub" + expiryClaim = "exp" + audienceClaim = "aud" + issuerClaim = "iss" + + PreferredUsernameClaim = "preferred_username" + EmailClaim = "email" + NameClaim = "name" + NicknameClaim = "nickname" + PictureClaim = "picture" + GivenNameClaim = "given_name" + FamilyNameClaim = "family_name" + AddressClaim = "address" + + // Unused but available to set in Provider claims + MiddleNameClaim = "middle_name" + ProfileClaim = "profile" + WebsiteClaim = "website" + EmailVerifiedClaim = "email_verified" + GenderClaim = "gender" + BirthdateClaim = "birthdate" + ZoneinfoClaim = "zoneinfo" + LocaleClaim = "locale" + PhoneNumberClaim = "phone_number" + PhoneNumberVerifiedClaim = "phone_number_verified" + UpdatedAtClaim = "updated_at" + + clockSkew = 10 * time.Second +) + +// Provider is the implementation of `goth.Provider` for accessing OpenID Connect provider +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + OpenIDConfig *OpenIDConfig + config *oauth2.Config + authCodeOptions []oauth2.AuthCodeOption + providerName string + + UserIdClaims []string + NameClaims []string + NickNameClaims []string + EmailClaims []string + AvatarURLClaims []string + FirstNameClaims []string + LastNameClaims []string + LocationClaims []string + + SkipUserInfoRequest bool +} + +type OpenIDConfig struct { + AuthEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + + // If OpenID discovery is enabled, the end_session_endpoint field can optionally be provided + // in the discovery endpoint response according to OpenID spec. See: + // https://openid.net/specs/openid-connect-session-1_0-17.html#OPMetadata + EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` + Issuer string `json:"issuer"` +} + +type RefreshTokenResponse struct { + AccessToken string `json:"access_token"` + + // The OpenID spec defines the ID token as an optional response field in the + // refresh token flow. As a result, a new ID token may not be returned in a successful + // response. + // See more: https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken + IdToken string `json:"id_token,omitempty"` + + // The OAuth spec defines the refresh token as an optional response field in the + // refresh token flow. As a result, a new refresh token may not be returned in a successful + // response. + // See more: https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/ + RefreshToken string `json:"refresh_token,omitempty"` +} + +// New creates a new OpenID Connect provider, and sets up important connection details. +// You should always call `openidConnect.New` to get a new Provider. Never try to create +// one manually. +// New returns an implementation of an OpenID Connect Authorization Code Flow +// See http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth +// ID Token decryption is not (yet) supported +// UserInfo decryption is not (yet) supported +func New(clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes ...string) (*Provider, error) { + return NewNamed("", clientKey, secret, callbackURL, openIDAutoDiscoveryURL, scopes...) +} + +// NewNamed is similar to New(...) but can be used to set a custom name for the +// provider in order to use multiple OIDC providers +func NewNamed(name, clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes ...string) (*Provider, error) { + switch len(name) { + case 0: + name = "openid-connect" + default: + name = fmt.Sprintf("%s-oidc", strings.ToLower(name)) + } + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + + UserIdClaims: []string{subjectClaim}, + NameClaims: []string{NameClaim}, + NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim}, + EmailClaims: []string{EmailClaim}, + AvatarURLClaims: []string{PictureClaim}, + FirstNameClaims: []string{GivenNameClaim}, + LastNameClaims: []string{FamilyNameClaim}, + LocationClaims: []string{AddressClaim}, + + providerName: name, + } + + openIDConfig, err := getOpenIDConfig(p, openIDAutoDiscoveryURL) + if err != nil { + return nil, err + } + p.OpenIDConfig = openIDConfig + + p.config = newConfig(p, scopes, openIDConfig) + return p, nil +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs hence omit the auto-discovery step +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, issuerURL, userInfoURL, endSessionEndpointURL string, scopes ...string) (*Provider, error) { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + OpenIDConfig: &OpenIDConfig{ + AuthEndpoint: authURL, + TokenEndpoint: tokenURL, + Issuer: issuerURL, + UserInfoEndpoint: userInfoURL, + EndSessionEndpoint: endSessionEndpointURL, + }, + + UserIdClaims: []string{subjectClaim}, + NameClaims: []string{NameClaim}, + NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim}, + EmailClaims: []string{EmailClaim}, + AvatarURLClaims: []string{PictureClaim}, + FirstNameClaims: []string{GivenNameClaim}, + LastNameClaims: []string{FamilyNameClaim}, + LocationClaims: []string{AddressClaim}, + + providerName: "openid-connect", + } + + p.config = newConfig(p, scopes, p.OpenIDConfig) + return p, nil +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// SetAuthCodeOptions sets additional parameters for the authentication URL. +// It takes a map of string key-value pairs and appends them to the provider's authCodeOptions. +func (p *Provider) SetAuthCodeOptions(params map[string]string) { + for k, v := range params { + p.authCodeOptions = append(p.authCodeOptions, oauth2.SetAuthURLParam(k, v)) + } +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the openidConnect package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks the OpenID Connect provider for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state, p.authCodeOptions...) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will use the id_token and access requested information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + + expiresAt := sess.ExpiresAt + + if sess.IDToken == "" { + return goth.User{}, fmt.Errorf("%s cannot get user information without id_token", p.providerName) + } + + // decode returned id token to get expiry + claims, err := decodeJWT(sess.IDToken) + + if err != nil { + return goth.User{}, fmt.Errorf("oauth2: error decoding JWT token: %v", err) + } + + expiry, err := p.validateClaims(claims) + if err != nil { + return goth.User{}, fmt.Errorf("oauth2: error validating JWT token: %v", err) + } + + if expiry.Before(expiresAt) { + expiresAt = expiry + } + + if err := p.getUserInfo(sess.AccessToken, claims); err != nil { + return goth.User{}, err + } + + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: expiresAt, + RawData: claims, + IDToken: sess.IDToken, + } + + p.userFromClaims(claims, &user) + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(oauth2.NoContext, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// The ID token is a fundamental part of the OpenID connect refresh token flow but is not part of the OAuth flow. +// The existing RefreshToken function leverages the OAuth library's refresh token mechanism, ignoring the refreshed +// ID token. As a result, a new function needs to be exposed (rather than changing the existing function, for backwards +// compatibility purposes) that also returns the id_token in the OpenID refresh token flow API response +// Learn more about ID tokens: https://openid.net/specs/openid-connect-core-1_0.html#IDToken +func (p *Provider) RefreshTokenWithIDToken(refreshToken string) (*RefreshTokenResponse, error) { + urlValues := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {p.ClientKey}, + "client_secret": {p.Secret}, + } + req, err := http.NewRequest("POST", p.OpenIDConfig.TokenEndpoint, strings.NewReader(urlValues.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := p.Client().Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Non-200 response from RefreshToken: %d, WWW-Authenticate=%s", resp.StatusCode, resp.Header.Get("WWW-Authenticate")) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body.Close() + + refreshTokenResponse := &RefreshTokenResponse{} + + err = json.Unmarshal(body, refreshTokenResponse) + if err != nil { + return nil, err + } + + return refreshTokenResponse, nil +} + +// validate according to standard, returns expiry +// http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation +func (p *Provider) validateClaims(claims map[string]interface{}) (time.Time, error) { + audience := getClaimValue(claims, []string{audienceClaim}) + if audience != p.ClientKey { + found := false + audiences := getClaimValues(claims, []string{audienceClaim}) + for _, aud := range audiences { + if aud == p.ClientKey { + found = true + break + } + } + if !found { + return time.Time{}, errors.New("audience in token does not match client key") + } + } + + issuer := getClaimValue(claims, []string{issuerClaim}) + if issuer != p.OpenIDConfig.Issuer { + return time.Time{}, errors.New("issuer in token does not match issuer in OpenIDConfig discovery") + } + + // expiry is required for JWT, not for UserInfoResponse + // is actually a int64, so force it in to that type + expiryClaim := int64(claims[expiryClaim].(float64)) + expiry := time.Unix(expiryClaim, 0) + if expiry.Add(clockSkew).Before(time.Now()) { + return time.Time{}, errors.New("user info JWT token is expired") + } + return expiry, nil +} + +func (p *Provider) userFromClaims(claims map[string]interface{}, user *goth.User) { + // required + user.UserID = getClaimValue(claims, p.UserIdClaims) + + user.Name = getClaimValue(claims, p.NameClaims) + user.NickName = getClaimValue(claims, p.NickNameClaims) + user.Email = getClaimValue(claims, p.EmailClaims) + user.AvatarURL = getClaimValue(claims, p.AvatarURLClaims) + user.FirstName = getClaimValue(claims, p.FirstNameClaims) + user.LastName = getClaimValue(claims, p.LastNameClaims) + user.Location = getClaimValue(claims, p.LocationClaims) +} + +func (p *Provider) getUserInfo(accessToken string, claims map[string]interface{}) error { + // skip if there is no UserInfoEndpoint or is explicitly disabled + if p.OpenIDConfig.UserInfoEndpoint == "" || p.SkipUserInfoRequest { + return nil + } + + userInfoClaims, err := p.fetchUserInfo(p.OpenIDConfig.UserInfoEndpoint, accessToken) + if err != nil { + return err + } + + // The sub (subject) Claim MUST always be returned in the UserInfo Response. + // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + userInfoSubject := getClaimValue(userInfoClaims, []string{subjectClaim}) + if userInfoSubject == "" { + return fmt.Errorf("userinfo response did not contain a 'sub' claim: %#v", userInfoClaims) + } + + // The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; + // if they do not match, the UserInfo Response values MUST NOT be used. + // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + subject := getClaimValue(claims, []string{subjectClaim}) + if userInfoSubject != subject { + return fmt.Errorf("userinfo 'sub' claim (%s) did not match id_token 'sub' claim (%s)", userInfoSubject, subject) + } + + // Merge in userinfo claims in case id_token claims contained some that userinfo did not + for k, v := range userInfoClaims { + claims[k] = v + } + + return nil +} + +// fetch and decode JSON from the given UserInfo URL +func (p *Provider) fetchUserInfo(url, accessToken string) (map[string]interface{}, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + resp, err := p.Client().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Non-200 response from UserInfo: %d, WWW-Authenticate=%s", resp.StatusCode, resp.Header.Get("WWW-Authenticate")) + } + + // The UserInfo Claims MUST be returned as the members of a JSON object + // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return unMarshal(data) +} + +func getOpenIDConfig(p *Provider, openIDAutoDiscoveryURL string) (*OpenIDConfig, error) { + res, err := p.Client().Get(openIDAutoDiscoveryURL) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("Non-success code for Discovery URL: %d", res.StatusCode) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + openIDConfig := &OpenIDConfig{} + err = json.Unmarshal(body, openIDConfig) + if err != nil { + return nil, err + } + + return openIDConfig, nil +} + +func newConfig(provider *Provider, scopes []string, openIDConfig *OpenIDConfig) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: openIDConfig.AuthEndpoint, + TokenURL: openIDConfig.TokenEndpoint, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + foundOpenIDScope := false + + for _, scope := range scopes { + if scope == "openid" { + foundOpenIDScope = true + } + c.Scopes = append(c.Scopes, scope) + } + + if !foundOpenIDScope { + c.Scopes = append(c.Scopes, "openid") + } + } else { + c.Scopes = []string{"openid"} + } + + return c +} + +func getClaimValue(data map[string]interface{}, claims []string) string { + for _, claim := range claims { + if value, ok := data[claim]; ok { + if stringValue, ok := value.(string); ok && len(stringValue) > 0 { + return stringValue + } + } + } + + return "" +} + +func getClaimValues(data map[string]interface{}, claims []string) []string { + var result []string + + for _, claim := range claims { + if value, ok := data[claim]; ok { + if stringValues, ok := value.([]interface{}); ok { + for _, stringValue := range stringValues { + if s, ok := stringValue.(string); ok && len(s) > 0 { + result = append(result, s) + } + } + } + } + } + + return result +} + +// decodeJWT decodes a JSON Web Token into a simple map +// http://openid.net/specs/draft-jones-json-web-token-07.html +func decodeJWT(jwt string) (map[string]interface{}, error) { + jwtParts := strings.Split(jwt, ".") + if len(jwtParts) != 3 { + return nil, errors.New("jws: invalid token received, not all parts available") + } + + decodedPayload, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[1]) + + if err != nil { + return nil, err + } + + return unMarshal(decodedPayload) +} + +func unMarshal(payload []byte) (map[string]interface{}, error) { + data := make(map[string]interface{}) + + return data, json.NewDecoder(bytes.NewBuffer(payload)).Decode(&data) +} diff --git a/providers/openidConnect/openidConnect_test.go b/providers/openidConnect/openidConnect_test.go new file mode 100644 index 000000000..7dd76e04f --- /dev/null +++ b/providers/openidConnect/openidConnect_test.go @@ -0,0 +1,123 @@ +package openidConnect + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +var ( + server *httptest.Server +) + +func init() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // return the value of Google's setup at https://accounts.google.com/.well-known/openid-configuration + fmt.Fprintln(w, "{ \"issuer\": \"https://accounts.google.com\", \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\", \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\", \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\", \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\", \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\", \"response_types_supported\": [ \"code\", \"token\", \"id_token\", \"code token\", \"code id_token\", \"token id_token\", \"code token id_token\", \"none\" ], \"subject_types_supported\": [ \"public\" ], \"id_token_signing_alg_values_supported\": [ \"RS256\" ], \"scopes_supported\": [ \"openid\", \"email\", \"profile\" ], \"token_endpoint_auth_methods_supported\": [ \"client_secret_post\", \"client_secret_basic\" ], \"claims_supported\": [ \"aud\", \"email\", \"email_verified\", \"exp\", \"family_name\", \"given_name\", \"iat\", \"iss\", \"locale\", \"name\", \"picture\", \"sub\" ], \"code_challenge_methods_supported\": [ \"plain\", \"S256\" ] }") + })) +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := openidConnectProvider() + a.Equal(os.Getenv("OPENID_CONNECT_KEY"), provider.ClientKey) + a.Equal(os.Getenv("OPENID_CONNECT_SECRET"), provider.Secret) + a.Equal("http://localhost/foo", provider.CallbackURL) + + a.Equal("https://accounts.google.com", provider.OpenIDConfig.Issuer) + a.Equal("https://accounts.google.com/o/oauth2/v2/auth", provider.OpenIDConfig.AuthEndpoint) + a.Equal("https://www.googleapis.com/oauth2/v4/token", provider.OpenIDConfig.TokenEndpoint) + a.Equal("https://www.googleapis.com/oauth2/v3/userinfo", provider.OpenIDConfig.UserInfoEndpoint) +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider, _ := NewCustomisedURL( + os.Getenv("OPENID_CONNECT_KEY"), + os.Getenv("OPENID_CONNECT_SECRET"), + "http://localhost/foo", + "https://accounts.google.com/o/oauth2/v2/auth", + "https://www.googleapis.com/oauth2/v4/token", + "https://accounts.google.com", + "https://www.googleapis.com/oauth2/v3/userinfo", + "", + server.URL, + ) + a.Equal(os.Getenv("OPENID_CONNECT_KEY"), provider.ClientKey) + a.Equal(os.Getenv("OPENID_CONNECT_SECRET"), provider.Secret) + a.Equal("http://localhost/foo", provider.CallbackURL) + + a.Equal("https://accounts.google.com", provider.OpenIDConfig.Issuer) + a.Equal("https://accounts.google.com/o/oauth2/v2/auth", provider.OpenIDConfig.AuthEndpoint) + a.Equal("https://www.googleapis.com/oauth2/v4/token", provider.OpenIDConfig.TokenEndpoint) + a.Equal("https://www.googleapis.com/oauth2/v3/userinfo", provider.OpenIDConfig.UserInfoEndpoint) + a.Equal("", provider.OpenIDConfig.EndSessionEndpoint) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := openidConnectProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://accounts.google.com/o/oauth2/v2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("OPENID_CONNECT_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "redirect_uri=http%3A%2F%2Flocalhost%2Ffoo") + a.Contains(s.AuthURL, "scope=openid") +} + +func Test_BeginAuth_AuthCodeOptions(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := openidConnectProvider() + provider.SetAuthCodeOptions(map[string]string{"domain_hint": "test_domain.com", "prompt": "none"}) + session, err := provider.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://accounts.google.com/o/oauth2/v2/auth") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("OPENID_CONNECT_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "redirect_uri=http%3A%2F%2Flocalhost%2Ffoo") + a.Contains(s.AuthURL, "scope=openid") + a.Contains(s.AuthURL, "domain_hint=test_domain.com") + a.Contains(s.AuthURL, "prompt=none") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), openidConnectProvider()) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := openidConnectProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://accounts.google.com/o/oauth2/v2/auth","AccessToken":"1234567890","IDToken":"abc"}`) + a.NoError(err) + session := s.(*Session) + a.Equal("https://accounts.google.com/o/oauth2/v2/auth", session.AuthURL) + a.Equal("1234567890", session.AccessToken) + a.Equal("abc", session.IDToken) +} + +func openidConnectProvider() *Provider { + provider, _ := New(os.Getenv("OPENID_CONNECT_KEY"), os.Getenv("OPENID_CONNECT_SECRET"), "http://localhost/foo", server.URL) + return provider +} diff --git a/providers/openidConnect/session.go b/providers/openidConnect/session.go new file mode 100644 index 000000000..84b577c39 --- /dev/null +++ b/providers/openidConnect/session.go @@ -0,0 +1,81 @@ +package openidConnect + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with the OpenID Connect provider. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + IDToken string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the OpenID Connect provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New("an AuthURL has not be set") + } + return s.AuthURL, nil +} + +// Authorize the session with the OpenID Connect provider and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + + var authParams []oauth2.AuthCodeOption + + // override redirect_uri if passed as param + redirectURL := params.Get("redirect_uri") + if redirectURL != "" { + authParams = append(authParams, oauth2.SetAuthURLParam("redirect_uri", redirectURL)) + } + + // set code_verifier if passed as param + codeVerifier := params.Get("code_verifier") + if codeVerifier != "" { + authParams = append(authParams, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + } + + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"), authParams...) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + if idToken := token.Extra("id_token"); idToken != nil { + s.IDToken = idToken.(string) + } + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/openidConnect/session_test.go b/providers/openidConnect/session_test.go new file mode 100644 index 000000000..29f6b54b1 --- /dev/null +++ b/providers/openidConnect/session_test.go @@ -0,0 +1,47 @@ +package openidConnect + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","IDToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/oura/errors.go b/providers/oura/errors.go new file mode 100644 index 000000000..596c33676 --- /dev/null +++ b/providers/oura/errors.go @@ -0,0 +1,16 @@ +package oura + +// APIError describes an error from the Oura API +type APIError struct { + Code int + Description string +} + +// NewAPIError initializes an Oura APIError +func NewAPIError(code int, description string) APIError { + return APIError{code, description} +} + +func (e APIError) Error() string { + return e.Description +} diff --git a/providers/oura/oura.go b/providers/oura/oura.go new file mode 100644 index 000000000..a62e89470 --- /dev/null +++ b/providers/oura/oura.go @@ -0,0 +1,191 @@ +// Package oura implements the OAuth protocol for authenticating users through Oura API (for OuraRing). +package oura + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://cloud.ouraring.com/oauth/authorize" + tokenURL string = "https://api.ouraring.com/oauth/token" + endpointProfile string = "https://api.ouraring.com/v1/userinfo" +) + +const ( + // ScopeEmail includes email address of the user + ScopeEmail = "email" + // ScopePersonal includes personal information (gender, age, height, weight) + ScopePersonal = "personal" + // ScopeDaily includes daily summaries of sleep, activity and readiness + ScopeDaily = "daily" +) + +// New creates a new Oura provider (for OuraRing), and sets up important connection details. +// You should always call `oura.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "oura", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Oura API. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client for making requests on the provider +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the oura package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Oura for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Oura and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + UserID: s.UserID, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, NewAPIError(resp.StatusCode, fmt.Sprintf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode)) + } + + // err = userFromReader(io.TeeReader(resp.Body, os.Stdout), &user) + err = userFromReader(resp.Body, &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + Age int `json:"age"` + Weight float32 `json:"weight"` // kg + Height int `json:"height"` // cm + Gender string `json:"gender"` + Email string `json:"email"` + UserID string `json:"user_id"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + rawData := make(map[string]interface{}) + + if u.Age != 0 { + rawData["age"] = u.Age + } + if u.Weight != 0 { + rawData["weight"] = u.Weight + } + if u.Height != 0 { + rawData["height"] = u.Height + } + if u.Gender != "" { + rawData["gender"] = u.Gender + } + + user.UserID = u.UserID + user.Email = u.Email + if len(rawData) > 0 { + user.RawData = rawData + } + + return err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + + return c +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(oauth2.NoContext, token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +// RefreshTokenAvailable refresh token is not provided by oura +func (p *Provider) RefreshTokenAvailable() bool { + return true +} diff --git a/providers/oura/oura_test.go b/providers/oura/oura_test.go new file mode 100644 index 000000000..09b7b48a5 --- /dev/null +++ b/providers/oura/oura_test.go @@ -0,0 +1,55 @@ +package oura_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/oura" + "github.com/stretchr/testify/assert" +) + +func provider() *oura.Provider { + return oura.New(os.Getenv("OURA_KEY"), os.Getenv("OURA_SECRET"), "/foo", "user") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("OURA_KEY")) + a.Equal(p.Secret, os.Getenv("OURA_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_ImplementsProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*oura.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://cloud.ouraring.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://cloud.ouraring.com/oauth/authorize","AccessToken":"1234567890","UserID":"abc"}`) + a.NoError(err) + + s := session.(*oura.Session) + a.Equal(s.AuthURL, "https://cloud.ouraring.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") + a.Equal(s.UserID, "abc") +} diff --git a/providers/oura/session.go b/providers/oura/session.go new file mode 100644 index 000000000..b164293bf --- /dev/null +++ b/providers/oura/session.go @@ -0,0 +1,64 @@ +package oura + +import ( + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Oura. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + UserID string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the +// Oura provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize completes the authorization with Oura and returns the access +// token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + if userID, ok := token.Extra("user_id").(string); ok { + s.UserID = userID + } + return token.AccessToken, err +} + +// Marshal marshals a session into a JSON string. +func (s Session) Marshal() string { + j, _ := json.Marshal(s) + return string(j) +} + +// String is equivalent to Marshal. It returns a JSON representation of the session. +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := Session{} + err := json.Unmarshal([]byte(data), &s) + return &s, err +} diff --git a/providers/oura/session_test.go b/providers/oura/session_test.go new file mode 100644 index 000000000..2b986463e --- /dev/null +++ b/providers/oura/session_test.go @@ -0,0 +1,38 @@ +package oura_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/oura" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &oura.Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &oura.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &oura.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","UserID":""}`) +} diff --git a/providers/patreon/patreon.go b/providers/patreon/patreon.go new file mode 100644 index 000000000..b960aa242 --- /dev/null +++ b/providers/patreon/patreon.go @@ -0,0 +1,219 @@ +package patreon + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + // AuthorizationURL specifies Patreon's OAuth2 authorization endpoint (see https://tools.ietf.org/html/rfc6749#section-3.1). + // See Example_refreshToken for examples. + authorizationURL = "https://www.patreon.com/oauth2/authorize" + + // AccessTokenURL specifies Patreon's OAuth2 token endpoint (see https://tools.ietf.org/html/rfc6749#section-3.2). + // See Example_refreshToken for examples. + tokenURL = "https://www.patreon.com/api/oauth2/token" + + profileURL = "https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=created,email,full_name,image_url,vanity" +) + +//goland:noinspection GoUnusedConst +const ( + // ScopeIdentity provides read access to data about the user. See the /identity endpoint documentation for details about what data is available. + ScopeIdentity = "identity" + + // ScopeIdentityEmail provides read access to the user’s email. + ScopeIdentityEmail = "identity[email]" + + // ScopeIdentityMemberships provides read access to the user’s memberships. + ScopeIdentityMemberships = "identity.memberships" + + // ScopeCampaigns provides read access to basic campaign data. See the /campaign endpoint documentation for details about what data is available. + ScopeCampaigns = "campaigns" + + // ScopeCampaignsWebhook provides read, write, update, and delete access to the campaign’s webhooks created by the client. + ScopeCampaignsWebhook = "w:campaigns.webhook" + + // ScopeCampaignsMembers provides read access to data about a campaign’s members. See the /members endpoint documentation for details about what data is available. Also allows the same information to be sent via webhooks created by your client. + ScopeCampaignsMembers = "campaigns.members" + + // ScopeCampaignsMembersEmail provides read access to the member’s email. Also allows the same information to be sent via webhooks created by your client. + ScopeCampaignsMembersEmail = "campaigns.members[email]" + + // ScopeCampaignsMembersAddress provides read access to the member’s address, if an address was collected in the pledge flow. Also allows the same information to be sent via webhooks created by your client. + ScopeCampaignsMembersAddress = "campaigns.members.address" + + // ScopeCampaignsPosts provides read access to the posts on a campaign. + ScopeCampaignsPosts = "campaigns.posts" +) + +// New creates a new Patreon provider and sets up important connection details. +// You should always call `patreon.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(clientKey, secret, callbackURL, authorizationURL, tokenURL, profileURL, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "patreon", + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Patreon. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + authURL string + tokenURL string + profileURL string +} + +// Name gets the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the Patreon package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Patreon for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Patreon and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", p.profileURL, nil) + if err != nil { + return user, err + } + + req.Header.Add("authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Data struct { + Attributes struct { + Created time.Time `json:"created"` + Email string `json:"email"` + FullName string `json:"full_name"` + ImageURL string `json:"image_url"` + Vanity string `json:"vanity"` + } `json:"attributes"` + ID string `json:"id"` + } `json:"data"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Data.Attributes.Email + user.Name = u.Data.Attributes.FullName + user.NickName = u.Data.Attributes.Vanity + user.UserID = u.Data.ID + user.AvatarURL = u.Data.Attributes.ImageURL + return nil +} diff --git a/providers/patreon/patreon_test.go b/providers/patreon/patreon_test.go new file mode 100644 index 000000000..a2ec13d3b --- /dev/null +++ b/providers/patreon/patreon_test.go @@ -0,0 +1,53 @@ +package patreon + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func provider() *Provider { + return New(os.Getenv("PATREON_KEY"), os.Getenv("PATREON_SECRET"), "/foo", "user") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("PATREON_KEY")) + a.Equal(p.Secret, os.Getenv("PATREON_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_ImplementsProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.patreon.com/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"http://www.patreon.com/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*Session) + a.Equal(s.AuthURL, "http://www.patreon.com/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} diff --git a/providers/patreon/session.go b/providers/patreon/session.go new file mode 100644 index 000000000..7e5f22f03 --- /dev/null +++ b/providers/patreon/session.go @@ -0,0 +1,63 @@ +package patreon + +import ( + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Patreon. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the +// Patreon provider. +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize completes the authorization with Patreon and returns the access +// token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal marshals a session into a JSON string. +func (s *Session) Marshal() string { + j, _ := json.Marshal(s) + return string(j) +} + +// String is equivalent to Marshal. It returns a JSON representation of the session. +func (s *Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := Session{} + err := json.Unmarshal([]byte(data), &s) + return &s, err +} diff --git a/providers/patreon/session_test.go b/providers/patreon/session_test.go new file mode 100644 index 000000000..7b2e7a4e9 --- /dev/null +++ b/providers/patreon/session_test.go @@ -0,0 +1,37 @@ +package patreon + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} diff --git a/providers/paypal/paypal.go b/providers/paypal/paypal.go new file mode 100644 index 000000000..64579f6dc --- /dev/null +++ b/providers/paypal/paypal.go @@ -0,0 +1,199 @@ +// Package paypal implements the OAuth2 protocol for authenticating users through paypal. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package paypal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + sandbox string = "sandbox" + envKey string = "PAYPAL_ENV" + + // Endpoints for paypal sandbox env + authURLSandbox string = "https://www.sandbox.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize" + tokenURLSandbox string = "https://www.sandbox.paypal.com/webapps/auth/protocol/openidconnect/v1/tokenservice" + endpointProfileSandbox string = "https://www.sandbox.paypal.com/webapps/auth/protocol/openidconnect/v1/userinfo" + + // Endpoints for paypal production env + authURLProduction string = "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize" + tokenURLProduction string = "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/tokenservice" + endpointProfileProduction string = "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/userinfo" +) + +// Provider is the implementation of `goth.Provider` for accessing Paypal. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + profileURL string +} + +// New creates a new Paypal provider and sets up important connection details. +// You should always call `paypal.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + paypalEnv := os.Getenv(envKey) + + authURL := authURLProduction + tokenURL := tokenURLProduction + profileEndPoint := endpointProfileProduction + + if paypalEnv == sandbox { + authURL = authURLSandbox + tokenURL = tokenURLSandbox + profileEndPoint = endpointProfileSandbox + } + + return NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileEndPoint, scopes...) +} + +// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to +func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "paypal", + profileURL: profileURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the paypal package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Paypal for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Paypal and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(p.profileURL + "?schema=openid&access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "profile", "email") + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"name"` + Address struct { + Locality string `json:"locality"` + } `json:"address"` + Email string `json:"email"` + ID string `json:"user_id"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.UserID = u.ID + user.Location = u.Address.Locality + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/paypal/paypal_test.go b/providers/paypal/paypal_test.go new file mode 100644 index 000000000..f7e9f99be --- /dev/null +++ b/providers/paypal/paypal_test.go @@ -0,0 +1,67 @@ +package paypal_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/paypal" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("PAYPAL_KEY")) + a.Equal(p.Secret, os.Getenv("PAYPAL_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := urlCustomisedURLProvider() + session, err := p.BeginAuth("test_state") + s := session.(*paypal.Session) + a.NoError(err) + a.Contains(s.AuthURL, "http://authURL") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*paypal.Session) + a.NoError(err) + a.Contains(s.AuthURL, "paypal.com/webapps/auth/protocol/openidconnect/v1/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*paypal.Session) + a.Equal(s.AuthURL, "https://www.paypal.com/webapps/auth/protocol/openidconnect/v1/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *paypal.Provider { + return paypal.New(os.Getenv("PAYPAL_KEY"), os.Getenv("PAYPAL_SECRET"), "/foo") +} + +func urlCustomisedURLProvider() *paypal.Provider { + return paypal.NewCustomisedURL(os.Getenv("PAYPAL_KEY"), os.Getenv("PAYPAL_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL") +} diff --git a/providers/paypal/session.go b/providers/paypal/session.go new file mode 100644 index 000000000..0e099b3f2 --- /dev/null +++ b/providers/paypal/session.go @@ -0,0 +1,63 @@ +package paypal + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with PayPal. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the PayPal provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with PayPal and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/paypal/session_test.go b/providers/paypal/session_test.go new file mode 100644 index 000000000..e8b597591 --- /dev/null +++ b/providers/paypal/session_test.go @@ -0,0 +1,48 @@ +package paypal_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/paypal" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &paypal.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &paypal.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &paypal.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &paypal.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/reddit/reddit.go b/providers/reddit/reddit.go new file mode 100644 index 000000000..f3d328599 --- /dev/null +++ b/providers/reddit/reddit.go @@ -0,0 +1,137 @@ +package reddit + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL = "https://www.reddit.com/api/v1/authorize" +) + +type Provider struct { + providerName string + duration string + config oauth2.Config + client http.Client + // TODO: userURL should be a constant + userURL string +} + +func New(clientID string, clientSecret string, redirectURI string, duration string, tokenEndpoint string, userURL string, scopes ...string) Provider { + return Provider{ + providerName: "reddit", + duration: duration, + config: oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenEndpoint, + AuthStyle: 0, + }, + RedirectURL: redirectURI, + Scopes: scopes, + }, + client: http.Client{}, + userURL: userURL, + } +} + +func (p *Provider) Name() string { + return p.providerName +} + +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) UnmarshalSession(s string) (goth.Session, error) { + session := &Session{} + err := json.Unmarshal([]byte(s), session) + if err != nil { + return nil, err + } + + return session, nil +} + +func (p *Provider) Debug(b bool) {} + +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} + +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authCodeOption := oauth2.SetAuthURLParam("duration", p.duration) + return &Session{AuthURL: p.config.AuthCodeURL(state, authCodeOption)}, nil +} + +type redditResponse struct { + Id string `json:"id"` + Name string `json:"name"` +} + +func (p *Provider) FetchUser(s goth.Session) (goth.User, error) { + session := s.(*Session) + request, err := http.NewRequest("GET", p.userURL, nil) + if err != nil { + return goth.User{}, err + } + + bearer := "Bearer " + session.AccessToken + request.Header.Add("Authorization", bearer) + + res, err := p.client.Do(request) + if err != nil { + return goth.User{}, err + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + if res.StatusCode == http.StatusForbidden { + return goth.User{}, fmt.Errorf("%s responded with a %s because you did not provide the identity scope which is required to fetch user profile", p.providerName, res.Status) + } + return goth.User{}, fmt.Errorf("%s responded with a %d trying to fetch user profile", p.providerName, res.StatusCode) + } + + bits, err := io.ReadAll(res.Body) + if err != nil { + return goth.User{}, err + } + + var r redditResponse + + err = json.Unmarshal(bits, &r) + if err != nil { + return goth.User{}, err + } + + gothUser := goth.User{ + RawData: nil, + Provider: p.Name(), + Name: r.Name, + UserID: r.Id, + AccessToken: session.AccessToken, + RefreshToken: session.RefreshToken, + ExpiresAt: time.Time{}, + } + + err = json.Unmarshal(bits, &gothUser.RawData) + if err != nil { + return goth.User{}, err + } + + return gothUser, nil +} diff --git a/providers/reddit/reddit_test.go b/providers/reddit/reddit_test.go new file mode 100644 index 000000000..5b57d5e41 --- /dev/null +++ b/providers/reddit/reddit_test.go @@ -0,0 +1,88 @@ +package reddit + +import ( + "encoding/json" + "github.com/markbates/goth" + "golang.org/x/oauth2" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" +) + +var response = redditResponse{ + Id: "invader21", + Name: "JohnDoe", +} + +func TestProvider(t *testing.T) { + t.Run("create a new provider", func(t *testing.T) { + got := New("client id", "client secret", "redirect uri", "duration", "example.com", "userURL", "scope1", "scope2", "scope 3") + want := Provider{ + providerName: "reddit", + duration: "duration", + config: oauth2.Config{ + ClientID: "client id", + ClientSecret: "client secret", + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: "example.com", + AuthStyle: 0, + }, + RedirectURL: "redirect uri", + Scopes: []string{"scope1", "scope2", "scope 3"}, + }, + userURL: "userURL", + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("\033[31;1;4mgot\033[0m %+v, \n\t \033[31;1;4mwant\033[0m %+v", got, want) + } + }) + + t.Run("fetch reddit user that created the given session", func(t *testing.T) { + redditServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + b, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + writer.Header().Add("Content-Type", "application/json") + writer.Write(b) + })) + + defer redditServer.Close() + + userURL := redditServer.URL + p := New("client id", "client secret", "redirect uri", "duration", "example.com", userURL, "scope1", "scope2", "scope 3") + s := &Session{ + AuthURL: "", + AccessToken: "i am a token", + TokenType: "bearer", + RefreshToken: "your refresh token", + Expiry: time.Time{}, + } + + got, err := p.FetchUser(s) + if err != nil { + t.Errorf("did not expect an error: %s", err) + } + + want := goth.User{ + RawData: map[string]interface{}{ + "id": "invader21", + "name": "JohnDoe", + }, + Provider: "reddit", + Name: "JohnDoe", + UserID: "invader21", + AccessToken: "i am a token", + RefreshToken: "your refresh token", + ExpiresAt: time.Time{}, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("\033[31;1;4mgot\033[0m %+v, \n\t\t \033[31;1;4mwant\033[0m %+v", got, want) + } + }) +} diff --git a/providers/reddit/session.go b/providers/reddit/session.go new file mode 100644 index 000000000..1d646992f --- /dev/null +++ b/providers/reddit/session.go @@ -0,0 +1,46 @@ +package reddit + +import ( + "context" + "encoding/json" + "errors" + "github.com/markbates/goth" + "golang.org/x/oauth2" + "time" +) + +type Session struct { + AuthURL string + AccessToken string `json:"access_token"` + TokenType string `json:"token_type,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` +} + +func (s *Session) GetAuthURL() (string, error) { + return s.AuthURL, nil +} + +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + t, err := p.config.Exchange(context.WithValue(context.Background(), oauth2.HTTPClient, p.client), params.Get("code")) + if err != nil { + return "", err + } + + if !t.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = t.AccessToken + s.TokenType = t.TokenType + s.RefreshToken = t.RefreshToken + s.Expiry = t.Expiry + + return s.AccessToken, nil +} diff --git a/providers/reddit/session_test.go b/providers/reddit/session_test.go new file mode 100644 index 000000000..72de35c7a --- /dev/null +++ b/providers/reddit/session_test.go @@ -0,0 +1,122 @@ +package reddit + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +var validAuthResponseTestData = struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + RefreshToken string `json:"refresh_token"` +}{ + AccessToken: "i am a token", + TokenType: "type", + ExpiresIn: 120, + Scope: "identity", + RefreshToken: "your refresh token", +} + +var invalidAuthResponseTestData = struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + RefreshToken string `json:"refresh_token"` +}{ + AccessToken: "", + TokenType: "type", + ExpiresIn: 120, + Scope: "identity", + RefreshToken: "Your refresh token", +} + +func TestSession(t *testing.T) { + t.Run("gets the URL for the authentication end-point for the provider", func(t *testing.T) { + s := Session{AuthURL: "example.com"} + got, err := s.GetAuthURL() + if err != nil { + t.Fatal("should return a url string") + } + + want := "example.com" + + if got != want { + t.Errorf("got %q want %q", got, want) + } + }) + + t.Run("generates a string representation of the session", func(t *testing.T) { + s := Session{ + AuthURL: "example", + } + got := s.Marshal() + want := `{"AuthURL":"example","access_token":"","expiry":"0001-01-01T00:00:00Z"}` + + if got != want { + t.Errorf("got %q want %q", got, want) + } + }) + + t.Run("return an access token", func(t *testing.T) { + + s := Session{AuthURL: "example.com"} + authServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + b, err := json.Marshal(validAuthResponseTestData) + if err != nil { + writer.WriteHeader(http.StatusInternalServerError) + return + } + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write(b) + })) + + tokenURL := authServer.URL + + p := New("CLIENT_ID", "CLIENT_SECRET", "URI", "DURATION", tokenURL, "SCOPE_STRING1", "SCOPE_STRING2") + u := url.Values{} + u.Set("code", "12345678") + + got, err := s.Authorize(&p, u) + if err != nil { + t.Fatal("did not expect an error: ", err) + } + + want := validAuthResponseTestData.AccessToken + + if got != want { + t.Errorf("got %q want %q", got, want) + } + }) + + t.Run("validates access token", func(t *testing.T) { + s := Session{AuthURL: "example.com"} + authServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + b, err := json.Marshal(invalidAuthResponseTestData) + if err != nil { + writer.WriteHeader(http.StatusInternalServerError) + return + } + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write(b) + })) + + tokenURL := authServer.URL + + p := New("CLIENT_ID", "CLIENT_SECRET", "URI", "DURATION", tokenURL, "SCOPE_STRING1", "SCOPE_STRING2") + u := url.Values{} + u.Set("code", "12345678") + + _, err := s.Authorize(&p, u) + if err == nil { + t.Errorf("expected an error but didn't get one") + } + }) +} diff --git a/providers/salesforce/salesforce.go b/providers/salesforce/salesforce.go new file mode 100644 index 000000000..d4a1c3f50 --- /dev/null +++ b/providers/salesforce/salesforce.go @@ -0,0 +1,191 @@ +// Package salesforce implements the OAuth2 protocol for authenticating users through salesforce. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package salesforce + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// These vars define the Authentication and Token URLS for Salesforce. If +// using Salesforce Community, you should change these values before calling New. +// +// Examples: +// +// salesforce.AuthURL = "https://salesforce.acme.com/services/oauth2/authorize +// salesforce.TokenURL = "https://salesforce.acme.com/services/oauth2/token +var ( + AuthURL = "https://login.salesforce.com/services/oauth2/authorize" + TokenURL = "https://login.salesforce.com/services/oauth2/token" + + // endpointProfile string = "https://api.salesforce.com/2.0/users/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Salesforce. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Salesforce provider and sets up important connection details. +// You should always call `salesforce.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "salesforce", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the salesforce package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Salesforce for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Salesforce and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + url, err := url.Parse(s.ID) + if err != nil { + return user, err + } + + // creating dynamic url to retrieve user information + userURL := url.Scheme + "://" + url.Host + "/" + url.Path + req, err := http.NewRequest("GET", userURL, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: AuthURL, + TokenURL: TokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + var rawData map[string]interface{} + + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(r) + if err != nil { + return err + } + + err = json.Unmarshal(buf.Bytes(), &rawData) + if err != nil { + return err + } + + u := struct { + Name string `json:"display_name"` + NickName string `json:"nick_name"` + Location string `json:"addr_country"` + Email string `json:"email"` + AvatarURL string `json:"photos.picture"` + ID string `json:"user_id"` + }{} + + err = json.Unmarshal(buf.Bytes(), &u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.NickName = u.Name + user.UserID = u.ID + user.Location = u.Location + user.RawData = rawData + + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/salesforce/salesforce_test.go b/providers/salesforce/salesforce_test.go new file mode 100644 index 000000000..e983bff5a --- /dev/null +++ b/providers/salesforce/salesforce_test.go @@ -0,0 +1,53 @@ +package salesforce_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/salesforce" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("SALESFORCE_KEY")) + a.Equal(p.Secret, os.Getenv("SALESFORCE_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*salesforce.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.salesforce.com/services/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://login.salesforce.com/services/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*salesforce.Session) + a.Equal(s.AuthURL, "https://login.salesforce.com/services/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *salesforce.Provider { + return salesforce.New(os.Getenv("SALESFORCE_KEY"), os.Getenv("SALESFORCE_SECRET"), "/foo") +} diff --git a/providers/salesforce/session.go b/providers/salesforce/session.go new file mode 100644 index 000000000..1d2ffd12e --- /dev/null +++ b/providers/salesforce/session.go @@ -0,0 +1,72 @@ +package salesforce + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Salesforce. +// Expiry of access token is not provided by Salesforce, it is just controlled by timeout configured in auth2 settings +// by individual users +// Only way to check whether access token has expired or not is based on the response you receive if you try using +// access token and get some error +// Also, For salesforce refresh token to work follow these else remove scopes from here +// On salesforce.com, navigate to where you app is configured. (Setup > Create > Apps) +// Under Connected Apps, click on your application's name to view its settings, then click Edit. +// Under Selected OAuth Scopes, ensure that "Perform requests on your behalf at any time" is selected. You must include this even if you already chose "Full access". +// Save, then try your OAuth flow again. It takes a short while for the update to propagate. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ID string // Required to get the user info from sales force +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Salesforce provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Salesforce and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ID = token.Extra("id").(string) // Required to get the user info from sales force + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/salesforce/session_test.go b/providers/salesforce/session_test.go new file mode 100644 index 000000000..b0a4d9b97 --- /dev/null +++ b/providers/salesforce/session_test.go @@ -0,0 +1,48 @@ +package salesforce_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/salesforce" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &salesforce.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &salesforce.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &salesforce.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ID":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &salesforce.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/seatalk/seatalk.go b/providers/seatalk/seatalk.go new file mode 100644 index 000000000..e399eb495 --- /dev/null +++ b/providers/seatalk/seatalk.go @@ -0,0 +1,161 @@ +package seatalk + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Endpoint is SeaTalk's OAuth 2.0 endpoint. +var Endpoint = oauth2.Endpoint{ + AuthURL: "https://seatalkweb.com/webapp/oauth2/authorize", + TokenURL: "https://seatalkweb.com/webapp/oauth2/token", +} + +const endpointProfile string = "https://seatalkweb.com/webapp/oauth2/profile" + +// Provider is the implementation of `goth.Provider` for accessing SeaTalk. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + config *oauth2.Config + providerName string +} + +// New creates a new SeaTalk provider and sets up important connection details. +// You should always call `seatalk.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "seatalk", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// BeginAuth asks SeaTalk for an authentication endpoint. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} + +type seatalkUser struct { + ID string `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// FetchUser will go to SeaTalk and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // Data is not yet retrieved, since accessToken is still empty. + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := http.Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + var u seatalkUser + if err := json.Unmarshal(responseBytes, &u); err != nil { + return user, err + } + + // Extract the user data we got from Google into our goth.User. + user.Name = u.Name + user.NickName = u.Name + user.Email = u.Email + user.UserID = u.ID + + return user, nil +} + +// Debug is a no-op for the seatalk package. +func (p *Provider) Debug(bool) { + +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(context.Background(), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: Endpoint, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = []string{"email"} + } + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} diff --git a/providers/seatalk/seatalk_test.go b/providers/seatalk/seatalk_test.go new file mode 100644 index 000000000..07ede5768 --- /dev/null +++ b/providers/seatalk/seatalk_test.go @@ -0,0 +1,53 @@ +package seatalk_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/seatalk" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("SEATALK_KEY")) + a.Equal(p.Secret, os.Getenv("SEATALK_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*seatalk.Session) + a.NoError(err) + a.Contains(s.AuthURL, "seatalkweb.com/webapp/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://seatalkweb.com/webapp/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*seatalk.Session) + a.Equal(s.AuthURL, "https://seatalkweb.com/webapp/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *seatalk.Provider { + return seatalk.New(os.Getenv("SEATALK_KEY"), os.Getenv("SEATALK_SECRET"), "/foo") +} diff --git a/providers/seatalk/session.go b/providers/seatalk/session.go new file mode 100644 index 000000000..e0e474a93 --- /dev/null +++ b/providers/seatalk/session.go @@ -0,0 +1,54 @@ +package seatalk + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with SeaTalk. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Google provider. +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with SeaTalk and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(context.Background(), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} diff --git a/providers/seatalk/session_test.go b/providers/seatalk/session_test.go new file mode 100644 index 000000000..d6693208f --- /dev/null +++ b/providers/seatalk/session_test.go @@ -0,0 +1,48 @@ +package seatalk_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/seatalk" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &seatalk.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &seatalk.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &seatalk.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &seatalk.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/shopify/scopes.go b/providers/shopify/scopes.go new file mode 100644 index 000000000..52c8e52db --- /dev/null +++ b/providers/shopify/scopes.go @@ -0,0 +1,49 @@ +package shopify + +// Define scopes supported by Shopify. +// See: https://help.shopify.com/en/api/getting-started/authentication/oauth/scopes#authenticated-access-scopes +const ( + ScopeReadContent = "read_content" + ScopeWriteContent = "write_content" + ScopeReadThemes = "read_themes" + ScopeWriteThemes = "write_themes" + ScopeReadProducts = "read_products" + ScopeWriteProducts = "write_products" + ScopeReadProductListings = "read_product_listings" + ScopeReadCustomers = "read_customers" + ScopeWriteCustomers = "write_customers" + ScopeReadOrders = "read_orders" + ScopeWriteOrders = "write_orders" + ScopeReadDrafOrders = "read_draft_orders" + ScopeWriteDrafOrders = "write_draft_orders" + ScopeReadInventory = "read_inventory" + ScopeWriteInventory = "write_inventory" + ScopeReadLocations = "read_locations" + ScopeReadScriptTags = "read_script_tags" + ScopeWriteScriptTags = "write_script_tags" + ScopeReadFulfillments = "read_fulfillments" + ScopeWriteFulfillments = "write_fulfillments" + ScopeReadShipping = "read_shipping" + ScopeWriteShipping = "write_shipping" + ScopeReadAnalytics = "read_analytics" + ScopeReadUsers = "read_users" + ScopeWriteUsers = "write_users" + ScopeReadCheckouts = "read_checkouts" + ScopeWriteCheckouts = "write_checkouts" + ScopeReadReports = "read_reports" + ScopeWriteReports = "write_reports" + ScopeReadPriceRules = "read_price_rules" + ScopeWritePriceRules = "write_price_rules" + ScopeMarketingEvents = "read_marketing_events" + ScopeWriteMarketingEvents = "write_marketing_events" + ScopeReadResourceFeedbacks = "read_resource_feedbacks" + ScopeWriteResourceFeedbacks = "write_resource_feedbacks" + ScopeReadShopifyPaymentsPayouts = "read_shopify_payments_payouts" + ScopeReadShopifyPaymentsDisputes = "read_shopify_payments_disputes" + + // Special: + // Grants access to all orders rather than the default window of 60 days worth of orders. + // This OAuth scope is used in conjunction with read_orders, or write_orders. You need to request + // this scope from your Partner Dashboard before adding it to your app. + ScopeReadAllOrders = "read_all_orders" +) diff --git a/providers/shopify/session.go b/providers/shopify/session.go new file mode 100644 index 000000000..ba9e7e95a --- /dev/null +++ b/providers/shopify/session.go @@ -0,0 +1,103 @@ +package shopify + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/markbates/goth" +) + +const ( + shopifyHostnameRegex = `^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$` +) + +// Session stores data during the auth process with Shopify. +type Session struct { + AuthURL string + AccessToken string + Hostname string + HMAC string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Shopify provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Shopify and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + // Validate the incoming HMAC is valid. + // See: https://help.shopify.com/en/api/getting-started/authentication/oauth#verification + digest := fmt.Sprintf( + "code=%s&host=%s&shop=%s&state=%s×tamp=%s", + params.Get("code"), + params.Get("host"), + params.Get("shop"), + params.Get("state"), + params.Get("timestamp"), + ) + h := hmac.New(sha256.New, []byte(os.Getenv("SHOPIFY_SECRET"))) + h.Write([]byte(digest)) + sha := hex.EncodeToString(h.Sum(nil)) + + // Ensure our HMAC hash's match. + if sha != params.Get("hmac") { + return "", errors.New("Invalid HMAC received") + } + + // Validate the hostname matches what we're expecting. + // See: https://help.shopify.com/en/api/getting-started/authentication/oauth#step-3-confirm-installation + re := regexp.MustCompile(shopifyHostnameRegex) + if !re.MatchString(params.Get("shop")) { + return "", errors.New("Invalid hostname received") + } + + // Make the exchange for an access token. + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + // Ensure it's valid. + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.Hostname = params.Get("hostname") + s.HMAC = params.Get("hmac") + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/shopify/session_test.go b/providers/shopify/session_test.go new file mode 100644 index 000000000..85ea9adc0 --- /dev/null +++ b/providers/shopify/session_test.go @@ -0,0 +1,48 @@ +package shopify_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/shopify" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","Hostname":"","HMAC":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &shopify.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/shopify/shopify.go b/providers/shopify/shopify.go new file mode 100644 index 000000000..9b1450680 --- /dev/null +++ b/providers/shopify/shopify.go @@ -0,0 +1,192 @@ +// Package shopify implements the OAuth2 protocol for authenticating users through Shopify. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package shopify + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + providerName = "shopify" + + // URL protocol and subdomain will be populated by newConfig(). + authURL = "myshopify.com/admin/oauth/authorize" + tokenURL = "myshopify.com/admin/oauth/access_token" + endpointProfile = "myshopify.com/admin/api/2019-04/shop.json" +) + +// Provider is the implementation of `goth.Provider` for accessing Shopify. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + shopName string + scopes []string +} + +// New creates a new Shopify provider and sets up important connection details. +// You should always call `shopify.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: providerName, + scopes: scopes, + } + p.config = newConfig(p, scopes) + return p +} + +// Client is HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// SetShopName is to update the shopify shop name, needed when interfacing with different shops. +func (p *Provider) SetShopName(name string) { + p.shopName = name + + // Reparse config with the new shop name. + p.config = newConfig(p, p.scopes) +} + +// Debug is a no-op for the Shopify package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Shopify for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by Shopify") +} + +// FetchUser will go to Shopify and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + shop := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + } + + if shop.AccessToken == "" { + // Data is not yet retrieved since accessToken is still empty. + return shop, fmt.Errorf("%s cannot get shop information without accessToken", p.providerName) + } + + // Build the request. + req, err := http.NewRequest("GET", fmt.Sprintf("https://%s.%s", p.shopName, endpointProfile), nil) + if err != nil { + return shop, err + } + req.Header.Set("X-Shopify-Access-Token", s.AccessToken) + + // Execute the request. + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return shop, err + } + defer resp.Body.Close() + + // Check our response status. + if resp.StatusCode != http.StatusOK { + return shop, fmt.Errorf("%s responded with a %d trying to fetch shop information", p.providerName, resp.StatusCode) + } + + // Parse response. + return shop, shopFromReader(resp.Body, &shop) +} + +func shopFromReader(r io.Reader, shop *goth.User) error { + rsp := struct { + Shop struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + City string `json:"city"` + Country string `json:"country"` + ShopOwner string `json:"shop_owner"` + MyShopifyDomain string `json:"myshopify_domain"` + PlanDisplayName string `json:"plan_display_name"` + } `json:"shop"` + }{} + + err := json.NewDecoder(r).Decode(&rsp) + if err != nil { + return err + } + + shop.UserID = strconv.Itoa(int(rsp.Shop.ID)) + shop.Name = rsp.Shop.Name + shop.Email = rsp.Shop.Email + shop.Description = fmt.Sprintf("%s (%s)", rsp.Shop.MyShopifyDomain, rsp.Shop.PlanDisplayName) + shop.Location = fmt.Sprintf("%s, %s", rsp.Shop.City, rsp.Shop.Country) + shop.AvatarURL = "Not provided by the Shopify API" + shop.NickName = "Not provided by the Shopify API" + + return nil +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("https://%s.%s", p.shopName, authURL), + TokenURL: fmt.Sprintf("https://%s.%s", p.shopName, tokenURL), + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for i, scope := range scopes { + // Shopify require comma separated scopes. + s := fmt.Sprintf("%s,", scope) + if i == len(scopes)+1 { + s = scope + } + c.Scopes = append(c.Scopes, s) + } + } else { + // Default to a read customers scope. + c.Scopes = append(c.Scopes, ScopeReadCustomers) + } + + return c +} diff --git a/providers/shopify/shopify_test.go b/providers/shopify/shopify_test.go new file mode 100644 index 000000000..393a887c3 --- /dev/null +++ b/providers/shopify/shopify_test.go @@ -0,0 +1,55 @@ +package shopify_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/shopify" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("SHOPIFY_KEY")) + a.Equal(p.Secret, os.Getenv("SHOPIFY_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*shopify.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://test-shop.myshopify.com/admin/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://test-shop.myshopify.com/admin/oauth/authorize","AccessToken":"1234567890"}"`) + a.NoError(err) + + s := session.(*shopify.Session) + a.Equal(s.AuthURL, "https://test-shop.myshopify.com/admin/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *shopify.Provider { + p := shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "/foo") + p.SetShopName("test-shop") + return p +} diff --git a/providers/slack/session.go b/providers/slack/session.go new file mode 100644 index 000000000..83d66f9e9 --- /dev/null +++ b/providers/slack/session.go @@ -0,0 +1,63 @@ +package slack + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Slack. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Slack provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Slack and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/slack/session_test.go b/providers/slack/session_test.go new file mode 100644 index 000000000..5364c1f76 --- /dev/null +++ b/providers/slack/session_test.go @@ -0,0 +1,48 @@ +package slack_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/slack" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &slack.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &slack.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &slack.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &slack.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/slack/slack.go b/providers/slack/slack.go new file mode 100644 index 000000000..daec6f422 --- /dev/null +++ b/providers/slack/slack.go @@ -0,0 +1,236 @@ +// Package slack implements the OAuth2 protocol for authenticating users through slack. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package slack + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Scopes +const ( + ScopeUserRead string = "users:read" +) + +// URLs and endpoints +const ( + authURL string = "https://slack.com/oauth/authorize" + tokenURL string = "https://slack.com/api/oauth.access" + endpointUser string = "https://slack.com/api/auth.test" + endpointProfile string = "https://slack.com/api/users.info" +) + +// Provider is the implementation of `goth.Provider` for accessing Slack. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Slack provider and sets up important connection details. +// You should always call `slack.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "slack", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns the http.Client used in the provider. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the slack package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Slack for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Slack and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // Get the userID, Slack needs userID in order to get user profile info + req, _ := http.NewRequest("GET", endpointUser, nil) + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = simpleUserFromReader(bytes.NewReader(bits), &user) + + if p.hasScope(ScopeUserRead) { + // Get user profile info + req, _ := http.NewRequest("GET", endpointProfile+"?user="+user.UserID, nil) + req.Header.Add("Authorization", "Bearer "+sess.AccessToken) + response, err = p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err = io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + } + + return user, err +} + +func (p *Provider) hasScope(scope string) bool { + hasScope := false + + for i := range p.config.Scopes { + if p.config.Scopes[i] == scope { + hasScope = true + break + } + } + + return hasScope +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, ScopeUserRead) + } + return c +} + +func simpleUserFromReader(r io.Reader, user *goth.User) error { + u := struct { + UserID string `json:"user_id"` + Name string `json:"user"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + user.UserID = u.UserID + user.NickName = u.Name + + return nil +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + User struct { + NickName string `json:"name"` + ID string `json:"id"` + Profile struct { + Email string `json:"email"` + Name string `json:"real_name"` + AvatarURL string `json:"image_32"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } `json:"profile"` + } `json:"user"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.User.Profile.Email + user.Name = u.User.Profile.Name + user.NickName = u.User.NickName + user.UserID = u.User.ID + user.AvatarURL = u.User.Profile.AvatarURL + user.FirstName = u.User.Profile.FirstName + user.LastName = u.User.Profile.LastName + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} diff --git a/providers/slack/slack_test.go b/providers/slack/slack_test.go new file mode 100644 index 000000000..dd6d59556 --- /dev/null +++ b/providers/slack/slack_test.go @@ -0,0 +1,236 @@ +package slack_test + +import ( + "context" + "crypto/tls" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/slack" + "github.com/stretchr/testify/assert" +) + +var ( + testAuthTestResponseData = map[string]interface{}{ + "user": "testuser", + "user_id": "user1234", + } + + testUserInfoResponseData = map[string]interface{}{ + "user": map[string]interface{}{ + "id": testAuthTestResponseData["user_id"], + "name": testAuthTestResponseData["user"], + "profile": map[string]interface{}{ + "real_name": "Test User", + "first_name": "Test", + "last_name": "User", + "image_32": "http://example.org/avatar.png", + "email": "test@example.org", + }, + }, + } +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("SLACK_KEY")) + a.Equal(p.Secret, os.Getenv("SLACK_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*slack.Session) + a.NoError(err) + a.Contains(s.AuthURL, "slack.com/oauth/authorize") +} + +func Test_FetchUser(t *testing.T) { + t.Parallel() + + for _, testData := range []struct { + name string + provider *slack.Provider + session goth.Session + handler http.Handler + expectedUser goth.User + expectErr bool + }{ + { + name: "FetchesFullProfile", + provider: provider(), + session: &slack.Session{AccessToken: "TOKEN"}, + handler: http.HandlerFunc( + func(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/auth.test": + res.WriteHeader(http.StatusOK) + json.NewEncoder(res).Encode(testAuthTestResponseData) + case "/api/users.info": + res.WriteHeader(http.StatusOK) + json.NewEncoder(res).Encode(testUserInfoResponseData) + default: + res.WriteHeader(http.StatusNotFound) + } + }, + ), + expectedUser: goth.User{ + UserID: "user1234", + NickName: "testuser", + Name: "Test User", + FirstName: "Test", + LastName: "User", + AvatarURL: "http://example.org/avatar.png", + Email: "test@example.org", + AccessToken: "TOKEN", + }, + expectErr: false, + }, + { + name: "FetchesBasicProfileWhenLackingUserReadScope", + provider: slack.New(os.Getenv("SLACK_KEY"), os.Getenv("SLACK_SECRET"), "/foo", "commands"), + session: &slack.Session{AccessToken: "TOKEN"}, + handler: http.HandlerFunc( + func(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/auth.test": + res.WriteHeader(http.StatusOK) + json.NewEncoder(res).Encode(testAuthTestResponseData) + default: + res.WriteHeader(http.StatusNotFound) + } + }, + ), + expectedUser: goth.User{ + UserID: "user1234", + NickName: "testuser", + AccessToken: "TOKEN", + }, + expectErr: false, + }, + { + name: "FailsWithNoAccessToken", + provider: provider(), + session: &slack.Session{AccessToken: ""}, + handler: nil, + expectErr: true, + }, + { + name: "FailsWithBadAuthTestResponse", + provider: provider(), + session: &slack.Session{AccessToken: "TOKEN"}, + handler: http.HandlerFunc( + func(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/auth.test": + res.WriteHeader(http.StatusForbidden) + } + }, + ), + expectedUser: goth.User{ + AccessToken: "TOKEN", + }, + expectErr: true, + }, + { + name: "FailsWithBadUserInfoResponse", + provider: provider(), + session: &slack.Session{AccessToken: "TOKEN"}, + handler: http.HandlerFunc( + func(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/api/auth.test": + res.WriteHeader(http.StatusOK) + json.NewEncoder(res).Encode(testAuthTestResponseData) + case "/api/users.info": + res.WriteHeader(http.StatusForbidden) + } + }, + ), + expectedUser: goth.User{ + UserID: "user1234", + NickName: "testuser", + AccessToken: "TOKEN", + }, + expectErr: true, + }, + } { + t.Run(testData.name, func(t *testing.T) { + a := assert.New(t) + + withMockServer(testData.provider, testData.handler, func(p *slack.Provider) { + user, err := p.FetchUser(testData.session) + a.NotZero(user) + + if testData.expectErr { + a.Error(err) + } else { + a.NoError(err) + } + + a.Equal(testData.expectedUser.UserID, user.UserID) + a.Equal(testData.expectedUser.NickName, user.NickName) + a.Equal(testData.expectedUser.Name, user.Name) + a.Equal(testData.expectedUser.FirstName, user.FirstName) + a.Equal(testData.expectedUser.LastName, user.LastName) + a.Equal(testData.expectedUser.AvatarURL, user.AvatarURL) + a.Equal(testData.expectedUser.Email, user.Email) + a.Equal(testData.expectedUser.AccessToken, user.AccessToken) + }) + }) + } +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://slack.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*slack.Session) + a.Equal(s.AuthURL, "https://slack.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *slack.Provider { + return slack.New(os.Getenv("SLACK_KEY"), os.Getenv("SLACK_SECRET"), "/foo") +} + +func withMockServer(p *slack.Provider, handler http.Handler, fn func(p *slack.Provider)) { + server := httptest.NewTLSServer(handler) + defer server.Close() + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return net.Dial(network, server.Listener.Addr().String()) + }, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + p.HTTPClient = httpClient + + fn(p) +} diff --git a/providers/soundcloud/session.go b/providers/soundcloud/session.go new file mode 100644 index 000000000..f06bd0edd --- /dev/null +++ b/providers/soundcloud/session.go @@ -0,0 +1,63 @@ +package soundcloud + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Soundcloud. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Soundcloud provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New("an AuthURL has not be set") + } + return s.AuthURL, nil +} + +// Authorize the session with Soundcloud and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/soundcloud/session_test.go b/providers/soundcloud/session_test.go new file mode 100644 index 000000000..56e572af8 --- /dev/null +++ b/providers/soundcloud/session_test.go @@ -0,0 +1,48 @@ +package soundcloud_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/soundcloud" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &soundcloud.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &soundcloud.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &soundcloud.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &soundcloud.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/soundcloud/soundcloud.go b/providers/soundcloud/soundcloud.go new file mode 100644 index 000000000..5e6dff719 --- /dev/null +++ b/providers/soundcloud/soundcloud.go @@ -0,0 +1,169 @@ +// Package soundcloud implements the OAuth2 protocol for authenticating users through soundcloud. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package soundcloud + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://soundcloud.com/connect" + tokenURL string = "https://api.soundcloud.com/oauth2/token" + endpointProfile string = "https://api.soundcloud.com/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Soundcloud. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Soundcloud provider and sets up important connection details. +// You should always call `soundcloud.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "soundcloud", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the soundcloud package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Soundcloud for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Soundcloud and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.Client().Get(endpointProfile + "?oauth_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"full_name"` + NickName string `json:"username"` + ID int `json:"id"` + AvatarURL string `json:"avatar_url"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + // Soundcloud does not provide the email_id + user.Name = u.Name + user.NickName = u.NickName + user.UserID = strconv.Itoa(u.ID) + user.AvatarURL = u.AvatarURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/soundcloud/soundcloud_test.go b/providers/soundcloud/soundcloud_test.go new file mode 100644 index 000000000..3249c21f9 --- /dev/null +++ b/providers/soundcloud/soundcloud_test.go @@ -0,0 +1,53 @@ +package soundcloud_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/soundcloud" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("SOUNDCLOUD_KEY")) + a.Equal(p.Secret, os.Getenv("SOUNDCLOUD_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*soundcloud.Session) + a.NoError(err) + a.Contains(s.AuthURL, "soundcloud.com/connect") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://soundcloud.com/connect","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*soundcloud.Session) + a.Equal(s.AuthURL, "https://soundcloud.com/connect") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *soundcloud.Provider { + return soundcloud.New(os.Getenv("SOUNDCLOUD_KEY"), os.Getenv("SOUNDCLOUD_SECRET"), "/foo") +} diff --git a/providers/spotify/session.go b/providers/spotify/session.go new file mode 100644 index 000000000..3d106faf1 --- /dev/null +++ b/providers/spotify/session.go @@ -0,0 +1,63 @@ +package spotify + +import ( + "encoding/json" + "errors" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Spotify. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the +// Spotify provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize completes the authorization with Spotify and returns the access +// token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal marshals a session into a JSON string. +func (s Session) Marshal() string { + j, _ := json.Marshal(s) + return string(j) +} + +// String is equivalent to Marshal. It returns a JSON representation of the session. +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := Session{} + err := json.Unmarshal([]byte(data), &s) + return &s, err +} diff --git a/providers/spotify/session_test.go b/providers/spotify/session_test.go new file mode 100644 index 000000000..42abfc453 --- /dev/null +++ b/providers/spotify/session_test.go @@ -0,0 +1,38 @@ +package spotify_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/spotify" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &spotify.Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &spotify.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &spotify.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} diff --git a/providers/spotify/spotify.go b/providers/spotify/spotify.go new file mode 100644 index 000000000..f36512c0c --- /dev/null +++ b/providers/spotify/spotify.go @@ -0,0 +1,224 @@ +// Package spotify implements the OAuth protocol for authenticating users through Spotify. +// This package can be used as a reference implementation of an OAuth provider for Goth. +package spotify + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL = "https://accounts.spotify.com/authorize" + tokenURL = "https://accounts.spotify.com/api/token" + userEndpoint = "https://api.spotify.com/v1/me" +) + +const ( + // ScopePlaylistReadPrivate seeks permission to read + // a user's collaborative playlists. + ScopePlaylistReadCollaborative = "playlist-read-collaborative" + // ScopePlaylistReadPrivate seeks permission to read + // a user's private playlists. + ScopePlaylistReadPrivate = "playlist-read-private" + // ScopePlaylistModifyPublic seeks write access + // to a user's public playlists. + ScopePlaylistModifyPublic = "playlist-modify-public" + // ScopePlaylistModifyPrivate seeks write access to + // a user's private playlists. + ScopePlaylistModifyPrivate = "playlist-modify-private" + // ScopeUserFollowModify seeks write/delete access to + // the list of artists and other users that a user follows. + ScopeUserFollowModify = "user-follow-modify" + // ScopeUserFollowRead seeks read access to the list of + // artists and other users that a user follows. + ScopeUserFollowRead = "user-follow-read" + // ScopeUserLibraryModify seeks write/delete access to a + // user's "Your Music" library. + ScopeUserLibraryModify = "user-library-modify" + // ScopeUserLibraryRead seeks read access to a user's + // "Your Music" library. + ScopeUserLibraryRead = "user-library-read" + // ScopeUserReadPrivate seeks read access to a user's + // subsription details (type of user account) + ScopeUserReadPrivate = "user-read-private" + // ScopeUserReadEmail seeks read access to a user's + // email address. + ScopeUserReadEmail = "user-read-email" + // ScopeUGCImageUpload seeks write access to user-provided images. + ScopeUGCImageUpload = "ugc-image-upload" + // ScopeUserReadPlaybackState seeks read access to a user’s player state. + ScopeUserReadPlaybackState = "user-read-playback-state" + // ScopeUserModifyPlaybackState seeks write access to a user’s playback state + ScopeUserModifyPlaybackState = "user-modify-playback-state" + // ScopeUserReadCurrentlyPlaying seeks read access to a user’s currently playing track + ScopeUserReadCurrentlyPlaying = "user-read-currently-playing" + // ScopeStreaming seeks to control playback of a Spotify track. + // This scope is currently available to the Web Playback SDK. + // The user must have a Spotify Premium account. + ScopeStreaming = "streaming" + // ScopeAppRemoteControl seeks remote control playback of Spotify. + // This scope is currently available to Spotify iOS and Android SDKs. + ScopeAppRemoteControl = "app-remote-control" + // ScopeUserTopRead seeks read access to a user's top artists and tracks. + ScopeUserTopRead = "user-top-read" + // ScopeUserReadRecentlyPlayed seeks read access to a user’s recently played tracks. + ScopeUserReadRecentlyPlayed = "user-read-recently-played" +) + +// New creates a new Spotify provider and sets up important connection details. +// You should always call `spotify.New` to get a new Provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "spotify", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Spotify. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name gets the name used to retrieve this provider. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the spotify package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Spotify for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + return session, nil +} + +// FetchUser will go to Spotify and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + // err = userFromReader(io.TeeReader(resp.Body, os.Stdout), &user) + err = userFromReader(resp.Body, &user) + return user, err +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Country string `json:"country"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + ID string `json:"id"` + Images []struct { + URL string `json:"url"` + } `json:"images"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + user.Name = u.DisplayName + user.Email = u.Email + user.UserID = u.ID + user.Location = u.Country + if len(u.Images) > 0 { + user.AvatarURL = u.Images[0].URL + } + return nil +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ScopeUserReadEmail, ScopeUserReadPrivate}, + } + + defaultScopes := map[string]struct{}{ + ScopeUserReadEmail: {}, + ScopeUserReadPrivate: {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/spotify/spotify_test.go b/providers/spotify/spotify_test.go new file mode 100644 index 000000000..5d65e9ce9 --- /dev/null +++ b/providers/spotify/spotify_test.go @@ -0,0 +1,54 @@ +package spotify_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/spotify" + "github.com/stretchr/testify/assert" +) + +func provider() *spotify.Provider { + return spotify.New(os.Getenv("SPOTIFY_KEY"), os.Getenv("SPOTIFY_SECRET"), "/foo", "user") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("SPOTIFY_KEY")) + a.Equal(p.Secret, os.Getenv("SPOTIFY_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_ImplementsProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*spotify.Session) + a.NoError(err) + a.Contains(s.AuthURL, "accounts.spotify.com/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"http://accounts.spotify.com/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*spotify.Session) + a.Equal(s.AuthURL, "http://accounts.spotify.com/authorize") + a.Equal(s.AccessToken, "1234567890") +} diff --git a/providers/steam/session.go b/providers/steam/session.go new file mode 100644 index 000000000..7f06c8c98 --- /dev/null +++ b/providers/steam/session.go @@ -0,0 +1,100 @@ +// Package steam implements the OpenID protocol for authenticating users through Steam. +package steam + +import ( + "encoding/json" + "errors" + "io" + "net/url" + "regexp" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Steam. +type Session struct { + AuthURL string + CallbackURL string + SteamID string + ResponseNonce string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Steam provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Steam and return the unique response_nonce by OpenID. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + if params.Get("openid.mode") != "id_res" { + return "", errors.New("Mode must equal to \"id_res\".") + } + + if params.Get("openid.return_to") != s.CallbackURL { + return "", errors.New("The \"return_to url\" must match the url of current request.") + } + + v := make(url.Values) + v.Set("openid.assoc_handle", params.Get("openid.assoc_handle")) + v.Set("openid.signed", params.Get("openid.signed")) + v.Set("openid.sig", params.Get("openid.sig")) + v.Set("openid.ns", params.Get("openid.ns")) + + split := strings.Split(params.Get("openid.signed"), ",") + for _, item := range split { + v.Set("openid."+item, params.Get("openid."+item)) + } + v.Set("openid.mode", "check_authentication") + + resp, err := p.Client().PostForm(apiLoginEndpoint, v) + if err != nil { + return "", err + } + defer resp.Body.Close() + content, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + response := strings.Split(string(content), "\n") + if response[0] != "ns:"+openIDNs { + return "", errors.New("Wrong ns in the response.") + } + + if response[1] == "is_valid:false" { + return "", errors.New("Unable validate openId.") + } + + openIDURL := params.Get("openid.claimed_id") + validationRegExp := regexp.MustCompile("^(http|https)://steamcommunity.com/openid/id/[0-9]{15,25}$") + if !validationRegExp.MatchString(openIDURL) { + return "", errors.New("Invalid Steam ID pattern.") + } + + s.SteamID = regexp.MustCompile("\\D+").ReplaceAllString(openIDURL, "") + s.ResponseNonce = params.Get("openid.response_nonce") + + return s.ResponseNonce, nil +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/steam/session_test.go b/providers/steam/session_test.go new file mode 100644 index 000000000..b6e945565 --- /dev/null +++ b/providers/steam/session_test.go @@ -0,0 +1,48 @@ +package steam_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/steam" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &steam.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &steam.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &steam.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","CallbackURL":"","SteamID":"","ResponseNonce":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &steam.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/steam/steam.go b/providers/steam/steam.go new file mode 100644 index 000000000..79679defe --- /dev/null +++ b/providers/steam/steam.go @@ -0,0 +1,199 @@ +// Package steam implements the OpenID protocol for authenticating users through Steam. +package steam + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + // Steam API Endpoints + apiLoginEndpoint = "https://steamcommunity.com/openid/login" + apiUserSummaryEndpoint = "http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=%s&steamids=%s" + + // OpenID settings + openIDMode = "checkid_setup" + openIDNs = "http://specs.openid.net/auth/2.0" + openIDIdentifier = "http://specs.openid.net/auth/2.0/identifier_select" +) + +// New creates a new Steam provider, and sets up important connection details. +// You should always call `steam.New` to get a new Provider. Never try to create +// one manually. +func New(apiKey string, callbackURL string) *Provider { + p := &Provider{ + APIKey: apiKey, + CallbackURL: callbackURL, + providerName: "steam", + } + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Steam +type Provider struct { + APIKey string + CallbackURL string + HTTPClient *http.Client + providerName string +} + +// Name gets the name used to retrieve this provider. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is no-op for the Steam package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth will return the authentication end-point for Steam. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + u, err := p.getAuthURL() + s := &Session{ + AuthURL: u.String(), + CallbackURL: p.CallbackURL, + } + return s, err +} + +// getAuthURL is an internal function to build the correct +// authentication url to redirect the user to Steam. +func (p *Provider) getAuthURL() (*url.URL, error) { + callbackURL, err := url.Parse(p.CallbackURL) + if err != nil { + return nil, err + } + + urlValues := map[string]string{ + "openid.claimed_id": openIDIdentifier, + "openid.identity": openIDIdentifier, + "openid.mode": openIDMode, + "openid.ns": openIDNs, + "openid.realm": fmt.Sprintf("%s://%s", callbackURL.Scheme, callbackURL.Host), + "openid.return_to": callbackURL.String(), + } + + u, err := url.Parse(apiLoginEndpoint) + if err != nil { + return nil, err + } + + v := u.Query() + for key, value := range urlValues { + v.Set(key, value) + } + u.RawQuery = v.Encode() + + return u, nil +} + +// FetchUser will go to Steam and access basic info about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + u := goth.User{ + Provider: p.Name(), + AccessToken: s.ResponseNonce, + } + + if s.SteamID == "" { + // data is not yet retrieved since SteamID is still empty + return u, fmt.Errorf("%s cannot get user information without SteamID", p.providerName) + } + + apiURL := fmt.Sprintf(apiUserSummaryEndpoint, p.APIKey, s.SteamID) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return u, err + } + req.Header.Add("Accept", "application/json") + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return u, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return u, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + u, err = buildUserObject(resp.Body, u) + + return u, err +} + +// buildUserObject is an internal function to build a goth.User object +// based in the data stored in r +func buildUserObject(r io.Reader, u goth.User) (goth.User, error) { + // Response object from Steam + apiResponse := struct { + Response struct { + Players []struct { + UserID string `json:"steamid"` + NickName string `json:"personaname"` + Name string `json:"realname"` + AvatarURL string `json:"avatarfull"` + LocationCountryCode string `json:"loccountrycode"` + LocationStateCode string `json:"locstatecode"` + } `json:"players"` + } `json:"response"` + }{} + + err := json.NewDecoder(r).Decode(&apiResponse) + if err != nil { + return u, err + } + + if l := len(apiResponse.Response.Players); l != 1 { + return u, fmt.Errorf("Expected one player in API response. Got %d.", l) + } + + player := apiResponse.Response.Players[0] + u.UserID = player.UserID + u.Name = player.Name + if len(player.Name) == 0 { + u.Name = "No name is provided by the Steam API" + } + u.NickName = player.NickName + u.AvatarURL = player.AvatarURL + u.Email = "No email is provided by the Steam API" + u.Description = "No description is provided by the Steam API" + + if len(player.LocationStateCode) > 0 && len(player.LocationCountryCode) > 0 { + u.Location = fmt.Sprintf("%s, %s", player.LocationStateCode, player.LocationCountryCode) + } else if len(player.LocationCountryCode) > 0 { + u.Location = player.LocationCountryCode + } else if len(player.LocationStateCode) > 0 { + u.Location = player.LocationStateCode + } else { + u.Location = "No location is provided by the Steam API" + } + + return u, nil +} + +// RefreshToken refresh token is not provided by Steam +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} + +// RefreshTokenAvailable refresh token is not provided by Steam +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/steam/steam_test.go b/providers/steam/steam_test.go new file mode 100644 index 000000000..f800bd0a0 --- /dev/null +++ b/providers/steam/steam_test.go @@ -0,0 +1,55 @@ +package steam_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/steam" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.APIKey, os.Getenv("STEAM_KEY")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*steam.Session) + a.NoError(err) + a.Contains(s.AuthURL, "steamcommunity.com/openid/login") + a.Contains(s.AuthURL, "foo") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://steamcommunity.com/openid/login?openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.mode=checkid_setup&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.realm=%3A%2F%2F&openid.return_to=%2Ffoo","SteamID":"1234567890","CallbackURL":"http://localhost:3030/","ResponseNonce":"2016-03-13T16:56:30ZJ8tlKVquwHi9ZSPV4ElU5PY2dmI="}`) + a.NoError(err) + + s := session.(*steam.Session) + a.Equal(s.AuthURL, "https://steamcommunity.com/openid/login?openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.mode=checkid_setup&openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.realm=%3A%2F%2F&openid.return_to=%2Ffoo") + a.Equal(s.CallbackURL, "http://localhost:3030/") + a.Equal(s.SteamID, "1234567890") + a.Equal(s.ResponseNonce, "2016-03-13T16:56:30ZJ8tlKVquwHi9ZSPV4ElU5PY2dmI=") +} + +func provider() *steam.Provider { + return steam.New(os.Getenv("STEAM_KEY"), "/foo") +} diff --git a/providers/strava/session.go b/providers/strava/session.go new file mode 100644 index 000000000..cc0cbd0d4 --- /dev/null +++ b/providers/strava/session.go @@ -0,0 +1,61 @@ +package strava + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Strava. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Strava provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Strava and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/strava/session_test.go b/providers/strava/session_test.go new file mode 100644 index 000000000..f3175102b --- /dev/null +++ b/providers/strava/session_test.go @@ -0,0 +1,48 @@ +package strava_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/strava" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &strava.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &strava.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &strava.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &strava.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/strava/strava.go b/providers/strava/strava.go new file mode 100644 index 000000000..4527844fe --- /dev/null +++ b/providers/strava/strava.go @@ -0,0 +1,182 @@ +// Package strava implements the OAuth2 protocol for authenticating users through Strava. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package strava + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.strava.com/oauth/authorize" + tokenURL string = "https://www.strava.com/oauth/token" + endpointProfile string = "https://www.strava.com/api/v3/athlete" +) + +// New creates a new Strava provider, and sets up important connection details. +// You should always call `strava.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "strava", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Strava. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns an HTTP client to be used in all fetch operations. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the strava package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Strava for an authentication endpoint. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authUrl := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: authUrl, + } + return session, nil +} + +// FetchUser will go to Strava and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + reqUrl := fmt.Sprint(endpointProfile, + "?access_token=", url.QueryEscape(sess.AccessToken), + ) + response, err := p.Client().Get(reqUrl) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + ID int64 `json:"id"` + Username string `json:"username"` + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + City string `json:"city"` + Region string `json:"state"` + Country string `json:"country"` + Gender string `json:"sex"` + Picture string `json:"profile"` + }{} + + err := json.NewDecoder(reader).Decode(&u) + if err != nil { + return err + } + + user.UserID = fmt.Sprintf("%d", u.ID) + user.Name = fmt.Sprintf("%s %s", u.FirstName, u.LastName) + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.Username + user.AvatarURL = u.Picture + user.Description = fmt.Sprintf(`{"gender":"%s"}`, u.Gender) + user.Location = fmt.Sprintf(`{"city":"%s","region":"%s","country":"%s"}`, u.City, u.Region, u.Country) + + return err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + c.Scopes = []string{strings.Join(scopes, ",")} + } else { + c.Scopes = []string{"read"} + } + + return c +} + +// RefreshTokenAvailable refresh token is not provided by Strava +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken refresh token is not provided by Strava +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/strava/strava_test.go b/providers/strava/strava_test.go new file mode 100644 index 000000000..19762d86a --- /dev/null +++ b/providers/strava/strava_test.go @@ -0,0 +1,59 @@ +package strava_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/strava" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := stravaProvider() + a.Equal(provider.ClientKey, os.Getenv("STRAVA_KEY")) + a.Equal(provider.Secret, os.Getenv("STRAVA_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), stravaProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := stravaProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*strava.Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.strava.com/oauth/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("STRAVA_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=read") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := stravaProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://www.strava.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*strava.Session) + a.Equal(session.AuthURL, "https://www.strava.com/oauth/authorize") + a.Equal(session.AccessToken, "1234567890") +} + +func stravaProvider() *strava.Provider { + return strava.New(os.Getenv("STRAVA_KEY"), os.Getenv("STRAVA_SECRET"), "/foo", "read") +} diff --git a/providers/stripe/session.go b/providers/stripe/session.go new file mode 100644 index 000000000..24f5581ed --- /dev/null +++ b/providers/stripe/session.go @@ -0,0 +1,65 @@ +package stripe + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Stripe. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + ID string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Stripe provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Stripe and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + s.ID = token.Extra("stripe_user_id").(string) // Required to get the user info from sales force + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/stripe/session_test.go b/providers/stripe/session_test.go new file mode 100644 index 000000000..b043f11c8 --- /dev/null +++ b/providers/stripe/session_test.go @@ -0,0 +1,48 @@ +package stripe_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/stripe" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &stripe.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &stripe.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &stripe.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","ID":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &stripe.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/stripe/stripe.go b/providers/stripe/stripe.go new file mode 100644 index 000000000..b2c2257ba --- /dev/null +++ b/providers/stripe/stripe.go @@ -0,0 +1,164 @@ +// Package stripe implements the OAuth2 protocol for authenticating users through stripe. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package stripe + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://connect.stripe.com/oauth/authorize" + tokenURL string = "https://connect.stripe.com/oauth/token" + endPointAccount string = "https://api.stripe.com/v1/accounts/" +) + +// Provider is the implementation of `goth.Provider` for accessing Stripe. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Stripe provider and sets up important connection details. +// You should always call `stripe.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "stripe", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the stripe package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Stripe for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Stripe and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endPointAccount+s.ID, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Email string `json:"email"` + Name string `json:"display_name"` + AvatarURL string `json:"business_logo"` + ID string `json:"id"` + Address struct { + Location string `json:"city"` + } `json:"support_address"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email // email is not provided by yahoo + user.Name = u.Name + user.NickName = u.Name + user.UserID = u.ID + user.Location = u.Address.Location + user.AvatarURL = u.AvatarURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/stripe/stripe_test.go b/providers/stripe/stripe_test.go new file mode 100644 index 000000000..8b7e7327f --- /dev/null +++ b/providers/stripe/stripe_test.go @@ -0,0 +1,53 @@ +package stripe_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/stripe" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("STRIPE_KEY")) + a.Equal(p.Secret, os.Getenv("STRIPE_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*stripe.Session) + a.NoError(err) + a.Contains(s.AuthURL, "connect.stripe.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://connect.stripe.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*stripe.Session) + a.Equal(s.AuthURL, "https://connect.stripe.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *stripe.Provider { + return stripe.New(os.Getenv("STRIPE_KEY"), os.Getenv("STRIPE_SECRET"), "/foo") +} diff --git a/providers/tiktok/session.go b/providers/tiktok/session.go new file mode 100644 index 000000000..ff917c820 --- /dev/null +++ b/providers/tiktok/session.go @@ -0,0 +1,104 @@ +package tiktok + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with TikTok +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time + OpenID string + RefreshToken string + RefreshExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the TikTok provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with TikTok and return the access token to be stored for future use. Note that +// we call the endpoints directly vs calling *oauth2.Config.Exchange() due to inconsistent TikTok param names. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + + // Set up the url params to post to get a new access token from a code + v := url.Values{ + "grant_type": {"authorization_code"}, + "code": {params.Get("code")}, + } + if p.config.RedirectURL != "" { + v.Set("redirect_uri", p.config.RedirectURL) + } + + req, err := http.NewRequest(http.MethodPost, endpointToken, nil) + if err != nil { + return "", err + } + v.Add("client_key", p.config.ClientID) + v.Add("client_secret", p.config.ClientSecret) + + req.URL.RawQuery = v.Encode() + response, err := p.GetClient().Do(req) + if err != nil { + return "", err + } + + tokenResp := struct { + Data struct { + OpenID string `json:"open_id"` + Scope string `json:"scope"` + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresIn int64 `json:"refresh_expires_in"` + } `json:"data"` + }{} + + // Get the body bytes in case we have to parse an error response + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + defer response.Body.Close() + + err = json.Unmarshal(bodyBytes, &tokenResp) + if err != nil { + return "", err + } + + // If we do not have an access token we assume we have an error response payload + if tokenResp.Data.AccessToken == "" { + return "", handleErrorResponse(bodyBytes) + } + + // Create and Bind the Access Token + s.AccessToken = tokenResp.Data.AccessToken + s.ExpiresAt = time.Now().UTC().Add(time.Second * time.Duration(tokenResp.Data.ExpiresIn)) + s.OpenID = tokenResp.Data.OpenID + s.RefreshToken = tokenResp.Data.RefreshToken + s.RefreshExpiresAt = time.Now().UTC().Add(time.Second * time.Duration(tokenResp.Data.RefreshExpiresIn)) + return s.AccessToken, nil +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} diff --git a/providers/tiktok/session_test.go b/providers/tiktok/session_test.go new file mode 100644 index 000000000..99cc69093 --- /dev/null +++ b/providers/tiktok/session_test.go @@ -0,0 +1,48 @@ +package tiktok_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/tiktok" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &tiktok.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &tiktok.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &tiktok.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z","OpenID":"","RefreshToken":"","RefreshExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &tiktok.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/tiktok/tiktok.go b/providers/tiktok/tiktok.go new file mode 100644 index 000000000..01066b789 --- /dev/null +++ b/providers/tiktok/tiktok.go @@ -0,0 +1,278 @@ +// Package tiktok implements the OAuth2 protocol for authenticating users through TikTok. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package tiktok + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + endpointAuth = "https://open-api.tiktok.com/platform/oauth/connect/" + endpointToken = "https://open-api.tiktok.com/oauth/access_token/" + endpointRefresh = "https://open-api.tiktok.com/oauth/refresh_token/" + endpointUserInfo = "https://open-api.tiktok.com/oauth/userinfo/" + + ScopeUserInfoBasic = "user.info.basic" + ScopeVideoList = "video.list" + ScopeVideoUpload = "video.upload" + ScopeShareSoundCreate = "share.sound.create" +) + +// Provider is the implementation of `goth.Provider` for accessing TikTok +type Provider struct { + CallbackURL string + Client *http.Client + ClientKey string + ClientSecret string + config *oauth2.Config + providerName string +} + +// New creates a new TikTok provider, and sets up connection details. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + ClientSecret: secret, + CallbackURL: callbackURL, + providerName: "tiktok", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) GetClient() *http.Client { + return goth.HTTPClientWithFallBack(p.Client) +} + +// Debug TODO +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks TikTok for an authentication end-point. Note that we create our own URL string instead +// of calling oauth2.AuthCodeURL() due to TikTok param name requirements. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + var buf bytes.Buffer + buf.WriteString(p.config.Endpoint.AuthURL) + v := url.Values{ + "response_type": {"code"}, + "client_key": {p.config.ClientID}, + "state": {state}, + } + + if p.config.RedirectURL != "" { + v.Set("redirect_uri", p.config.RedirectURL) + } + + // Note scopes are CSVs + if len(p.config.Scopes) > 0 { + v.Set("scope", strings.Join(p.config.Scopes, ",")) + } + + if strings.Contains(p.config.Endpoint.AuthURL, "?") { + buf.WriteByte('&') + } else { + buf.WriteByte('?') + } + buf.WriteString(v.Encode()) + return &Session{ + AuthURL: buf.String(), + }, nil +} + +// FetchUser will go to TikTok and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + ExpiresAt: sess.ExpiresAt, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + UserID: sess.OpenID, + } + + // data is not yet retrieved since accessToken is still empty + if user.AccessToken == "" || user.UserID == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken and userID", p.providerName) + } + + // Set up the url params to post to get a new access token from a code + v := url.Values{ + "access_token": {user.AccessToken}, + "open_id": {user.UserID}, + } + response, err := p.GetClient().Get(endpointUserInfo + "?" + v.Encode()) + if err != nil { + return user, err + } + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + err = userFromReader(response.Body, &user) + response.Body.Close() + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + u := struct { + Data struct { + OpenID string `json:"open_id"` + Avatar string `json:"avatar"` + DisplayName string `json:"display_name"` + } `json:"data"` + }{} + + bodyBytes, err := io.ReadAll(reader) + if err != nil { + return err + } + + err = json.Unmarshal(bodyBytes, &u) + if err != nil { + return err + } + user.AvatarURL = u.Data.Avatar + user.Name = u.Data.DisplayName + user.NickName = u.Data.DisplayName + + // On no display name, we assume an error response. TikTok returns error codes and descriptions inside + // the same struct/body. Sigh...refer https://developers.tiktok.com/doc/login-kit-user-info-basic + if user.Name == "" { + return handleErrorResponse(bodyBytes) + } + + // Bind the all the bytes to the raw data returning err + return json.Unmarshal(bodyBytes, &user.RawData) +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.ClientSecret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: endpointAuth, + }, + Scopes: []string{ScopeUserInfoBasic}, + } + + // Note that the "user.info.basic" scope is always bound so don't dupe + for _, scope := range scopes { + if scope != ScopeUserInfoBasic { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +// RefreshToken will refresh a TikTok access token. +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + req, err := http.NewRequest(http.MethodPost, endpointRefresh, nil) + if err != nil { + return nil, err + } + + // Set up the url params to post to get a new access token from a code + v := url.Values{ + "client_key": {p.config.ClientID}, + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + } + req.URL.RawQuery = v.Encode() + refreshResponse, err := p.GetClient().Do(req) + if err != nil { + return nil, err + } + + // We get the body bytes in case we need to parse an error response + bodyBytes, err := io.ReadAll(refreshResponse.Body) + if err != nil { + return nil, err + } + defer refreshResponse.Body.Close() + + refresh := struct { + Data struct { + OpenID string `json:"open_id"` + Scope string `json:"scope"` + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresIn int64 `json:"refresh_expires_in"` + } `json:"data"` + }{} + err = json.Unmarshal(bodyBytes, &refresh) + if err != nil { + return nil, err + } + + // If we do not have an access token we assume we have an error response payload + if refresh.Data.AccessToken == "" { + return nil, handleErrorResponse(bodyBytes) + } + + token := &oauth2.Token{ + AccessToken: refresh.Data.AccessToken, + TokenType: "Bearer", + RefreshToken: refresh.Data.RefreshToken, + Expiry: time.Now().Add(time.Second * time.Duration(refresh.Data.ExpiresIn)), + } + + tokenExtra := map[string]interface{}{ + "open_id": refresh.Data.OpenID, + "scope": refresh.Data.Scope, + "refresh_expires_in": refresh.Data.RefreshExpiresIn, + } + + return token.WithExtra(tokenExtra), nil +} + +// RefreshTokenAvailable refresh token +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} + +func handleErrorResponse(data []byte) error { + errResp := struct { + Data struct { + Captcha string `json:"captcha"` + DescURL string `json:"desc_url"` + Description string `json:"description"` + ErrorCode int `json:"error_code"` + } `json:"data"` + Message string `json:"message"` + }{} + if err := json.Unmarshal(data, &errResp); err != nil { + return err + } + + return fmt.Errorf("%s [%d]", errResp.Data.Description, errResp.Data.ErrorCode) +} diff --git a/providers/tiktok/tiktok_test.go b/providers/tiktok/tiktok_test.go new file mode 100644 index 000000000..9ab7295b3 --- /dev/null +++ b/providers/tiktok/tiktok_test.go @@ -0,0 +1,59 @@ +package tiktok_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/tiktok" + "github.com/stretchr/testify/assert" +) + +const callbackURL = "/tests/for/the/win" + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("tiktok_KEY")) + a.Equal(p.ClientSecret, os.Getenv("tiktok_SECRET")) + a.Nil(p.Client) + a.Equal(p.CallbackURL, callbackURL) +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*tiktok.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://open-api.tiktok.com/platform/oauth/connect") + a.Contains(s.AuthURL, fmt.Sprintf("%s%%2C%s", tiktok.ScopeUserInfoBasic, tiktok.ScopeVideoList)) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://open-api.tiktok.com/platform/oauth/connect","AccessToken":"1234567890"}"`) + a.NoError(err) + + s := session.(*tiktok.Session) + a.Equal(s.AuthURL, "https://open-api.tiktok.com/platform/oauth/connect") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *tiktok.Provider { + p := tiktok.New(os.Getenv("TIKTOK_KEY"), os.Getenv("TIKTOK_SECRET"), callbackURL, tiktok.ScopeVideoList) + return p +} diff --git a/providers/tumblr/session.go b/providers/tumblr/session.go new file mode 100644 index 000000000..10b5de8c0 --- /dev/null +++ b/providers/tumblr/session.go @@ -0,0 +1,54 @@ +package tumblr + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" +) + +// Session stores data during the auth process with Tumblr. +type Session struct { + AuthURL string + AccessToken *oauth.AccessToken + RequestToken *oauth.RequestToken +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Tumblr provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Tumblr and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) + if err != nil { + return "", err + } + + s.AccessToken = accessToken + return accessToken.Token, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/tumblr/tumblr.go b/providers/tumblr/tumblr.go new file mode 100644 index 000000000..07028c227 --- /dev/null +++ b/providers/tumblr/tumblr.go @@ -0,0 +1,152 @@ +// Package tumblr implements the OAuth protocol for authenticating users through Tumblr. +// This package can be used as a reference implementation of an OAuth provider for Goth. +package tumblr + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" + "golang.org/x/oauth2" +) + +var ( + requestURL = "https://www.tumblr.com/oauth/request_token" + authorizeURL = "https://www.tumblr.com/oauth/authorize" + tokenURL = "https://www.tumblr.com/oauth/access_token" + endpointProfile = "https://api.tumblr.com/v2/user/info" +) + +// user/update_token + +// New creates a new Tumblr provider, and sets up important connection details. +// You should always call `tumblr.New` to get a new Provider. Never try to create +// one manually. +// +// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "tumblr", + } + p.consumer = newConsumer(p, authorizeURL) + return p +} + +// NewAuthenticate is the almost same as New. +// NewAuthenticate uses the authenticate URL instead of the authorize URL. +func NewAuthenticate(clientKey, secret, callbackURL string) *Provider { + return New(clientKey, secret, callbackURL) +} + +// Provider is the implementation of `goth.Provider` for accessing Tumblr. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + debug bool + consumer *oauth.Consumer + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug sets the logging of the OAuth client to verbose. +func (p *Provider) Debug(debug bool) { + p.debug = debug +} + +// BeginAuth asks Tumblr for an authentication end-point and a request token for a session. +// Tumblr does not support the "state" variable. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) + session := &Session{ + AuthURL: url, + RequestToken: requestToken, + } + return session, err +} + +// FetchUser will go to Tumblr and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + Provider: p.Name(), + } + + if sess.AccessToken == nil { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.consumer.Get(endpointProfile, map[string]string{}, sess.AccessToken) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + if err = json.NewDecoder(response.Body).Decode(&user.RawData); err != nil { + return user, err + } + + res, ok := user.RawData["response"].(map[string]interface{}) + if !ok { + return user, errors.New("could not decode response") + } + resUser, ok := res["user"].(map[string]interface{}) + if !ok { + return user, errors.New("could not decode user") + } + + user.Name = resUser["name"].(string) + user.NickName = resUser["name"].(string) + user.AccessToken = sess.AccessToken.Token + user.AccessTokenSecret = sess.AccessToken.Secret + return user, err +} + +func newConsumer(provider *Provider, authURL string) *oauth.Consumer { + c := oauth.NewConsumer( + provider.ClientKey, + provider.Secret, + oauth.ServiceProvider{ + RequestTokenUrl: requestURL, + AuthorizeTokenUrl: authURL, + AccessTokenUrl: tokenURL, + }) + + c.Debug(provider.debug) + return c +} + +// RefreshToken refresh token is not provided by Tumblr +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by Tumblr") +} + +// RefreshTokenAvailable refresh token is not provided by Tumblr +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/twitch/session.go b/providers/twitch/session.go new file mode 100644 index 000000000..109962d91 --- /dev/null +++ b/providers/twitch/session.go @@ -0,0 +1,65 @@ +package twitch + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Twitch +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on +// the Twitch provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize completes the authorization with Twitch and returns the access +// token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal marshals a session into a JSON string. +func (s Session) Marshal() string { + j, _ := json.Marshal(s) + return string(j) +} + +// String is equivalent to Marshal. It returns a JSON representation of the +// session. +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/twitch/session_test.go b/providers/twitch/session_test.go new file mode 100644 index 000000000..d616416c1 --- /dev/null +++ b/providers/twitch/session_test.go @@ -0,0 +1,38 @@ +package twitch + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func Test_ImplementsSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} diff --git a/providers/twitch/twitch.go b/providers/twitch/twitch.go new file mode 100644 index 000000000..1fe70702b --- /dev/null +++ b/providers/twitch/twitch.go @@ -0,0 +1,369 @@ +// Package twitch implements the OAuth2 protocol for authenticating users through Twitch. +// This package can be used as a reference implementation of an OAuth2 provider for Twitch. +package twitch + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://id.twitch.tv/oauth2/authorize" + tokenURL string = "https://id.twitch.tv/oauth2/token" + userEndpoint string = "https://api.twitch.tv/helix/users" +) + +const ( + // ScopeAnalyticsReadExtensions provides access to view analytics data for + // the Twitch Extensions owned by the authenticated account. + ScopeAnalyticsReadExtensions = "analytics:read:extensions" + // ScopeAnalyticsReadGames provides accesss to view analytics data for the + // games owned by the authenticated account. + ScopeAnalyticsReadGames = "analytics:read:games" + // ScopeBitsRead provides access to view Bits information for a channel. + ScopeBitsRead = "bits:read" + // ScopeChannelManageBroadcast provides access to manage a channel’s + // broadcast configuration, including updating channel configuration and + // managing stream markers and stream tags. + ScopeChannelManageBroadcast = "channel:manage:broadcast" + // ScopeChannelReadCharity provides access to read charity campaign details + // and user donations on your channel. + ScopeChannelReadCharity = "channel:read:charity" + // ScopeChannelEditCommercial provides access to run commercials on a + // channel. + ScopeChannelEditCommercial = "channel:edit:commercial" + // ScopeChannelReadEditors provides access to view a list of users with the + // editor role for a channel. + ScopeChannelReadEditors = "channel:read:editors" + // ScopeChannelManageExtensions provides access to manage a channel’s + // Extension configuration, including activating Extensions. + ScopeChannelManageExtensions = "channel:manage:extensions" + // ScopeChannelReadGoals provides access to view Creator Goals for a + // channel. + ScopeChannelReadGoals = "channel:read:goals" + // ScopeChannelReadGuestStar provides access to read Guest Star details + // for your channel. + ScopeChannelReadGuestStar = "channel:read:guest_star" + // ScopeChannelManageGuestStar provides access to manage Guest Star + // for your channel. + ScopeChannelManageGuestStar = "channel:manage:guest_star" + // ScopeChannelReadHypeTrain provides access to view Hype Train information + // for a channel. + ScopeChannelReadHypeTrain = "channel:read:hype_train" + // ScopeChannelManageModerators provides access to add or remove the + // moderator role from users in your channel. + ScopeChannelManageModerators = "channel:manage:moderators" + // ScopeChannelReadPolls provides access to view a channel’s polls. + ScopeChannelReadPolls = "channel:read:polls" + // ScopeChannelManagePolls provides access to manage a channel’s polls. + ScopeChannelManagePolls = "channel:manage:polls" + // ScopeChannelReadPredictions provides access to view a channel’s Channel + // Points Predictions. + ScopeChannelReadPredictions = "channel:read:predictions" + // ScopeChannelManagePredictions provides access to manage a channel’s + // Channel Points Predictions. + ScopeChannelManagePredictions = "channel:manage:predictions" + // ScopeChannelManageRaids provides access to manage a channel raiding another channel. + ScopeChannelManageRaids = "channel:manage:raids" + // ScopeChannelReadRedemptions provides access to view Channel Points custom + // rewards and their redemptions on a channel. + ScopeChannelReadRedemptions = "channel:read:redemptions" + // ScopeChannelManageRedemptions provides access to manage Channel Points + // custom rewards and their redemptions on a channel. + ScopeChannelManageRedemptions = "channel:manage:redemptions" + // ScopeChannelManageSchedule provides access to manage a channel’s stream + // schedule. + ScopeChannelManageSchedule = "channel:manage:schedule" + // ScopeChannelReadStreamKey provides access to view an authorized user’s + // stream key. + ScopeChannelReadStreamKey = "channel:read:stream_key" + // ScopeChannelReadSubscriptions provides access to view a list of all + // subscribers to a channel and check if a user is subscribed to a channel. + ScopeChannelReadSubscriptions = "channel:read:subscriptions" + // ScopeChannelManageVideos provides access to manage a channel’s videos, + // including deleting videos. + ScopeChannelManageVideos = "channel:manage:videos" + // ScopeChannelReadVips provides access to read the list of VIPs in your channel. + ScopeChannelReadVips = "channel:read:vips" + // ScopeChannelManageVips provide access to add or remove the VIP role from + // users in your channel. + ScopeChannelManageVips = "channel:manage:vips" + // ScopeClipsEdit provides access to manage Clips for a channel. + ScopeClipsEdit = "clips:edit" + // ScopeModerationRead provides access to view a channel’s moderation data + // including Moderators, Bans, Timeouts, and AutoMod settings. + ScopeModerationRead = "moderation:read" + // ScopeModeratorManageAnnouncements provides access to send announcements + // in channels where you have the moderator role. + ScopeModeratorManageAnnouncements = "moderator:manage:announcements" + // ScopeModeratorManageAutomod provides access to manage messages held for + // review by AutoMod in channels where you are a moderator. + ScopeModeratorManageAutomod = "moderator:manage:automod" + // ScopeModeratorReadAutomodSettings provides access to view a broadcaster’s + // AutoMod settings. + ScopeModeratorReadAutomodSettings = "moderator:read:automod_settings" + // ScopeModeratorManageAutomodSettings provides access to manage a broadcaster’s + // AutoMod settings. + ScopeModeratorManageAutomodSettings = "moderator:manage:automod_settings" + // ScopeModeratorManageBannedUsers provides access to ban and unban users. + ScopeModeratorManageBannedUsers = "moderator:manage:banned_users" + // ScopeModeratorReadBlockedTerms provides access to view a broadcaster’s + // list of blocked terms. + ScopeModeratorReadBlockedTerms = "moderator:read:blocked_terms" + // ScopeModeratorManageBlockedTerms provides access to manage a + // broadcaster’s list of blocked terms. + ScopeModeratorManageBlockedTerms = "moderator:manage:blocked_terms" + // ScopeModeratorManageChatMessages provides access to delete chat messages + // in channels where you have the moderator role + ScopeModeratorManageChatMessages = "moderator:manage:chat_messages" + // ScopeModeratorReadChatSettings provides access to view a broadcaster’s + // chat room settings. + ScopeModeratorReadChatSettings = "moderator:read:chat_settings" + // ScopeModeratorManageChatSettings provides access to manage a + // broadcaster’s chat room settings. + ScopeModeratorManageChatSettings = "moderator:manage:chat_settings" + // ScopeModeratorReadChatters provides access to view the chatters + // in a broadcaster’s chat room. + ScopeModeratorReadChatters = "moderator:read:chatters" + // ScopeModeratorReadFollowers provides access to read the followers of a broadcaster. + ScopeModeratorReadFollowers = "moderator:read:followers" + // ScopeModeratorReadGuestStar provides access to read Guest Star details + // for channels where you are a Guest Star moderator. + ScopeModeratorReadGuestStar = "moderator:read:guest_star" + // ScopeModeratorManageGuestStar provides access to Manage Guest Star for + // channels where you are a Guest Star moderator. + ScopeModeratorManageGuestStar = "moderator:manage:guest_star" + // ScopeModeratorReadShieldMode provides access to view a broadcaster’s + // Shield Mode status. + ScopeModeratorReadShieldMode = "moderator:read:shield_mode" + // ScopeModeratorManageShieldMode provides access to manage a broadcaster’s + // Shield Mode status. + ScopeModeratorManageShieldMode = "moderator:manage:shield_mode" + // ScopeModeratorReadShoutouts provides access to view a broadcaster’s shoutouts. + ScopeModeratorReadShoutouts = "moderator:read:shoutouts" + // ScopeModeratorManageShoutouts provides access to manage a broadcaster’s shoutouts. + ScopeModeratorManageShoutouts = "moderator:manage:shoutouts" + // ScopeUserEdit provides access to manage a user object. + ScopeUserEdit = "user:edit" + // ScopeUserEditFollows is deprecated. Was previously used for + // “Create User Follows” and “Delete User Follows.” + ScopeUserEditFollows = "user:edit:follows" + // ScopeUserReadBlockedUsers provides access to view the block list of a + // user. + ScopeUserReadBlockedUsers = "user:read:blocked_users" + // ScopeUserManageBlockedUsers provides access to manage the block list of a + // user. + ScopeUserManageBlockedUsers = "user:manage:blocked_users" + // ScopeUserReadBroadcast provides access to view a user’s broadcasting + // configuration, including Extension configurations. + ScopeUserReadBroadcast = "user:read:broadcast" + // ScopeUserManageChatColor provides access to update the color use + // for the user’s name in chat. + ScopeUserManageChatColor = "user:manage:chat_color" + // ScopeUserReadEmail provides access to view a user’s email address. + ScopeUserReadEmail = "user:read:email" + // ScopeUserReadFollows provides access to view the list of channels a user + // follows. + ScopeUserReadFollows = "user:read:follows" + // ScopeUserReadSubscriptions provides access to view if an authorized user + // is subscribed to specific channels. + ScopeUserReadSubscriptions = "user:read:subscriptions" + // ScopeUserManageWhispers provides access to read whispers that you send and receive, + // and send whispers on your behalf. + ScopeUserManageWhispers = "user:manage:whispers" + // ScopeChannelModerate provides access to perform moderation actions in a channel. + // The user requesting the scope must be a moderator in the channel. + ScopeChannelModerate = "channel:moderate" + // ScopeChatEdit provides access to send live stream chat messages. + ScopeChatEdit = "chat:edit" + // ScopeChatRead provides access to view live stream chat messages. + ScopeChatRead = "chat:read" + // ScopeWhispersRead provides access to view your whisper messages. + ScopeWhispersRead = "whispers:read" + // ScopeWhispersEdit provides access to send whisper messages. + ScopeWhispersEdit = "whispers:edit" + + // ScopeChannelSubscriptions is a v5 scope. + ScopeChannelSubscriptions = ScopeChannelReadSubscriptions + // ScopeChannelCommercial is a v5 scope. + ScopeChannelCommercial = ScopeChannelEditCommercial + // ScopeChannelEditor is a v5 scope which maps to channel:manage:broadcast + // and channel:manage:videos. + ScopeChannelEditor = "channel_editor" + // ScopeUserFollowsEdit is a v5 scope. + ScopeUserFollowsEdit = ScopeUserEditFollows + // ScopeChannelRead is a v5 scope which maps to channel:read:editors, + // channel:read:stream_key, and user:read:email. + ScopeChannelRead = "channel_read" + // ScopeUserRead is a v5 scope. + ScopeUserRead = ScopeUserReadEmail + // ScopeUserBlocksRead is a v5 scope. + ScopeUserBlocksRead = ScopeUserReadBlockedUsers + // ScopeUserBlocksEdit is a v5 scope. + ScopeUserBlocksEdit = ScopeUserManageBlockedUsers + // ScopeUserSubscriptions is a v5 scope. + ScopeUserSubscriptions = ScopeUserReadSubscriptions +) + +// New creates a new Twitch provider, and sets up important connection details. +// You should always call `twitch.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "twitch", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Twitch +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// Name gets the name used to retrieve this provider. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client ... +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is no-op for the Twitch package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Twitch for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + s := &Session{ + AuthURL: url, + } + return s, nil +} + +// FetchUser will go to Twitch and access basic info about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + + s := session.(*Session) + + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return user, err + } + req.Header.Set("Client-Id", p.config.ClientID) + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func userFromReader(r io.Reader, user *goth.User) error { + var users struct { + Data []struct { + ID string `json:"id"` + Name string `json:"login"` + Nickname string `json:"display_name"` + Description string `json:"description"` + AvatarURL string `json:"profile_image_url"` + Email string `json:"email"` + } `json:"data"` + } + err := json.NewDecoder(r).Decode(&users) + if err != nil { + return err + } + if len(users.Data) == 0 { + return errors.New("user not found") + } + u := users.Data[0] + user.Name = u.Name + user.Email = u.Email + user.NickName = u.Nickname + user.Location = "No location is provided by the Twitch API" + user.AvatarURL = u.AvatarURL + user.Description = u.Description + user.UserID = u.ID + + return nil +} + +func newConfig(p *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.Secret, + RedirectURL: p.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = []string{ScopeUserReadEmail} + } + + return c +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/twitch/twitch_test.go b/providers/twitch/twitch_test.go new file mode 100644 index 000000000..73d5bd7e1 --- /dev/null +++ b/providers/twitch/twitch_test.go @@ -0,0 +1,54 @@ +package twitch + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func provider() *Provider { + return New(os.Getenv("TWITCH_KEY"), + os.Getenv("TWITCH_SECRET"), "/foo", "user") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("TWITCH_KEY")) + a.Equal(p.Secret, os.Getenv("TWITCH_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_ImplementsProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "id.twitch.tv/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://id.twitch.tv/oauth2/authorize", "AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*Session) + a.Equal(s.AuthURL, "https://id.twitch.tv/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} diff --git a/providers/twitter/session.go b/providers/twitter/session.go new file mode 100644 index 000000000..049928ff2 --- /dev/null +++ b/providers/twitter/session.go @@ -0,0 +1,54 @@ +package twitter + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" +) + +// Session stores data during the auth process with Twitter. +type Session struct { + AuthURL string + AccessToken *oauth.AccessToken + RequestToken *oauth.RequestToken +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Twitter provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Twitter and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) + if err != nil { + return "", err + } + + s.AccessToken = accessToken + return accessToken.Token, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/twitter/session_test.go b/providers/twitter/session_test.go new file mode 100644 index 000000000..1773b07b9 --- /dev/null +++ b/providers/twitter/session_test.go @@ -0,0 +1,48 @@ +package twitter_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/twitter" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitter.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitter.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitter.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":null,"RequestToken":null}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &twitter.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/twitter/twitter.go b/providers/twitter/twitter.go new file mode 100644 index 000000000..ad4abced2 --- /dev/null +++ b/providers/twitter/twitter.go @@ -0,0 +1,167 @@ +// Package twitter implements the OAuth protocol for authenticating users through Twitter. +// This package can be used as a reference implementation of an OAuth provider for Goth. +package twitter + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" + "golang.org/x/oauth2" +) + +var ( + requestURL = "https://api.twitter.com/oauth/request_token" + authorizeURL = "https://api.twitter.com/oauth/authorize" + authenticateURL = "https://api.twitter.com/oauth/authenticate" + tokenURL = "https://api.twitter.com/oauth/access_token" + endpointProfile = "https://api.twitter.com/1.1/account/verify_credentials.json" +) + +// New creates a new Twitter provider, and sets up important connection details. +// You should always call `twitter.New` to get a new Provider. Never try to create +// one manually. +// +// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "twitter", + } + p.consumer = newConsumer(p, authorizeURL) + return p +} + +// NewAuthenticate is the almost same as New. +// NewAuthenticate uses the authenticate URL instead of the authorize URL. +func NewAuthenticate(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "twitter", + } + p.consumer = newConsumer(p, authenticateURL) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Twitter. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + debug bool + consumer *oauth.Consumer + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug sets the logging of the OAuth client to verbose. +func (p *Provider) Debug(debug bool) { + p.debug = debug +} + +// BeginAuth asks Twitter for an authentication end-point and a request token for a session. +// Twitter does not support the "state" variable. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) + session := &Session{ + AuthURL: url, + RequestToken: requestToken, + } + return session, err +} + +// FetchUser will go to Twitter and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + Provider: p.Name(), + } + + if sess.AccessToken == nil { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.consumer.Get( + endpointProfile, + map[string]string{"include_entities": "false", "skip_status": "true", "include_email": "true"}, + sess.AccessToken) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + user.Name = user.RawData["name"].(string) + user.NickName = user.RawData["screen_name"].(string) + if user.RawData["email"] != nil { + user.Email = user.RawData["email"].(string) + } + user.Description = user.RawData["description"].(string) + user.AvatarURL = user.RawData["profile_image_url"].(string) + user.UserID = user.RawData["id_str"].(string) + user.Location = user.RawData["location"].(string) + user.AccessToken = sess.AccessToken.Token + user.AccessTokenSecret = sess.AccessToken.Secret + return user, err +} + +func newConsumer(provider *Provider, authURL string) *oauth.Consumer { + c := oauth.NewConsumer( + provider.ClientKey, + provider.Secret, + oauth.ServiceProvider{ + RequestTokenUrl: requestURL, + AuthorizeTokenUrl: authURL, + AccessTokenUrl: tokenURL, + }) + + c.Debug(provider.debug) + return c +} + +// RefreshToken refresh token is not provided by twitter +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by twitter") +} + +// RefreshTokenAvailable refresh token is not provided by twitter +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/twitter/twitter_test.go b/providers/twitter/twitter_test.go new file mode 100644 index 000000000..141b5d8d7 --- /dev/null +++ b/providers/twitter/twitter_test.go @@ -0,0 +1,120 @@ +package twitter + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gorilla/pat" + "github.com/markbates/goth" + "github.com/mrjones/oauth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + a.Equal(provider.ClientKey, os.Getenv("TWITTER_KEY")) + a.Equal(provider.Secret, os.Getenv("TWITTER_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), twitterProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + session, err := provider.BeginAuth("state") + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "authorize?oauth_token=TOKEN") + a.Equal("TOKEN", s.RequestToken.Token) + a.Equal("SECRET", s.RequestToken.Secret) + + provider = twitterProviderAuthenticate() + session, err = provider.BeginAuth("state") + s = session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "authenticate?oauth_token=TOKEN") + a.Equal("TOKEN", s.RequestToken.Token) + a.Equal("SECRET", s.RequestToken.Secret) +} + +func Test_FetchUser(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + session := Session{AccessToken: &oauth.AccessToken{Token: "TOKEN", Secret: "SECRET"}} + + user, err := provider.FetchUser(&session) + a.NoError(err) + + a.Equal("Homer", user.Name) + a.Equal("duffman", user.NickName) + a.Equal("Duff rules!!", user.Description) + a.Equal("http://example.com/image.jpg", user.AvatarURL) + a.Equal("1234", user.UserID) + a.Equal("Springfield", user.Location) + a.Equal("TOKEN", user.AccessToken) + a.Equal("duffman@springfield.com", user.Email) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := twitterProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) + a.NoError(err) + session := s.(*Session) + a.Equal(session.AuthURL, "http://com/auth_url") + a.Equal(session.AccessToken.Token, "1234567890") + a.Equal(session.AccessToken.Secret, "secret!!") + a.Equal(session.RequestToken.Token, "0987654321") + a.Equal(session.RequestToken.Secret, "!!secret") +} + +func twitterProvider() *Provider { + return New(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") +} + +func twitterProviderAuthenticate() *Provider { + return NewAuthenticate(os.Getenv("TWITTER_KEY"), os.Getenv("TWITTER_SECRET"), "/foo") +} + +func init() { + p := pat.New() + p.Get("/oauth/request_token", func(res http.ResponseWriter, req *http.Request) { + fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") + }) + p.Get("/1.1/account/verify_credentials.json", func(res http.ResponseWriter, req *http.Request) { + data := map[string]string{ + "name": "Homer", + "screen_name": "duffman", + "description": "Duff rules!!", + "profile_image_url": "http://example.com/image.jpg", + "id_str": "1234", + "location": "Springfield", + "email": "duffman@springfield.com", + } + json.NewEncoder(res).Encode(&data) + }) + ts := httptest.NewServer(p) + + requestURL = ts.URL + "/oauth/request_token" + endpointProfile = ts.URL + "/1.1/account/verify_credentials.json" +} diff --git a/providers/typetalk/session.go b/providers/typetalk/session.go new file mode 100644 index 000000000..9d5d5cf94 --- /dev/null +++ b/providers/typetalk/session.go @@ -0,0 +1,63 @@ +package typetalk + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Typetalk. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Typetalk provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Typetalk and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/typetalk/session_test.go b/providers/typetalk/session_test.go new file mode 100644 index 000000000..cf4bcee2b --- /dev/null +++ b/providers/typetalk/session_test.go @@ -0,0 +1,48 @@ +package typetalk_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/typetalk" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &typetalk.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &typetalk.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &typetalk.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &typetalk.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/typetalk/typetalk.go b/providers/typetalk/typetalk.go new file mode 100644 index 000000000..9822cb475 --- /dev/null +++ b/providers/typetalk/typetalk.go @@ -0,0 +1,205 @@ +// Package typetalk implements the OAuth2 protocol for authenticating users through Typetalk. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +// +// Typetalk API Docs: https://developer.nulab-inc.com/docs/typetalk/auth/ +package typetalk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://typetalk.com/oauth2/authorize" + tokenURL string = "https://typetalk.com/oauth2/access_token" + endpointProfile string = "https://typetalk.com/api/v1/profile" + endpointUser string = "https://typetalk.com/api/v1/accounts/profile/" +) + +// Provider is the implementation of `goth.Provider` for accessing Typetalk. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Typetalk provider and sets up important connection details. +// You should always call `typetalk.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "typetalk", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers os 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client returns HTTP client. +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the typetalk package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Typetalk for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Typetalk and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + // Get username + response, err := p.Client().Get(endpointProfile + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user name", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + u := struct { + Account struct { + Name string `json:"name"` + } `json:"account"` + }{} + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u) + if err != nil { + return user, err + } + + // Get user profile info + response, err = p.Client().Get(endpointUser + u.Account.Name + "?access_token=" + url.QueryEscape(sess.AccessToken)) + if err != nil { + if response != nil { + response.Body.Close() + } + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch profile", p.providerName, response.StatusCode) + } + + bits, err = io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "my") + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Account struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"fullName"` + Suggestion string `json:"suggestion"` + MailAddress string `json:"mailAddress"` + ImageURL string `json:"imageUrl"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ImageUpdatedAt string `json:"imageUpdatedAt"` + } `json:"account"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.UserID = strconv.FormatInt(u.Account.ID, 10) + user.Email = u.Account.MailAddress + user.Name = u.Account.FullName + user.NickName = u.Account.Name + user.AvatarURL = u.Account.ImageURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, nil +} diff --git a/providers/typetalk/typetalk_test.go b/providers/typetalk/typetalk_test.go new file mode 100644 index 000000000..f09f361a1 --- /dev/null +++ b/providers/typetalk/typetalk_test.go @@ -0,0 +1,53 @@ +package typetalk_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/typetalk" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("TYPETALK_KEY")) + a.Equal(p.Secret, os.Getenv("TYPETALK_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*typetalk.Session) + a.NoError(err) + a.Contains(s.AuthURL, "typetalk.com/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://typetalk.com/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*typetalk.Session) + a.Equal(s.AuthURL, "https://typetalk.com/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *typetalk.Provider { + return typetalk.New(os.Getenv("TYPETALK_KEY"), os.Getenv("TYPETALK_SECRET"), "/foo") +} diff --git a/providers/uber/session.go b/providers/uber/session.go new file mode 100644 index 000000000..41dabc361 --- /dev/null +++ b/providers/uber/session.go @@ -0,0 +1,63 @@ +package uber + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Uber. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Uber provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Uber and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/uber/session_test.go b/providers/uber/session_test.go new file mode 100644 index 000000000..e8ffa0b73 --- /dev/null +++ b/providers/uber/session_test.go @@ -0,0 +1,48 @@ +package uber_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/uber" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &uber.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &uber.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &uber.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &uber.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/uber/uber.go b/providers/uber/uber.go new file mode 100644 index 000000000..de48df81c --- /dev/null +++ b/providers/uber/uber.go @@ -0,0 +1,161 @@ +// Package uber implements the OAuth2 protocol for authenticating users through uber. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package uber + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://login.uber.com/oauth/authorize" + tokenURL string = "https://login.uber.com/oauth/token" + endpointProfile string = "https://api.uber.com/v1/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Uber. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Uber provider and sets up important connection details. +// You should always call `uber.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "uber", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the uber package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Uber for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Uber and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "profile") + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Name string `json:"first_name"` + Email string `json:"email"` + ID string `json:"uuid"` + AvatarURL string `json:"picture"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.UserID = u.ID + user.AvatarURL = u.AvatarURL + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/uber/uber_test.go b/providers/uber/uber_test.go new file mode 100644 index 000000000..efd2d8114 --- /dev/null +++ b/providers/uber/uber_test.go @@ -0,0 +1,53 @@ +package uber_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/uber" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("UBER_KEY")) + a.Equal(p.Secret, os.Getenv("UBER_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*uber.Session) + a.NoError(err) + a.Contains(s.AuthURL, "login.uber.com/oauth/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://login.uber.com/oauth/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*uber.Session) + a.Equal(s.AuthURL, "https://login.uber.com/oauth/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *uber.Provider { + return uber.New(os.Getenv("UBER_KEY"), os.Getenv("UBER_SECRET"), "/foo") +} diff --git a/providers/vk/session.go b/providers/vk/session.go new file mode 100644 index 000000000..4331a4afa --- /dev/null +++ b/providers/vk/session.go @@ -0,0 +1,62 @@ +package vk + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with VK. +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time + email string +} + +// GetAuthURL returns the URL for the authentication end-point for the provider. +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Marshal the session into a string +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +// Authorize the session with VK and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + email, ok := token.Extra("email").(string) + if !ok { + return "", errors.New("Cannot fetch user email") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + s.email = email + return s.AccessToken, err +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := new(Session) + err := json.NewDecoder(strings.NewReader(data)).Decode(&sess) + return sess, err +} diff --git a/providers/vk/session_test.go b/providers/vk/session_test.go new file mode 100644 index 000000000..53abf246e --- /dev/null +++ b/providers/vk/session_test.go @@ -0,0 +1,40 @@ +package vk_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/vk" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &vk.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &vk.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &vk.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} diff --git a/providers/vk/vk.go b/providers/vk/vk.go new file mode 100644 index 000000000..f0619f2e5 --- /dev/null +++ b/providers/vk/vk.go @@ -0,0 +1,183 @@ +// Package vk implements the OAuth2 protocol for authenticating users through vk.com. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package vk + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +var ( + authURL = "https://oauth.vk.com/authorize" + tokenURL = "https://oauth.vk.com/access_token" + endpointUser = "https://api.vk.com/method/users.get" + apiVersion = "5.131" +) + +// New creates a new VK provider and sets up important connection details. +// You should always call `vk.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "vk", + } + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `goth.Provider` for accessing VK. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + version string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// BeginAuth asks VK for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + url := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: url, + } + + return session, nil +} + +// FetchUser will go to VK and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + ExpiresAt: sess.ExpiresAt, + Email: sess.email, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + fields := "photo_200,nickname" + requestURL := fmt.Sprintf("%s?fields=%s&access_token=%s&v=%s", endpointUser, fields, sess.AccessToken, apiVersion) + response, err := p.Client().Get(requestURL) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func userFromReader(reader io.Reader, user *goth.User) error { + response := struct { + Response []struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + NickName string `json:"nickname"` + Photo200 string `json:"photo_200"` + } `json:"response"` + }{} + + err := json.NewDecoder(reader).Decode(&response) + if err != nil { + return err + } + + if len(response.Response) == 0 { + return fmt.Errorf("vk cannot get user information") + } + + u := response.Response[0] + + user.UserID = strconv.FormatInt(u.ID, 10) + user.FirstName = u.FirstName + user.LastName = u.LastName + user.NickName = u.NickName + user.AvatarURL = u.Photo200 + + return err +} + +// Debug is a no-op for the vk package. +func (p *Provider) Debug(debug bool) {} + +// RefreshToken refresh token is not provided by vk +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by vk") +} + +// RefreshTokenAvailable refresh token is not provided by vk +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{ + "email", + }, + } + + defaultScopes := map[string]struct{}{ + "email": {}, + } + + for _, scope := range scopes { + if _, exists := defaultScopes[scope]; !exists { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} diff --git a/providers/vk/vk_test.go b/providers/vk/vk_test.go new file mode 100644 index 000000000..a4310b79c --- /dev/null +++ b/providers/vk/vk_test.go @@ -0,0 +1,76 @@ +package vk_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/vk" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := vkProvider() + a.Equal(provider.ClientKey, os.Getenv("VK_KEY")) + a.Equal(provider.Secret, os.Getenv("VK_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Name(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := vkProvider() + a.Equal(provider.Name(), "vk") +} + +func Test_SetName(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := vkProvider() + provider.SetName("foo") + a.Equal(provider.Name(), "foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), vkProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := vkProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*vk.Session) + a.NoError(err) + a.Contains(s.AuthURL, "oauth.vk.com/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("VK_KEY"))) + a.Contains(s.AuthURL, "state=test_state") + a.Contains(s.AuthURL, "scope=email") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := vkProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://vk.com/auth_url","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*vk.Session) + a.Equal(session.AuthURL, "http://vk.com/auth_url") + a.Equal(session.AccessToken, "1234567890") +} + +func vkProvider() *vk.Provider { + return vk.New(os.Getenv("VK_KEY"), os.Getenv("VK_SECRET"), "/foo", "user") +} diff --git a/providers/wechat/session.go b/providers/wechat/session.go new file mode 100644 index 000000000..ab0330a77 --- /dev/null +++ b/providers/wechat/session.go @@ -0,0 +1,67 @@ +package wechat + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Wechat. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time + Openid string + Unionid string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Wepay provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Wepay and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, openid, err := p.fetchToken(params.Get("code")) + + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + s.Openid = openid + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/wechat/session_test.go b/providers/wechat/session_test.go new file mode 100644 index 000000000..128473a3a --- /dev/null +++ b/providers/wechat/session_test.go @@ -0,0 +1,48 @@ +package wechat_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/wechat" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wechat.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wechat.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wechat.Session{} + + data := s.Marshal() + a.Equal(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","Openid":"","Unionid":""}`, data) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wechat.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/wechat/wechat.go b/providers/wechat/wechat.go new file mode 100644 index 000000000..416b5eb04 --- /dev/null +++ b/providers/wechat/wechat.go @@ -0,0 +1,237 @@ +package wechat + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + AuthURL = "https://open.weixin.qq.com/connect/qrconnect" + TokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token" + + ScopeSnsapiLogin = "snsapi_login" + + ProfileURL = "https://api.weixin.qq.com/sns/userinfo" +) + +type Provider struct { + providerName string + config *oauth2.Config + httpClient *http.Client + ClientID string + ClientSecret string + RedirectURL string + Lang WechatLangType + + AuthURL string + TokenURL string + ProfileURL string +} + +type WechatLangType string + +const ( + WECHAT_LANG_CN WechatLangType = "cn" + WECHAT_LANG_EN WechatLangType = "en" +) + +// New creates a new Wechat provider, and sets up important connection details. +// You should always call `wechat.New` to get a new Provider. Never try to create +// one manually. +func New(clientID, clientSecret, redirectURL string, lang WechatLangType) *Provider { + p := &Provider{ + providerName: "wechat", + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Lang: lang, + AuthURL: AuthURL, + TokenURL: TokenURL, + ProfileURL: ProfileURL, + } + p.config = newConfig(p) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.httpClient) +} + +// Debug is a no-op for the wechat package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Wechat for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + params := url.Values{} + params.Add("appid", p.ClientID) + params.Add("response_type", "code") + params.Add("state", state) + params.Add("scope", ScopeSnsapiLogin) + params.Add("redirect_uri", p.RedirectURL) + session := &Session{ + AuthURL: fmt.Sprintf("%s?%s", p.AuthURL, params.Encode()), + } + return session, nil +} + +// FetchUser will go to Wepay and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + params := url.Values{} + params.Add("access_token", s.AccessToken) + params.Add("openid", s.Openid) + params.Add("lang", string(p.Lang)) + + url := fmt.Sprintf("%s?%s", p.ProfileURL, params.Encode()) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return user, err + } + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientID, + ClientSecret: provider.ClientSecret, + RedirectURL: provider.RedirectURL, + Endpoint: oauth2.Endpoint{ + AuthURL: provider.AuthURL, + TokenURL: provider.TokenURL, + }, + Scopes: []string{}, + } + + c.Scopes = append(c.Scopes, ScopeSnsapiLogin) + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Openid string `json:"openid"` + Nickname string `json:"nickname"` + Sex int `json:"sex"` + Province string `json:"province"` + City string `json:"city"` + Country string `json:"country"` + AvatarURL string `json:"headimgurl"` + Unionid string `json:"unionid"` + Code int `json:"errcode"` + Msg string `json:"errmsg"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + + if len(u.Msg) > 0 { + return fmt.Errorf("CODE: %d, MSG: %s", u.Code, u.Msg) + } + + user.Email = fmt.Sprintf("%s@wechat.com", u.Openid) + user.Name = u.Nickname + user.UserID = u.Openid + user.NickName = u.Nickname + user.Location = u.City + user.AvatarURL = u.AvatarURL + user.RawData = map[string]interface{}{ + "Unionid": u.Unionid, + } + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + + return nil, nil +} + +func (p *Provider) fetchToken(code string) (*oauth2.Token, string, error) { + + params := url.Values{} + params.Add("appid", p.ClientID) + params.Add("secret", p.ClientSecret) + params.Add("grant_type", "authorization_code") + params.Add("code", code) + url := fmt.Sprintf("%s?%s", p.TokenURL, params.Encode()) + resp, err := p.Client().Get(url) + + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("wechat /gettoken returns code: %d", resp.StatusCode) + } + + obj := struct { + AccessToken string `json:"access_token"` + ExpiresIn time.Duration `json:"expires_in"` + Openid string `json:"openid"` + Code int `json:"errcode"` + Msg string `json:"errmsg"` + }{} + if err = json.NewDecoder(resp.Body).Decode(&obj); err != nil { + return nil, "", err + } + if obj.Code != 0 { + return nil, "", fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) + } + + token := &oauth2.Token{ + AccessToken: obj.AccessToken, + Expiry: time.Now().Add(obj.ExpiresIn * time.Second), + } + + return token, obj.Openid, nil +} diff --git a/providers/wechat/wechat_test.go b/providers/wechat/wechat_test.go new file mode 100644 index 000000000..317f2112d --- /dev/null +++ b/providers/wechat/wechat_test.go @@ -0,0 +1,53 @@ +package wechat_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/wechat" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientID, os.Getenv("WECHAT_KEY")) + a.Equal(p.ClientSecret, os.Getenv("WECHAT_SECRET")) + a.Equal(p.RedirectURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*wechat.Session) + a.NoError(err) + a.Contains(s.AuthURL, "open.weixin.qq.com/connect/qrconnect") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://open.weixin.qq.com/connect/qrconnect","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*wechat.Session) + a.Equal(s.AuthURL, "https://open.weixin.qq.com/connect/qrconnect") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *wechat.Provider { + return wechat.New(os.Getenv("WECHAT_KEY"), os.Getenv("WECHAT_SECRET"), "/foo", wechat.WECHAT_LANG_CN) +} diff --git a/providers/wecom/session.go b/providers/wecom/session.go new file mode 100644 index 000000000..49ae5ba2a --- /dev/null +++ b/providers/wecom/session.go @@ -0,0 +1,55 @@ +package wecom + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with WeCom. +type Session struct { + AuthURL string + AccessToken string + UserID string +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the WeCom provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with WeCom and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.fetchToken() + if err != nil { + return "", err + } + s.AccessToken = token.AccessToken + + userID, err := p.fetchUserID(s, params.Get("code")) + if err != nil { + return "", err + } + s.UserID = userID + + return s.AccessToken, nil +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/wecom/session_test.go b/providers/wecom/session_test.go new file mode 100644 index 000000000..2ba260aed --- /dev/null +++ b/providers/wecom/session_test.go @@ -0,0 +1,40 @@ +package wecom_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/wecom" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wecom.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wecom.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_Marshal(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wecom.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","UserID":""}`) +} diff --git a/providers/wecom/wecom.go b/providers/wecom/wecom.go new file mode 100644 index 000000000..c30cf5c0e --- /dev/null +++ b/providers/wecom/wecom.go @@ -0,0 +1,217 @@ +// Package wecom implements the qrConnect protocol for authenticating users through WeCom. +// Reference: https://work.weixin.qq.com/api/doc/90000/90135/90988 +package wecom + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +var ( + AuthURL = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect" + BaseURL = "https://qyapi.weixin.qq.com/cgi-bin" +) + +// New creates a new WeCom provider, and sets up important connection details. +func New(corpID, secret, agentID, callbackURL string) *Provider { + return &Provider{ + ClientKey: corpID, + Secret: secret, + AgentID: agentID, + CallbackURL: callbackURL, + providerName: "wecom", + authURL: AuthURL, + baseURL: BaseURL, + } +} + +// Provider is the implementation of `goth.Provider` for accessing WeCom. +type Provider struct { + ClientKey string + Secret string + AgentID string + CallbackURL string + HTTPClient *http.Client + providerName string + + // token caches the access_token + token *oauth2.Token + + authURL string + baseURL string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the wecom package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks WeCom for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + params := url.Values{} + params.Add("appid", p.ClientKey) + params.Add("agentid", p.AgentID) + params.Add("state", state) + params.Add("redirect_uri", p.CallbackURL) + session := &Session{ + AuthURL: fmt.Sprintf("%s?%s", p.authURL, params.Encode()), + } + return session, nil +} + +// FetchUser will go to WeCom and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + params := url.Values{} + params.Add("access_token", user.AccessToken) + params.Add("userid", sess.UserID) + resp, err := p.Client().Get(fmt.Sprintf("%s/user/get?%s", p.baseURL, params.Encode())) + if err != nil { + return user, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("wecom /user/get returns code: %d", resp.StatusCode) + } + + if err := userFromReader(resp.Body, &user); err != nil { + return user, err + } + + return user, nil +} + +// RefreshToken refresh token is not provided by WeCom +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("refresh token is not provided by wecom") +} + +// RefreshTokenAvailable refresh token is not provided by WeCom +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +func (p *Provider) fetchToken() (*oauth2.Token, error) { + if p.token != nil && p.token.Valid() { + return p.token, nil + } + + params := url.Values{} + params.Add("corpid", p.ClientKey) + params.Add("corpsecret", p.Secret) + resp, err := p.Client().Get(fmt.Sprintf("%s/gettoken?%s", p.baseURL, params.Encode())) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("wecom /gettoken returns code: %d", resp.StatusCode) + } + + obj := struct { + AccessToken string `json:"access_token"` + ExpiresIn time.Duration `json:"expires_in"` + Code int `json:"errcode"` + Msg string `json:"errmsg"` + }{} + if err = json.NewDecoder(resp.Body).Decode(&obj); err != nil { + return nil, err + } + if obj.Code != 0 { + return nil, fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) + } + + p.token = &oauth2.Token{ + AccessToken: obj.AccessToken, + Expiry: time.Now().Add(obj.ExpiresIn * time.Second), + } + + return p.token, nil +} + +func (p *Provider) fetchUserID(session goth.Session, code string) (string, error) { + sess := session.(*Session) + params := url.Values{} + params.Add("access_token", sess.AccessToken) + params.Add("code", code) + resp, err := p.Client().Get(fmt.Sprintf("%s/user/getuserinfo?%s", p.baseURL, params.Encode())) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("wecom /getuserinfo returns code: %d", resp.StatusCode) + } + + obj := struct { + UserId string `json:"UserId"` + Code int `json:"errcode"` + Msg string `json:"errmsg"` + }{} + if err = json.NewDecoder(resp.Body).Decode(&obj); err != nil { + return "", err + } + if obj.Code != 0 { + return "", fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) + } + + return obj.UserId, nil +} + +func userFromReader(reader io.Reader, user *goth.User) error { + obj := struct { + UserId string `json:"userid"` + Name string `json:"name"` + Email string `json:"email"` + Alias string `json:"alias"` + Avatar string `json:"avatar"` + Address string `json:"address"` + Code int `json:"errcode"` + Msg string `json:"errmsg"` + }{} + + if err := json.NewDecoder(reader).Decode(&obj); err != nil { + return err + } + if obj.Code != 0 { + return fmt.Errorf("CODE: %d, MSG: %s", obj.Code, obj.Msg) + } + + user.Name = obj.Name + user.NickName = obj.Alias + user.Email = obj.Email + user.UserID = obj.UserId + user.AvatarURL = obj.Avatar + user.Location = obj.Address + + return nil +} diff --git a/providers/wecom/wecom_test.go b/providers/wecom/wecom_test.go new file mode 100644 index 000000000..2e4457561 --- /dev/null +++ b/providers/wecom/wecom_test.go @@ -0,0 +1,61 @@ +package wecom_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/wecom" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), wecomProvider()) +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := wecomProvider() + a.Equal(provider.ClientKey, os.Getenv("WECOM_CORP_ID")) + a.Equal(provider.Secret, os.Getenv("WECOM_SECRET")) + a.Equal(provider.AgentID, os.Getenv("WECOM_AGENT_ID")) + a.Equal(provider.CallbackURL, "/foo") +} + +func TestBeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := wecomProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*wecom.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://open.work.weixin.qq.com/wwopen/sso/qrConnect") + a.Contains(s.AuthURL, fmt.Sprintf("appid=%s", os.Getenv("WECOM_CORP_ID"))) + a.Contains(s.AuthURL, fmt.Sprintf("agentid=%s", os.Getenv("WECOM_AGENT_ID"))) + a.Contains(s.AuthURL, "state=test_state") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := wecomProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://wecom/auth_url","AccessToken":"1234567890","UserID":"1122334455"}`) + a.NoError(err) + session := s.(*wecom.Session) + a.Equal(session.AuthURL, "http://wecom/auth_url") + a.Equal(session.AccessToken, "1234567890") + a.Equal(session.UserID, "1122334455") +} + +func wecomProvider() *wecom.Provider { + return wecom.New(os.Getenv("WECOM_CORP_ID"), os.Getenv("WECOM_SECRET"), os.Getenv("WECOM_AGENT_ID"), "/foo") +} diff --git a/providers/wepay/session.go b/providers/wepay/session.go new file mode 100644 index 000000000..0316aec26 --- /dev/null +++ b/providers/wepay/session.go @@ -0,0 +1,65 @@ +package wepay + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Wepay. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Wepay provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Wepay and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + oauth2.RegisterBrokenAuthHeaderProvider(tokenURL) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/wepay/session_test.go b/providers/wepay/session_test.go new file mode 100644 index 000000000..c2a483588 --- /dev/null +++ b/providers/wepay/session_test.go @@ -0,0 +1,48 @@ +package wepay_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/wepay" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wepay.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wepay.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wepay.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &wepay.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/wepay/wepay.go b/providers/wepay/wepay.go new file mode 100644 index 000000000..ad8fdf248 --- /dev/null +++ b/providers/wepay/wepay.go @@ -0,0 +1,155 @@ +// Package wepay implements the OAuth2 protocol for authenticating users through wepay. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package wepay + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.wepay.com/v2/oauth2/authorize" + tokenURL string = "https://wepayapi.com/v2/oauth2/token" + endpointProfile string = "https://wepayapi.com/v2/user" +) + +// Provider is the implementation of `goth.Provider` for accessing Wepay. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Wepay provider and sets up important connection details. +// You should always call `wepay.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "wepay", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the wepay package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Wepay for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Wepay and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + // Wepay is not recognising scope, if scope is not present as first parameter + newAuthURL := authURL + + if len(scopes) > 0 { + newAuthURL = newAuthURL + "?scope=" + strings.Join(scopes, ",") + } else { + newAuthURL = newAuthURL + "?scope=view_user" + } + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: newAuthURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + Email string `json:"email"` + UserName string `json:"user_name"` + ID int `json:"user_id"` + }{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.UserName + user.UserID = strconv.Itoa(u.ID) + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + + return nil, nil +} diff --git a/providers/wepay/wepay_test.go b/providers/wepay/wepay_test.go new file mode 100644 index 000000000..c8a322920 --- /dev/null +++ b/providers/wepay/wepay_test.go @@ -0,0 +1,53 @@ +package wepay_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/wepay" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("WEPAY_KEY")) + a.Equal(p.Secret, os.Getenv("WEPAY_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*wepay.Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.wepay.com/v2/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.wepay.com/v2/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*wepay.Session) + a.Equal(s.AuthURL, "https://www.wepay.com/v2/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *wepay.Provider { + return wepay.New(os.Getenv("WEPAY_KEY"), os.Getenv("WEPAY_SECRET"), "/foo") +} diff --git a/providers/xero/session.go b/providers/xero/session.go new file mode 100644 index 000000000..c4ed58cc5 --- /dev/null +++ b/providers/xero/session.go @@ -0,0 +1,61 @@ +package xero + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" +) + +// Session stores data during the auth process with Xero. +type Session struct { + AuthURL string + AccessToken *oauth.AccessToken + RequestToken *oauth.RequestToken + AccessTokenExpires time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Xero provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Xero and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + if p.Method == "private" { + return p.ClientKey, nil + } + accessToken, err := p.consumer.AuthorizeToken(s.RequestToken, params.Get("oauth_verifier")) + if err != nil { + return "", err + } + + s.AccessTokenExpires = time.Now().UTC().Add(30 * time.Minute) + s.AccessToken = accessToken + + return accessToken.Token, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/xero/session_test.go b/providers/xero/session_test.go new file mode 100644 index 000000000..326245655 --- /dev/null +++ b/providers/xero/session_test.go @@ -0,0 +1,48 @@ +package xero_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/xero" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &xero.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &xero.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &xero.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":null,"RequestToken":null,"AccessTokenExpires":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &xero.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/xero/xero.go b/providers/xero/xero.go new file mode 100644 index 000000000..621bd6bc8 --- /dev/null +++ b/providers/xero/xero.go @@ -0,0 +1,260 @@ +// Package xero implements the OAuth protocol for authenticating users through Xero. +package xero + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "golang.org/x/oauth2" + + "github.com/markbates/goth" + "github.com/mrjones/oauth" +) + +// Organisation is the expected response from the Organisation endpoint - this is not a complete schema +type Organisation struct { + // Display name of organisation shown in Xero + Name string `json:"Name,omitempty"` + + // Organisation name shown on Reports + LegalName string `json:"LegalName,omitempty"` + + // Organisation Type + OrganisationType string `json:"OrganisationType,omitempty"` + + // Country code for organisation. See ISO 3166-2 Country Codes + CountryCode string `json:"CountryCode,omitempty"` + + // A unique identifier for the organisation. Potential uses. + ShortCode string `json:"ShortCode,omitempty"` +} + +// APIResponse is the Total response from the Xero API +type APIResponse struct { + Organisations []Organisation `json:"Organisations,omitempty"` +} + +var ( + requestURL = "https://api.xero.com/oauth/RequestToken" + authorizeURL = "https://api.xero.com/oauth/Authorize" + tokenURL = "https://api.xero.com/oauth/AccessToken" + endpointProfile = "https://api.xero.com/api.xro/2.0/" + // userAgentString should be changed to match the name of your Application + userAgentString = os.Getenv("XERO_USER_AGENT") + " (goth-xero 1.0)" + privateKeyFilePath = os.Getenv("XERO_PRIVATE_KEY_PATH") +) + +// New creates a new Xero provider, and sets up important connection details. +// You should always call `xero.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey, secret, callbackURL string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + // Method determines how you will connect to Xero. + // Options are public, private, and partner + // Use public if this is your first time. + // More details here: https://developer.xero.com/documentation/getting-started/api-application-types + Method: os.Getenv("XERO_METHOD"), + providerName: "xero", + } + + switch p.Method { + case "private": + p.consumer = newPrivateOrPartnerConsumer(p, authorizeURL) + case "public": + p.consumer = newPublicConsumer(p, authorizeURL) + case "partner": + p.consumer = newPrivateOrPartnerConsumer(p, authorizeURL) + default: + p.consumer = newPublicConsumer(p, authorizeURL) + } + return p +} + +// Provider is the implementation of `goth.Provider` for accessing Xero. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + Method string + debug bool + consumer *oauth.Consumer + providerName string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Client does pretty much everything +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug sets the logging of the OAuth client to verbose. +func (p *Provider) Debug(debug bool) { + p.debug = debug +} + +// BeginAuth asks Xero for an authentication end-point and a request token for a session. +// Xero does not support the "state" variable. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + requestToken, url, err := p.consumer.GetRequestTokenAndUrl(p.CallbackURL) + if err != nil { + return nil, err + } + session := &Session{ + AuthURL: url, + RequestToken: requestToken, + } + return session, err +} + +// FetchUser will go to Xero and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + Provider: p.Name(), + } + + if sess.AccessToken == nil { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + response, err := p.consumer.Get( + endpointProfile+"Organisation", + nil, + sess.AccessToken) + + if err != nil { + return user, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + var apiResponse APIResponse + responseBytes, err := io.ReadAll(response.Body) + if err != nil { + return user, fmt.Errorf("Could not read response: %s", err.Error()) + } + if responseBytes == nil { + return user, fmt.Errorf("Received no response: %s", err.Error()) + } + err = json.Unmarshal(responseBytes, &apiResponse) + if err != nil { + return user, fmt.Errorf("Could not unmarshal response: %s", err.Error()) + } + + user.Name = apiResponse.Organisations[0].Name + user.NickName = apiResponse.Organisations[0].LegalName + user.Location = apiResponse.Organisations[0].CountryCode + user.Description = apiResponse.Organisations[0].OrganisationType + user.UserID = apiResponse.Organisations[0].ShortCode + + user.AccessToken = sess.AccessToken.Token + user.AccessTokenSecret = sess.AccessToken.Secret + user.ExpiresAt = sess.AccessTokenExpires + return user, err +} + +// newPublicConsumer creates a consumer capable of communicating with a Public application: https://developer.xero.com/documentation/auth-and-limits/public-applications +func newPublicConsumer(provider *Provider, authURL string) *oauth.Consumer { + c := oauth.NewConsumer( + provider.ClientKey, + provider.Secret, + oauth.ServiceProvider{ + RequestTokenUrl: requestURL, + AuthorizeTokenUrl: authURL, + AccessTokenUrl: tokenURL}, + ) + + c.Debug(provider.debug) + + accepttype := []string{"application/json"} + useragent := []string{userAgentString} + c.AdditionalHeaders = map[string][]string{ + "Accept": accepttype, + "User-Agent": useragent, + } + + return c +} + +// newPartnerConsumer creates a consumer capable of communicating with a Partner application: https://developer.xero.com/documentation/auth-and-limits/partner-applications +func newPrivateOrPartnerConsumer(provider *Provider, authURL string) *oauth.Consumer { + privateKeyFileContents, err := os.ReadFile(privateKeyFilePath) + if err != nil { + log.Fatal(err) + } + + block, _ := pem.Decode(privateKeyFileContents) + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + log.Fatal(err) + } + c := oauth.NewRSAConsumer( + provider.ClientKey, + privateKey, + oauth.ServiceProvider{ + RequestTokenUrl: requestURL, + AuthorizeTokenUrl: authURL, + AccessTokenUrl: tokenURL}, + ) + + c.Debug(provider.debug) + + accepttype := []string{"application/json"} + useragent := []string{userAgentString} + c.AdditionalHeaders = map[string][]string{ + "Accept": accepttype, + "User-Agent": useragent, + } + + return c +} + +// RefreshOAuth1Token should be used instead of RefeshToken which is not compliant with the Oauth1.0a standard +func (p *Provider) RefreshOAuth1Token(session *Session) error { + newAccessToken, err := p.consumer.RefreshToken(session.AccessToken) + if err != nil { + return err + } + session.AccessToken = newAccessToken + session.AccessTokenExpires = time.Now().UTC().Add(30 * time.Minute) + return nil +} + +// RefreshToken refresh token is not provided by the Xero Public or Private Application - +// only the Partner Application and you must use RefreshOAuth1Token instead +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is only provided by Xero for Partner Applications") +} + +// RefreshTokenAvailable refresh token is not provided by the Xero Public or Private Application - +// only the Partner Application and you must use RefreshOAuth1Token instead +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/xero/xero_test.go b/providers/xero/xero_test.go new file mode 100644 index 000000000..17b2eb05f --- /dev/null +++ b/providers/xero/xero_test.go @@ -0,0 +1,124 @@ +package xero + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gorilla/pat" + "github.com/markbates/goth" + "github.com/mrjones/oauth" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := xeroProvider() + a.Equal(provider.ClientKey, os.Getenv("XERO_KEY")) + a.Equal(provider.Secret, os.Getenv("XERO_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + + a.Implements((*goth.Provider)(nil), xeroProvider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := xeroProvider() + session, err := provider.BeginAuth("state") + if err != nil { + a.Error(err, nil) + } + s := session.(*Session) + a.NoError(err) + a.Contains(s.AuthURL, "Authorize") + a.Equal("TOKEN", s.RequestToken.Token) + a.Equal("SECRET", s.RequestToken.Secret) +} + +func Test_FetchUser(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := xeroProvider() + session := Session{AccessToken: &oauth.AccessToken{Token: "TOKEN", Secret: "SECRET"}} + + user, err := provider.FetchUser(&session) + if err != nil { + a.Error(err, nil) + } + + a.NoError(err) + + a.Equal("Vanderlay Industries", user.Name) + a.Equal("Vanderlay Industries", user.NickName) + a.Equal("COMPANY", user.Description) + a.Equal("111-11", user.UserID) + a.Equal("NZ", user.Location) + a.Empty(user.Email) +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := xeroProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"http://com/auth_url","AccessToken":{"Token":"1234567890","Secret":"secret!!","AdditionalData":{}},"RequestToken":{"Token":"0987654321","Secret":"!!secret"}}`) + a.NoError(err) + session := s.(*Session) + a.Equal(session.AuthURL, "http://com/auth_url") + a.Equal(session.AccessToken.Token, "1234567890") + a.Equal(session.AccessToken.Secret, "secret!!") + a.Equal(session.RequestToken.Token, "0987654321") + a.Equal(session.RequestToken.Secret, "!!secret") +} + +func xeroProvider() *Provider { + return New(os.Getenv("XERO_KEY"), os.Getenv("XERO_SECRET"), "/foo") +} + +func init() { + p := pat.New() + p.Get("/oauth/RequestToken", func(res http.ResponseWriter, req *http.Request) { + fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") + }) + p.Get("/oauth/Authorize", func(res http.ResponseWriter, req *http.Request) { + fmt.Fprint(res, "DO NOT USE THIS ENDPOINT") + }) + p.Get("/oauth/AccessToken", func(res http.ResponseWriter, req *http.Request) { + fmt.Fprint(res, "oauth_token=TOKEN&oauth_token_secret=SECRET") + }) + p.Get("/api.xro/2.0/Organisation", func(res http.ResponseWriter, req *http.Request) { + apiResponse := APIResponse{ + Organisations: []Organisation{ + {"Vanderlay Industries", "Vanderlay Industries", "COMPANY", "NZ", "111-11"}, + }, + } + + js, err := json.Marshal(apiResponse) + if err != nil { + fmt.Fprint(res, "Json did not Marshal") + } + + res.Write(js) + }) + + ts := httptest.NewServer(p) + + requestURL = ts.URL + "/oauth/RequestToken" + endpointProfile = ts.URL + "/api.xro/2.0/" + authorizeURL = ts.URL + "/oauth/Authorize" + tokenURL = ts.URL + "/oauth/AccessToken" +} diff --git a/providers/yahoo/session.go b/providers/yahoo/session.go new file mode 100644 index 000000000..3cacb5831 --- /dev/null +++ b/providers/yahoo/session.go @@ -0,0 +1,63 @@ +package yahoo + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Yahoo. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Yahoo provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Yahoo and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/yahoo/session_test.go b/providers/yahoo/session_test.go new file mode 100644 index 000000000..8f0be485a --- /dev/null +++ b/providers/yahoo/session_test.go @@ -0,0 +1,48 @@ +package yahoo_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/yahoo" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yahoo.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yahoo.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yahoo.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yahoo.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/yahoo/yahoo.go b/providers/yahoo/yahoo.go new file mode 100644 index 000000000..e6a2065d4 --- /dev/null +++ b/providers/yahoo/yahoo.go @@ -0,0 +1,166 @@ +// Package yahoo implements the OAuth2 protocol for authenticating users through yahoo. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package yahoo + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://api.login.yahoo.com/oauth2/request_auth" + tokenURL string = "https://api.login.yahoo.com/oauth2/get_token" + endpointProfile string = "https://api.login.yahoo.com/openid/v1/userinfo" +) + +// Provider is the implementation of `goth.Provider` for accessing Yahoo. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Yahoo provider and sets up important connection details. +// You should always call `yahoo.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "yahoo", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the yahoo package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Yahoo for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Yahoo and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + ExpiresAt: s.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +type yahooUser struct { + Email string `json:"email"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Nickname string `json:"nickname"` + Picture string `json:"picture"` + Sub string `json:"sub"` +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := yahooUser{} + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.Email = u.Email + user.Name = u.Name + user.FirstName = u.GivenName + user.LastName = u.FamilyName + user.NickName = u.Nickname + user.AvatarURL = u.Picture + user.UserID = u.Sub + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/yahoo/yahoo_test.go b/providers/yahoo/yahoo_test.go new file mode 100644 index 000000000..7c42df5e2 --- /dev/null +++ b/providers/yahoo/yahoo_test.go @@ -0,0 +1,53 @@ +package yahoo_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/yahoo" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("YAHOO_KEY")) + a.Equal(p.Secret, os.Getenv("YAHOO_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*yahoo.Session) + a.NoError(err) + a.Contains(s.AuthURL, "api.login.yahoo.com/oauth2/request_auth") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://api.login.yahoo.com/oauth2/request_auth","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*yahoo.Session) + a.Equal(s.AuthURL, "https://api.login.yahoo.com/oauth2/request_auth") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *yahoo.Provider { + return yahoo.New(os.Getenv("YAHOO_KEY"), os.Getenv("YAHOO_SECRET"), "/foo") +} diff --git a/providers/yammer/session.go b/providers/yammer/session.go new file mode 100644 index 000000000..b39019ef9 --- /dev/null +++ b/providers/yammer/session.go @@ -0,0 +1,109 @@ +package yammer + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/markbates/goth" +) + +// Session stores data during the auth process with Yammer. +type Session struct { + AuthURL string + AccessToken string +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Yammer provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Yammer and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + v := url.Values{ + "grant_type": {"authorization_code"}, + "code": CondVal(params.Get("code")), + "redirect_uri": CondVal(p.config.RedirectURL), + "scope": CondVal(strings.Join(p.config.Scopes, " ")), + } + // Cant use standard auth2 implementation as yammer returns access_token as json rather than string + // stand methods are throwing exception + // token, err := p.config.Exchange(goth.ContextForClient(p.Client), params.Get("code")) + autData, err := retrieveAuthData(p, tokenURL, v) + if err != nil { + return "", err + } + token := autData["access_token"]["token"].(string) + s.AccessToken = token + return token, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// Custom implementation for yammer to get access token and user data +// Yammer provides user data along with access token, no separate api available +func retrieveAuthData(p *Provider, TokenURL string, v url.Values) (map[string]map[string]interface{}, error) { + v.Set("client_id", p.ClientKey) + v.Set("client_secret", p.Secret) + req, err := http.NewRequest("POST", TokenURL, strings.NewReader(v.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + r, err := p.Client().Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + if code := r.StatusCode; code < 200 || code > 299 { + return nil, fmt.Errorf("oauth2: cannot fetch token: %v\nResponse: %s", r.Status, body) + } + + var objmap map[string]map[string]interface{} + + err = json.Unmarshal(body, &objmap) + + if err != nil { + return nil, err + } + return objmap, nil +} + +// CondVal convert string in string array +func CondVal(v string) []string { + if v == "" { + return nil + } + return []string{v} +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/yammer/session_test.go b/providers/yammer/session_test.go new file mode 100644 index 000000000..bb6a1d4de --- /dev/null +++ b/providers/yammer/session_test.go @@ -0,0 +1,48 @@ +package yammer_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/yammer" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yammer.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yammer.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yammer.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":""}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yammer.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/yammer/yammer.go b/providers/yammer/yammer.go new file mode 100644 index 000000000..bcc7a2168 --- /dev/null +++ b/providers/yammer/yammer.go @@ -0,0 +1,160 @@ +// Package yammer implements the OAuth2 protocol for authenticating users through yammer. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package yammer + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authURL string = "https://www.yammer.com/oauth2/authorize" + tokenURL string = "https://www.yammer.com/oauth2/access_token" + endpointProfile string = "https://www.yammer.com/api/v1/users/current.json" +) + +// Provider is the implementation of `goth.Provider` for accessing Yammer. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Yammer provider and sets up important connection details. +// You should always call `yammer.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "yammer", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the yammer package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Yammer for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Yammer and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", endpointProfile, nil) + if err != nil { + return user, err + } + + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + + response, err := p.Client().Do(req) + if err != nil { + return user, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode) + } + + bits, err := io.ReadAll(response.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = populateUser(user.RawData, &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + return c +} + +func populateUser(userMap map[string]interface{}, user *goth.User) error { + user.Email = stringValue(userMap["email"]) + user.Name = stringValue(userMap["full_name"]) + user.NickName = stringValue(userMap["full_name"]) + user.UserID = strconv.FormatFloat(userMap["id"].(float64), 'f', -1, 64) + user.Location = stringValue(userMap["location"]) + return nil +} + +func stringValue(v interface{}) string { + if v == nil { + return "" + } + return v.(string) +} + +// RefreshToken refresh token is not provided by yammer +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("Refresh token is not provided by yammer") +} + +// RefreshTokenAvailable refresh token is not provided by yammer +func (p *Provider) RefreshTokenAvailable() bool { + return false +} diff --git a/providers/yammer/yammer_test.go b/providers/yammer/yammer_test.go new file mode 100644 index 000000000..5d76a0150 --- /dev/null +++ b/providers/yammer/yammer_test.go @@ -0,0 +1,53 @@ +package yammer_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/yammer" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("YAMMER_KEY")) + a.Equal(p.Secret, os.Getenv("YAMMER_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*yammer.Session) + a.NoError(err) + a.Contains(s.AuthURL, "www.yammer.com/oauth2/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://www.yammer.com/oauth2/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*yammer.Session) + a.Equal(s.AuthURL, "https://www.yammer.com/oauth2/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *yammer.Provider { + return yammer.New(os.Getenv("YAMMER_KEY"), os.Getenv("YAMMER_SECRET"), "/foo") +} diff --git a/providers/yandex/session.go b/providers/yandex/session.go new file mode 100644 index 000000000..587941664 --- /dev/null +++ b/providers/yandex/session.go @@ -0,0 +1,64 @@ +package yandex + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Yandex. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Yandex provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Yandex and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(oauth2.NoContext, params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + s.ExpiresAt = token.Expiry + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession will unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/yandex/session_test.go b/providers/yandex/session_test.go new file mode 100644 index 000000000..c52a97e67 --- /dev/null +++ b/providers/yandex/session_test.go @@ -0,0 +1,48 @@ +package yandex_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/yandex" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yandex.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yandex.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yandex.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &yandex.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/yandex/yandex.go b/providers/yandex/yandex.go new file mode 100644 index 000000000..a500564ea --- /dev/null +++ b/providers/yandex/yandex.go @@ -0,0 +1,182 @@ +// package yandex implements the OAuth2 protocol for authenticating users through Yandex. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package yandex + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const ( + authEndpoint string = "https://oauth.yandex.ru/authorize" + tokenEndpoint string = "https://oauth.yandex.com/token" + profileEndpoint string = "https://login.yandex.ru/info" + avatarURL string = "https://avatars.yandex.net/get-yapic" + avatarSize string = "islands-200" +) + +// Provider is the implementation of `goth.Provider` for accessing Yandex. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +// New creates a new Yandex provider and sets up important connection details. +// You should always call `yandex.New` to get a new provider. Never try to +// create one manually. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "yandex", + } + p.config = newConfig(p, scopes) + return p +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +// Debug is a no-op for the yandex package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks Yandex for an authentication end-point. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to Yandex and access basic information about the user. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + sess := session.(*Session) + user := goth.User{ + AccessToken: sess.AccessToken, + Provider: p.Name(), + RefreshToken: sess.RefreshToken, + ExpiresAt: sess.ExpiresAt, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", profileEndpoint, nil) + if err != nil { + return user, err + } + req.Header.Set("Authorization", "OAuth "+sess.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + bits, err := io.ReadAll(resp.Body) + if err != nil { + return user, err + } + + err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData) + if err != nil { + return user, err + } + + err = userFromReader(bytes.NewReader(bits), &user) + return user, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authEndpoint, + TokenURL: tokenEndpoint, + }, + Scopes: []string{}, + } + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } else { + c.Scopes = append(c.Scopes, "login:email login:info login:avatar") + } + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + u := struct { + UserID string `json:"id"` + Email string `json:"default_email"` + Login string `json:"login"` + Name string `json:"real_name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + AvatarID string `json:"default_avatar_id"` + IsAvatarEmpty bool `json:"is_avatar_empty"` + }{} + + err := json.NewDecoder(r).Decode(&u) + if err != nil { + return err + } + user.UserID = u.UserID + user.Email = u.Email + user.NickName = u.Login + user.Name = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + if u.AvatarID != `` { + user.AvatarURL = fmt.Sprintf("%s/%s/%s", avatarURL, u.AvatarID, avatarSize) + } + return nil +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} diff --git a/providers/yandex/yandex_test.go b/providers/yandex/yandex_test.go new file mode 100644 index 000000000..c37ea3a16 --- /dev/null +++ b/providers/yandex/yandex_test.go @@ -0,0 +1,61 @@ +package yandex_test + +import ( + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/yandex" + "github.com/stretchr/testify/assert" +) + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + + a.Equal(p.ClientKey, os.Getenv("YANDEX_KEY")) + a.Equal(p.Secret, os.Getenv("YANDEX_SECRET")) + a.Equal(p.CallbackURL, "/foo") +} + +func Test_Name(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + a.Equal(p.Name(), "yandex") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), provider()) +} + +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := provider() + session, err := p.BeginAuth("test_state") + s := session.(*yandex.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://oauth.yandex.ru/authorize") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + p := provider() + session, err := p.UnmarshalSession(`{"AuthURL":"https://oauth.yandex.ru/authorize","AccessToken":"1234567890"}`) + a.NoError(err) + + s := session.(*yandex.Session) + a.Equal(s.AuthURL, "https://oauth.yandex.ru/authorize") + a.Equal(s.AccessToken, "1234567890") +} + +func provider() *yandex.Provider { + return yandex.New(os.Getenv("YANDEX_KEY"), os.Getenv("YANDEX_SECRET"), "/foo") +} diff --git a/providers/zoom/session.go b/providers/zoom/session.go new file mode 100644 index 000000000..913f2d335 --- /dev/null +++ b/providers/zoom/session.go @@ -0,0 +1,79 @@ +package zoom + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +// Session stores data during the auth process with Zoom. +type Session struct { + AuthURL string + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +var _ goth.Session = &Session{} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Zoom provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +// Authorize the session with Zoom and return the access token to be stored for future use. +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + + var authParams []oauth2.AuthCodeOption + + // override redirect_uri if passed as param + redirectURL := params.Get("redirect_uri") + if redirectURL != "" { + authParams = append(authParams, oauth2.SetAuthURLParam("redirect_uri", redirectURL)) + } + + // set code_verifier if passed as param + codeVerifier := params.Get("code_verifier") + if codeVerifier != "" { + authParams = append(authParams, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + } + + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"), authParams...) + + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.RefreshToken = token.RefreshToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + s := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(s) + return s, err +} diff --git a/providers/zoom/session_test.go b/providers/zoom/session_test.go new file mode 100644 index 000000000..470aa81b7 --- /dev/null +++ b/providers/zoom/session_test.go @@ -0,0 +1,48 @@ +package zoom_test + +import ( + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/zoom" + "github.com/stretchr/testify/assert" +) + +func Test_Implements_Session(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &zoom.Session{} + + a.Implements((*goth.Session)(nil), s) +} + +func Test_GetAuthURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &zoom.Session{} + + _, err := s.GetAuthURL() + a.Error(err) + + s.AuthURL = "/foo" + + url, _ := s.GetAuthURL() + a.Equal(url, "/foo") +} + +func Test_ToJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &zoom.Session{} + + data := s.Marshal() + a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`) +} + +func Test_String(t *testing.T) { + t.Parallel() + a := assert.New(t) + s := &zoom.Session{} + + a.Equal(s.String(), s.Marshal()) +} diff --git a/providers/zoom/zoom.go b/providers/zoom/zoom.go new file mode 100644 index 000000000..a563f1457 --- /dev/null +++ b/providers/zoom/zoom.go @@ -0,0 +1,178 @@ +// Package zoom implements the OAuth2 protocol for authenticating users through zoo. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package zoom + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "golang.org/x/oauth2" + + "github.com/markbates/goth" +) + +var ( + authorizeURL = "https://zoom.us/oauth/authorize" + tokenURL = "https://zoom.us/oauth/token" + profileURL = "https://zoom.us/v2/users/me" +) + +// Provider is the implementation of `goth.Provider` for accessing Zoom. +type Provider struct { + ClientKey string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string +} + +type profileResp struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + AvatarURL string `json:"pic_url"` + ID string `json:"id"` +} + +// New creates a new Zoom provider and sets up connection details. +func New(clientKey, secret, callbackURL string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + providerName: "zoom", + } + p.config = newConfig(p, scopes) + return p +} + +// Name is the name used to retrieve the provider. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the zoom package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth returns zoom authentication endpoint. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser makes a request to profileURL and returns zoom user data. +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + s := session.(*Session) + user := goth.User{ + AccessToken: s.AccessToken, + Provider: p.Name(), + RefreshToken: s.RefreshToken, + } + + if user.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + + req, err := http.NewRequest("GET", profileURL, nil) + if err != nil { + return user, err + } + + req.Header.Set("Authorization", "Bearer "+s.AccessToken) + resp, err := p.Client().Do(req) + if err != nil { + return user, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode) + } + + err = userFromReader(resp.Body, &user) + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth provider or not +func (p *Provider) RefreshTokenAvailable() bool { + return true +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.ClientKey, + ClientSecret: provider.Secret, + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authorizeURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + if len(scopes) > 0 { + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + } + + return c +} + +func userFromReader(r io.Reader, user *goth.User) error { + var rawData map[string]interface{} + + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(r) + if err != nil { + return err + } + + err = json.Unmarshal(buf.Bytes(), &rawData) + if err != nil { + return err + } + + u := &profileResp{} + err = json.Unmarshal(buf.Bytes(), &u) + if err != nil { + return err + } + + user.Email = u.Email + user.FirstName = u.FirstName + user.LastName = u.LastName + user.Name = fmt.Sprintf("%s %s", u.FirstName, u.LastName) + user.UserID = u.ID + user.AvatarURL = u.AvatarURL + user.RawData = rawData + + return nil +} diff --git a/providers/zoom/zoom_test.go b/providers/zoom/zoom_test.go new file mode 100644 index 000000000..b90263dfe --- /dev/null +++ b/providers/zoom/zoom_test.go @@ -0,0 +1,55 @@ +package zoom_test + +import ( + "fmt" + "os" + "testing" + + "github.com/markbates/goth" + "github.com/markbates/goth/providers/zoom" + "github.com/stretchr/testify/assert" +) + +func zoomProvider() *zoom.Provider { + return zoom.New(os.Getenv("ZOOM_KEY"), os.Getenv("ZOOM_SECRET"), "/foo", "basic") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := zoomProvider() + a.Equal(provider.ClientKey, os.Getenv("ZOOM_KEY")) + a.Equal(provider.Secret, os.Getenv("ZOOM_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_Implements_Provider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), zoomProvider()) +} +func Test_BeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := zoomProvider() + session, err := provider.BeginAuth("test_state") + s := session.(*zoom.Session) + a.NoError(err) + a.Contains(s.AuthURL, "https://zoom.us/oauth/authorize") + a.Contains(s.AuthURL, fmt.Sprintf("client_id=%s", os.Getenv("ZOOM_KEY"))) + a.Contains(s.AuthURL, "state=test_state") +} + +func Test_SessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := zoomProvider() + + s, err := provider.UnmarshalSession(`{"AuthURL":"https://app.zoom.io/oauth","AccessToken":"1234567890"}`) + a.NoError(err) + session := s.(*zoom.Session) + a.Equal(session.AuthURL, "https://app.zoom.io/oauth") + a.Equal(session.AccessToken, "1234567890") +} diff --git a/session.go b/session.go new file mode 100644 index 000000000..2d40b50bb --- /dev/null +++ b/session.go @@ -0,0 +1,21 @@ +package goth + +// Params is used to pass data to sessions for authorization. An existing +// implementation, and the one most likely to be used, is `url.Values`. +type Params interface { + Get(string) string +} + +// Session needs to be implemented as part of the provider package. +// It will be marshaled and persisted between requests to "tie" +// the start and the end of the authorization process with a +// 3rd party provider. +type Session interface { + // GetAuthURL returns the URL for the authentication end-point for the provider. + GetAuthURL() (string, error) + // Marshal generates a string representation of the Session for storing between requests. + Marshal() string + // Authorize should validate the data from the provider and return an access token + // that can be stored for later access to the provider. + Authorize(Provider, Params) (string, error) +} diff --git a/user.go b/user.go new file mode 100644 index 000000000..3a8fd6c54 --- /dev/null +++ b/user.go @@ -0,0 +1,31 @@ +package goth + +import ( + "encoding/gob" + "time" +) + +func init() { + gob.Register(User{}) +} + +// User contains the information common amongst most OAuth and OAuth2 providers. +// All the "raw" data from the provider can be found in the `RawData` field. +type User struct { + RawData map[string]interface{} + Provider string + Email string + Name string + FirstName string + LastName string + NickName string + Description string + UserID string + AvatarURL string + Location string + AccessToken string + AccessTokenSecret string + RefreshToken string + ExpiresAt time.Time + IDToken string +} diff --git a/user_test.go b/user_test.go new file mode 100644 index 000000000..d5c015325 --- /dev/null +++ b/user_test.go @@ -0,0 +1 @@ +package goth_test

Log in with {{index $.ProvidersMap $value}}