diff --git a/.gitignore b/.gitignore index 6aa7c6698b..a249cc999f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.env +.env* vendor/ gotrue gotrue-arm64 diff --git a/README.md b/README.md index a0dcef2c48..a886997318 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ API service for handling user registration and authentication for JAM projects. It's based on OAuth2 and JWT and will handle user signup, authentication and custom user data. -## Quick Start -Create a `.env` file to store your own custom env vars. See [`example.env`](example.env) +## Quick Start + +Create a `.env` file to store your own custom env vars. See [`example.env`](example.env) + ```sh ./hack/postgresd.sh make build @@ -56,7 +58,7 @@ Header on which to rate limit the `/token` endpoint. `GOTRUE_RATE_LIMIT_EMAIL_SENT` - `string` -Rate limit the number of emails sent per hr on the following endpoints: `/signup`, `/invite`, `/magiclink`, `/recover`, `/otp`, & `/user`. +Rate limit the number of emails sent per hr on the following endpoints: `/signup`, `/invite`, `/magiclink`, `/recover`, `/otp`, & `/user`. `PASSWORD_MIN_LENGTH` - `int` @@ -109,8 +111,8 @@ Adds a prefix to all table names. Migrations are not applied automatically, so you will need to run them after you've built gotrue. -* If built locally: `./gotrue migrate` -* Using Docker: `docker run --rm gotrue gotrue migrate` +- If built locally: `./gotrue migrate` +- Using Docker: `docker run --rm gotrue gotrue migrate` ### Logging @@ -128,6 +130,7 @@ Controls what log levels are output. Choose from `panic`, `fatal`, `error`, `war If you wish logs to be written to a file, set `log_file` to a valid file path. ### Opentracing + Currently, only the Datadog tracer is supported. ```properties @@ -188,7 +191,7 @@ The default group to assign all new users to. ### External Authentication Providers -We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch` and `twitter` for external authentication. +We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `spotify`, `slack`, `twitch` and `twitter` for external authentication. Use the names as the keys underneath `external` to configure each separately. @@ -223,6 +226,7 @@ The base URL used for constructing the URLs to request authorization and access #### Apple OAuth To try out external authentication with Apple locally, you will need to do the following: + 1. Remap localhost to \ in your `/etc/hosts` config. 2. Configure gotrue to serve HTTPS traffic over localhost by replacing `ListenAndServe` in [api.go](api/api.go) with: ``` @@ -344,7 +348,10 @@ Default Content (if template is unavailable): ```html

You have been invited

-

You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:

+

+ You have been invited to create a user on {{ .SiteURL }}. Follow this link to + accept the invite: +

Accept the invite

``` @@ -400,7 +407,10 @@ Default Content (if template is unavailable): ```html

Confirm Change of Email

-

Follow this link to confirm the update of your email from {{ .Email }} to {{ .NewEmail }}:

+

+ Follow this link to confirm the update of your email from {{ .Email }} to {{ + .NewEmail }}: +

Change Email

``` @@ -410,7 +420,7 @@ Url of the webhook receiver endpoint. This will be called when events like `vali `WEBHOOK_SECRET` - `string` -Shared secret to authorize webhook requests. This secret signs the [JSON Web Signature](https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41) of the request. You *should* use this to verify the integrity of the request. Otherwise others can feed your webhook receiver with fake data. +Shared secret to authorize webhook requests. This secret signs the [JSON Web Signature](https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41) of the request. You _should_ use this to verify the integrity of the request. Otherwise others can feed your webhook receiver with fake data. `WEBHOOK_RETRIES` - `number` @@ -437,23 +447,25 @@ Controls the minimum amount of time that must pass before sending another sms ot `SMS_OTP_EXP` - `number` -Controls the duration an sms otp is valid for. +Controls the duration an sms otp is valid for. `SMS_OTP_LENGTH` - `number` -Controls the number of digits of the sms otp sent. +Controls the number of digits of the sms otp sent. `SMS_PROVIDER` - `string` for now only option is: `twilio` Then you can use your [twilio credentials](https://www.twilio.com/docs/usage/requests-to-twilio#credentials): + - `SMS_TWILIO_ACCOUNT_SID` - `SMS_TWILIO_AUTH_TOKEN` - `SMS_TWILIO_MESSAGE_SERVICE_SID` - can be set to your twilio sender mobile number ### CAPTCHA -* If enabled, CAPTCHA will check the request body for the `hcaptcha_token` field and make a verification request to the CAPTCHA provider. + +- If enabled, CAPTCHA will check the request body for the `hcaptcha_token` field and make a verification request to the CAPTCHA provider. `SECURITY_CAPTCHA_ENABLED` - `string` @@ -473,386 +485,401 @@ GoTrue exposes the following endpoints: ### **GET /settings** - Returns the publicly available settings for this gotrue instance. - - ```json - { - "external": { - "apple": true, - "azure": true, - "bitbucket": true, - "discord": true, - "facebook": true, - "github": true, - "gitlab": true, - "google": true, - "twitch": true, - "twitter": true - }, - "disable_signup": false, - "autoconfirm": false - } - ``` +Returns the publicly available settings for this gotrue instance. + +```json +{ + "external": { + "apple": true, + "azure": true, + "bitbucket": true, + "discord": true, + "facebook": true, + "github": true, + "gitlab": true, + "google": true, + "slack": true, + "spotify": true, + "twitch": true, + "twitter": true + }, + "disable_signup": false, + "autoconfirm": false +} +``` ### **POST /admin/generate_link** - Returns the corresponding email action link based on the type specified. - ```js - headers: - { - "Authorization": "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" // admin role required - } +Returns the corresponding email action link based on the type specified. - body: - { - "type": "signup" or "magiclink" or "recovery" or "invite", - "email": "email@example.com", - "password": "secret", // only if type = signup - "data": { - ... - }, // only if type = signup - "redirect_to": "https://supabase.io" // Redirect URL to send the user to after an email action. Defaults to SITE_URL. +```js +headers: +{ + "Authorization": "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" // admin role required +} - } - ``` - Returns - ```js - { - "action_link": "http://localhost:9999/verify?token=TOKEN&type=TYPE&redirect_to=REDIRECT_URL", +body: +{ + "type": "signup" or "magiclink" or "recovery" or "invite", + "email": "email@example.com", + "password": "secret", // only if type = signup + "data": { ... - } - ``` + }, // only if type = signup + "redirect_to": "https://supabase.io" // Redirect URL to send the user to after an email action. Defaults to SITE_URL. + +} +``` + +Returns + +```js +{ + "action_link": "http://localhost:9999/verify?token=TOKEN&type=TYPE&redirect_to=REDIRECT_URL", + ... +} +``` ### **POST /signup** - Register a new user with an email and password. +Register a new user with an email and password. - ```js - { - "email": "email@example.com", - "password": "secret" - } - ``` +```js +{ + "email": "email@example.com", + "password": "secret" +} +``` - Returns: +Returns: - ```json - { - "id": "11111111-2222-3333-4444-5555555555555", - "email": "email@example.com", - "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", - "created_at": "2016-05-15T19:53:12.368652374-07:00", - "updated_at": "2016-05-15T19:53:12.368652374-07:00" - } - ``` +```json +{ + "id": "11111111-2222-3333-4444-5555555555555", + "email": "email@example.com", + "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", + "created_at": "2016-05-15T19:53:12.368652374-07:00", + "updated_at": "2016-05-15T19:53:12.368652374-07:00" +} +``` - Register a new user with a phone number and password. +Register a new user with a phone number and password. - ```js - { - "phone": "12345678", // follows the E.164 format - "password": "secret" - } - ``` +```js +{ + "phone": "12345678", // follows the E.164 format + "password": "secret" +} +``` - Returns: +Returns: - ```json - { - "id": "11111111-2222-3333-4444-5555555555555", - "phone": "12345678", - "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", - "created_at": "2016-05-15T19:53:12.368652374-07:00", - "updated_at": "2016-05-15T19:53:12.368652374-07:00" - } - ``` +```json +{ + "id": "11111111-2222-3333-4444-5555555555555", + "phone": "12345678", + "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", + "created_at": "2016-05-15T19:53:12.368652374-07:00", + "updated_at": "2016-05-15T19:53:12.368652374-07:00" +} +``` ### **POST /invite** - Invites a new user with an email. - This endpoint requires the `service_role` or `supabase_admin` JWT set as an Auth Bearer header: +Invites a new user with an email. +This endpoint requires the `service_role` or `supabase_admin` JWT set as an Auth Bearer header: - e.g. - ```json - headers: { - "Authorization" : "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" - } - ``` +e.g. - ```json - { - "email": "email@example.com" - } - ``` - - Returns: - - ```json - { - "id": "11111111-2222-3333-4444-5555555555555", - "email": "email@example.com", - "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", - "created_at": "2016-05-15T19:53:12.368652374-07:00", - "updated_at": "2016-05-15T19:53:12.368652374-07:00", - "invited_at": "2016-05-15T19:53:12.368652374-07:00" - } - ``` +```json +headers: { + "Authorization" : "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" +} +``` + +```json +{ + "email": "email@example.com" +} +``` + +Returns: + +```json +{ + "id": "11111111-2222-3333-4444-5555555555555", + "email": "email@example.com", + "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", + "created_at": "2016-05-15T19:53:12.368652374-07:00", + "updated_at": "2016-05-15T19:53:12.368652374-07:00", + "invited_at": "2016-05-15T19:53:12.368652374-07:00" +} +``` ### **POST /verify** - Verify a registration or a password recovery. Type can be `signup` or `recovery` or `invite` - and the `token` is a token returned from either `/signup` or `/recover`. +Verify a registration or a password recovery. Type can be `signup` or `recovery` or `invite` +and the `token` is a token returned from either `/signup` or `/recover`. - ```json - { - "type": "signup", - "token": "confirmation-code-delivered-in-email" - } - ``` +```json +{ + "type": "signup", + "token": "confirmation-code-delivered-in-email" +} +``` - `password` is required for signup verification if no existing password exists. +`password` is required for signup verification if no existing password exists. - Returns: +Returns: - ```json - { - "access_token": "jwt-token-representing-the-user", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "a-refresh-token", - "type": "signup | recovery | invite" - } - ``` - - Verify a phone signup or sms otp. Type should be set to `sms`. - ```json - { - "type": "sms", - "token": "confirmation-otp-delivered-in-sms", - "redirect_to": "https://supabase.io", - "phone": "phone-number-sms-otp-was-delivered-to" - } - ``` +```json +{ + "access_token": "jwt-token-representing-the-user", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "a-refresh-token", + "type": "signup | recovery | invite" +} +``` - Returns: +Verify a phone signup or sms otp. Type should be set to `sms`. - ```json - { - "access_token": "jwt-token-representing-the-user", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "a-refresh-token" - } - ``` +```json +{ + "type": "sms", + "token": "confirmation-otp-delivered-in-sms", + "redirect_to": "https://supabase.io", + "phone": "phone-number-sms-otp-was-delivered-to" +} +``` + +Returns: + +```json +{ + "access_token": "jwt-token-representing-the-user", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "a-refresh-token" +} +``` ### **GET /verify** - Verify a registration or a password recovery. Type can be `signup` or `recovery` or `magiclink` or `invite` - and the `token` is a token returned from either `/signup` or `/recover` or `/magiclink`. +Verify a registration or a password recovery. Type can be `signup` or `recovery` or `magiclink` or `invite` +and the `token` is a token returned from either `/signup` or `/recover` or `/magiclink`. - query params: - ```json - { - "type": "signup", - "token": "confirmation-code-delivered-in-email", - "redirect_to": "https://supabase.io" - } - ``` +query params: - User will be logged in and redirected to: +```json +{ + "type": "signup", + "token": "confirmation-code-delivered-in-email", + "redirect_to": "https://supabase.io" +} +``` + +User will be logged in and redirected to: - ```json - SITE_URL/#access_token=jwt-token-representing-the-user&token_type=bearer&expires_in=3600&refresh_token=a-refresh-token&type=invite - ``` +```json +SITE_URL/#access_token=jwt-token-representing-the-user&token_type=bearer&expires_in=3600&refresh_token=a-refresh-token&type=invite +``` - Your app should detect the query params in the fragment and use them to set the session (supabase-js does this automatically) +Your app should detect the query params in the fragment and use them to set the session (supabase-js does this automatically) - You can use the `type` param to redirect the user to a password set form in the case of `invite` or `recovery`, - or show an account confirmed/welcome message in the case of `signup`, or direct them to some additional onboarding flow +You can use the `type` param to redirect the user to a password set form in the case of `invite` or `recovery`, +or show an account confirmed/welcome message in the case of `signup`, or direct them to some additional onboarding flow ### **POST /otp** - One-Time-Password. Will deliver a magiclink or sms otp to the user depending on whether the request body contains an "email" or "phone" key. - ```js - { - "phone": "12345678" // follows the E.164 format - } +One-Time-Password. Will deliver a magiclink or sms otp to the user depending on whether the request body contains an "email" or "phone" key. - OR +```js +{ + "phone": "12345678" // follows the E.164 format +} - // exactly the same as /magiclink - { - "email": "email@example.com" - } - ``` - Returns: - ``` - {} - ``` +OR + +// exactly the same as /magiclink +{ + "email": "email@example.com" +} +``` + +Returns: + +``` +{} +``` ### **POST /magiclink** (recommended to use /otp instead. See above.) - Magic Link. Will deliver a link (e.g. `/verify?type=magiclink&token=fgtyuf68ddqdaDd`) to the user based on - email address which they can use to redeem an access_token. +Magic Link. Will deliver a link (e.g. `/verify?type=magiclink&token=fgtyuf68ddqdaDd`) to the user based on +email address which they can use to redeem an access_token. - By default Magic Links can only be sent once every 60 seconds +By default Magic Links can only be sent once every 60 seconds - ```json - { - "email": "email@example.com" - } - ``` +```json +{ + "email": "email@example.com" +} +``` - Returns: +Returns: - ```json - {} - ``` +```json +{} +``` - when clicked the magic link will redirect the user to `#access_token=x&refresh_token=y&expires_in=z&token_type=bearer&type=magiclink` (see `/verify` above) +when clicked the magic link will redirect the user to `#access_token=x&refresh_token=y&expires_in=z&token_type=bearer&type=magiclink` (see `/verify` above) ### **POST /recover** - Password recovery. Will deliver a password recovery mail to the user based on - email address. +Password recovery. Will deliver a password recovery mail to the user based on +email address. - By default recovery links can only be sent once every 60 seconds +By default recovery links can only be sent once every 60 seconds - ```json - { - "email": "email@example.com" - } - ``` +```json +{ + "email": "email@example.com" +} +``` - Returns: +Returns: - ```json - {} - ``` +```json +{} +``` ### **POST /token** - This is an OAuth2 endpoint that currently implements - the password and refresh_token grant types +This is an OAuth2 endpoint that currently implements +the password and refresh_token grant types - query params: - ``` - ?grant_type=password - ``` +query params: - body: - ```json - // Email login - { - "email": "name@domain.com", - "password": "somepassword" - } - - // Phone login - { - "phone": "12345678", - "password": "somepassword" - } - ``` +``` +?grant_type=password +``` - or +body: - query params: - ``` - grant_type=refresh_token - ``` +```json +// Email login +{ + "email": "name@domain.com", + "password": "somepassword" +} - body: - ```json - { - "refresh_token": "a-refresh-token" - } - ``` +// Phone login +{ + "phone": "12345678", + "password": "somepassword" +} +``` - Once you have an access token, you can access the methods requiring authentication - by settings the `Authorization: Bearer YOUR_ACCESS_TOKEN_HERE` header. +or - Returns: +query params: - ```json - { - "access_token": "jwt-token-representing-the-user", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "a-refresh-token" - } - ``` +``` +grant_type=refresh_token +``` + +body: + +```json +{ + "refresh_token": "a-refresh-token" +} +``` + +Once you have an access token, you can access the methods requiring authentication +by settings the `Authorization: Bearer YOUR_ACCESS_TOKEN_HERE` header. + +Returns: + +```json +{ + "access_token": "jwt-token-representing-the-user", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "a-refresh-token" +} +``` ### **GET /user** - Get the JSON object for the logged in user (requires authentication) +Get the JSON object for the logged in user (requires authentication) - Returns: +Returns: - ```json - { - "id": "11111111-2222-3333-4444-5555555555555", - "email": "email@example.com", - "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", - "created_at": "2016-05-15T19:53:12.368652374-07:00", - "updated_at": "2016-05-15T19:53:12.368652374-07:00" - } - ``` +```json +{ + "id": "11111111-2222-3333-4444-5555555555555", + "email": "email@example.com", + "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00", + "created_at": "2016-05-15T19:53:12.368652374-07:00", + "updated_at": "2016-05-15T19:53:12.368652374-07:00" +} +``` ### **PUT /user** - Update a user (Requires authentication). Apart from changing email/password, this - method can be used to set custom user data. Changing the email will result in a magiclink being sent out. - - ```json - { - "email": "new-email@example.com", - "password": "new-password", - "data": { - "key": "value", - "number": 10, - "admin": false - } +Update a user (Requires authentication). Apart from changing email/password, this +method can be used to set custom user data. Changing the email will result in a magiclink being sent out. + +```json +{ + "email": "new-email@example.com", + "password": "new-password", + "data": { + "key": "value", + "number": 10, + "admin": false } - ``` +} +``` - Returns: +Returns: - ```json - { - "id": "11111111-2222-3333-4444-5555555555555", - "email": "email@example.com", - "email_change_sent_at": "2016-05-15T20:49:40.882805774-07:00", - "created_at": "2016-05-15T19:53:12.368652374-07:00", - "updated_at": "2016-05-15T19:53:12.368652374-07:00" - } - ``` +```json +{ + "id": "11111111-2222-3333-4444-5555555555555", + "email": "email@example.com", + "email_change_sent_at": "2016-05-15T20:49:40.882805774-07:00", + "created_at": "2016-05-15T19:53:12.368652374-07:00", + "updated_at": "2016-05-15T19:53:12.368652374-07:00" +} +``` ### **POST /logout** - Logout a user (Requires authentication). - - This will revoke all refresh tokens for the user. Remember that the JWT tokens - will still be valid for stateless auth until they expires. +Logout a user (Requires authentication). +This will revoke all refresh tokens for the user. Remember that the JWT tokens +will still be valid for stateless auth until they expires. ### **GET /authorize** - Get access_token from external oauth provider +Get access_token from external oauth provider + +query params: - query params: - ``` - provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | twitch | twitter - scopes= - ``` +``` +provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | slack | spotify | twitch | twitter +scopes= +``` - Redirects to provider and then to `/callback` +Redirects to provider and then to `/callback` - For apple specific setup see: https://github.com/supabase/gotrue#apple-oauth +For apple specific setup see: https://github.com/supabase/gotrue#apple-oauth ### **GET /callback** - External provider should redirect to here +External provider should redirect to here - Redirects to `#access_token=&refresh_token=&provider_token=&expires_in=3600&provider=` - If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services +Redirects to `#access_token=&refresh_token=&provider_token=&expires_in=3600&provider=` +If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services diff --git a/api/external.go b/api/external.go index 28cfbd3f22..4fcda7d076 100644 --- a/api/external.go +++ b/api/external.go @@ -377,6 +377,10 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewGoogleProvider(config.External.Google, scopes) case "facebook": return provider.NewFacebookProvider(config.External.Facebook, scopes) + case "spotify": + return provider.NewSpotifyProvider(config.External.Spotify, scopes) + case "slack": + return provider.NewSlackProvider(config.External.Slack, scopes) case "twitch": return provider.NewTwitchProvider(config.External.Twitch, scopes) case "twitter": diff --git a/api/provider/slack.go b/api/provider/slack.go new file mode 100644 index 0000000000..896bc8d09a --- /dev/null +++ b/api/provider/slack.go @@ -0,0 +1,95 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/netlify/gotrue/conf" + "golang.org/x/oauth2" +) + +const defaultSlackApiBase = "slack.com" + +type slackProvider struct { + *oauth2.Config + APIPath string +} + +type slackUser struct { + ID string `json:"https://slack.com/user_id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL string `json:"picture"` +} + +// NewSlackProvider creates a Slack account provider. +func NewSlackProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.Validate(); err != nil { + return nil, err + } + + apiPath := chooseHost(ext.URL, defaultSlackApiBase) + "/api" + authPath := chooseHost(ext.URL, defaultSlackApiBase) + "/oauth" + + oauthScopes := []string{ + "profile", + "email", + "openid", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &slackProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID, + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authPath + "/authorize", + TokenURL: apiPath + "/oauth.access", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + APIPath: apiPath, + }, nil +} + +func (g slackProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(oauth2.NoContext, code) +} + +func (g slackProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u slackUser + if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/openid.connect.userInfo", &u); err != nil { + return nil, err + } + fmt.Printf("%+v\n", u) + if u.Email == "" { + return nil, errors.New("Unable to find email with Slack provider") + } + + return &UserProvidedData{ + Metadata: &Claims{ + Issuer: g.APIPath, + Subject: u.ID, + Name: u.Name, + Picture: u.AvatarURL, + Email: u.Email, + EmailVerified: true, // Slack dosen't provide data on if email is verified. + + // To be deprecated + AvatarURL: u.AvatarURL, + FullName: u.Name, + ProviderId: u.ID, + }, + Emails: []Email{{ + Email: u.Email, + Verified: true, // Slack dosen't provide data on if email is verified. + Primary: true, + }}, + }, nil +} diff --git a/api/provider/spotify.go b/api/provider/spotify.go new file mode 100644 index 0000000000..0334b21c6c --- /dev/null +++ b/api/provider/spotify.go @@ -0,0 +1,107 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/netlify/gotrue/conf" + "golang.org/x/oauth2" +) + +const ( + defaultSpotifyAPIBase = "api.spotify.com/v1" // Used to get user data + defaultSpotifyAuthBase = "accounts.spotify.com" // Used for OAuth flow +) + +type spotifyProvider struct { + *oauth2.Config + APIPath string +} + +type spotifyUser struct { + DisplayName string `json:"display_name"` + Avatars []spotifyUserImage `json:"images"` + Email string `json:"email"` + ID string `json:"id"` +} + +type spotifyUserImage struct { + Url string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` +} + +// NewSpotifyProvider creates a Spotify account provider. +func NewSpotifyProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.Validate(); err != nil { + return nil, err + } + + apiPath := chooseHost(ext.URL, defaultSpotifyAPIBase) + authPath := chooseHost(ext.URL, defaultSpotifyAuthBase) + + oauthScopes := []string{ + "user-read-email", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &spotifyProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID, + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authPath + "/authorize", + TokenURL: authPath + "/api/token", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + APIPath: apiPath, + }, nil +} + +func (g spotifyProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(oauth2.NoContext, code) +} + +func (g spotifyProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u spotifyUser + if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/me", &u); err != nil { + return nil, err + } + + if u.Email == "" { + return nil, errors.New("Unable to find email with Spotify provider") + } + + var avatarURL string + + if len(u.Avatars) >= 1 { + avatarURL = u.Avatars[0].Url + } + + return &UserProvidedData{ + Metadata: &Claims{ + Issuer: g.APIPath, + Subject: u.ID, + Name: u.DisplayName, + Picture: avatarURL, + Email: u.Email, + EmailVerified: true, // Spotify dosen't provide data on if email is verified. + + // To be deprecated + AvatarURL: avatarURL, + FullName: u.DisplayName, + ProviderId: u.ID, + }, + Emails: []Email{{ + Email: u.Email, + Verified: true, // Spotify dosen't provide data on if email is verified. + Primary: true, + }}, + }, nil +} diff --git a/api/settings.go b/api/settings.go index a407e52d38..56b02a6382 100644 --- a/api/settings.go +++ b/api/settings.go @@ -11,6 +11,8 @@ type ProviderSettings struct { GitLab bool `json:"gitlab"` Google bool `json:"google"` Facebook bool `json:"facebook"` + Spotify bool `json:"spotify"` + Slack bool `json:"slack"` Twitch bool `json:"twitch"` Twitter bool `json:"twitter"` Email bool `json:"email"` @@ -44,6 +46,8 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { GitLab: config.External.Gitlab.Enabled, Google: config.External.Google.Enabled, Facebook: config.External.Facebook.Enabled, + Spotify: config.External.Spotify.Enabled, + Slack: config.External.Slack.Enabled, Twitch: config.External.Twitch.Enabled, Twitter: config.External.Twitter.Enabled, Email: config.External.Email.Enabled, diff --git a/api/settings_test.go b/api/settings_test.go index 61839b4cc3..f8698749fc 100644 --- a/api/settings_test.go +++ b/api/settings_test.go @@ -32,6 +32,8 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Bitbucket) require.True(t, p.Discord) require.True(t, p.Facebook) + require.True(t, p.Spotify) + require.True(t, p.Slack) require.True(t, p.Google) require.True(t, p.GitHub) require.True(t, p.GitLab) diff --git a/conf/configuration.go b/conf/configuration.go index ebe311dedc..c17fddb5fb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -88,6 +88,8 @@ type ProviderConfiguration struct { Github OAuthProviderConfiguration `json:"github"` Gitlab OAuthProviderConfiguration `json:"gitlab"` Google OAuthProviderConfiguration `json:"google"` + Spotify OAuthProviderConfiguration `json:"spotify"` + Slack OAuthProviderConfiguration `json:"slack"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` Email EmailProviderConfiguration `json:"email"` diff --git a/example.env b/example.env index 73b973a9e8..df2578ce0f 100644 --- a/example.env +++ b/example.env @@ -41,6 +41,12 @@ GOTRUE_EXTERNAL_AZURE_SECRET="" GOTRUE_EXTERNAL_FACEBOOK_ENABLED="false" GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID="" GOTRUE_EXTERNAL_FACEBOOK_SECRET="" +GOTRUE_EXTERNAL_SPOTIFY_ENABLED="true" +GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID="" +GOTRUE_EXTERNAL_SPOTIFY_SECRET="" +GOTRUE_EXTERNAL_SLACK_ENABLED="true" +GOTRUE_EXTERNAL_SLACK_CLIENT_ID="" +GOTRUE_EXTERNAL_SLACK_SECRET="" GOTRUE_EXTERNAL_TWITTER_ENABLED="false" GOTRUE_EXTERNAL_TWITTER_CLIENT_ID="" GOTRUE_EXTERNAL_TWITTER_SECRET="" @@ -76,6 +82,8 @@ GOTRUE_EXTERNAL_AZURE_REDIRECT_URI="http://example.com/callback" GOTRUE_EXTERNAL_FACEBOOK_REDIRECT_URI="http://example.com/callback" GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI="http://example.com/callback" GOTRUE_EXTERNAL_APPLE_REDIRECT_URI="http://example.com/callback" +GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI="http://example.com/callback" +GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="http://example.com/callback" GOTRUE_EXTERNAL_TWITCH_ENABLED=true GOTRUE_EXTERNAL_TWITCH_CLIENT_ID="6ww3qbjo9luvlj22yxk0u0negp381s" GOTRUE_EXTERNAL_TWITCH_SECRET="ubu4pfs2ghk39cn0bsxmzyv7cykgu0" diff --git a/hack/test.env b/hack/test.env index 9e4f07420b..6b5b6c25f1 100644 --- a/hack/test.env +++ b/hack/test.env @@ -45,6 +45,14 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED=true GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_SPOTIFY_ENABLED=true +GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_SPOTIFY_SECRET=testsecret +GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_SLACK_ENABLED=true +GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_SLACK_SECRET=testsecret +GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_TWITCH_ENABLED=true GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=testclientid GOTRUE_EXTERNAL_TWITCH_SECRET=testsecret