2022-04-23 22:58:57 +00:00
|
|
|
package coderd
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"database/sql"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
2022-08-01 04:05:35 +00:00
|
|
|
"strings"
|
2022-04-23 22:58:57 +00:00
|
|
|
|
2022-08-01 04:05:35 +00:00
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
2022-04-23 22:58:57 +00:00
|
|
|
"github.com/google/go-github/v43/github"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
|
|
|
|
"github.com/coder/coder/coderd/database"
|
|
|
|
"github.com/coder/coder/coderd/httpapi"
|
|
|
|
"github.com/coder/coder/coderd/httpmw"
|
|
|
|
"github.com/coder/coder/codersdk"
|
|
|
|
)
|
|
|
|
|
2022-07-09 02:37:18 +00:00
|
|
|
// GithubOAuth2Team represents a team scoped to an organization.
|
|
|
|
type GithubOAuth2Team struct {
|
|
|
|
Organization string
|
|
|
|
Slug string
|
|
|
|
}
|
|
|
|
|
2022-04-23 22:58:57 +00:00
|
|
|
// GithubOAuth2Provider exposes required functions for the Github authentication flow.
|
|
|
|
type GithubOAuth2Config struct {
|
|
|
|
httpmw.OAuth2Config
|
|
|
|
AuthenticatedUser func(ctx context.Context, client *http.Client) (*github.User, error)
|
|
|
|
ListEmails func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error)
|
|
|
|
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
|
2022-07-22 18:54:08 +00:00
|
|
|
TeamMembership func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error)
|
2022-04-23 22:58:57 +00:00
|
|
|
|
|
|
|
AllowSignups bool
|
|
|
|
AllowOrganizations []string
|
2022-07-09 02:37:18 +00:00
|
|
|
AllowTeams []GithubOAuth2Team
|
2022-04-23 22:58:57 +00:00
|
|
|
}
|
|
|
|
|
2022-05-26 03:14:08 +00:00
|
|
|
func (api *API) userAuthMethods(rw http.ResponseWriter, _ *http.Request) {
|
2022-04-23 22:58:57 +00:00
|
|
|
httpapi.Write(rw, http.StatusOK, codersdk.AuthMethods{
|
|
|
|
Password: true,
|
|
|
|
Github: api.GithubOAuth2Config != nil,
|
2022-08-01 04:05:35 +00:00
|
|
|
OIDC: api.OIDCConfig != nil,
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-05-26 03:14:08 +00:00
|
|
|
func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
|
2022-04-23 22:58:57 +00:00
|
|
|
state := httpmw.OAuth2(r)
|
|
|
|
|
|
|
|
oauthClient := oauth2.NewClient(r.Context(), oauth2.StaticTokenSource(state.Token))
|
|
|
|
memberships, err := api.GithubOAuth2Config.ListOrganizationMemberships(r.Context(), oauthClient)
|
|
|
|
if err != nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error fetching authenticated Github user organizations.",
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
var selectedMembership *github.Membership
|
|
|
|
for _, membership := range memberships {
|
|
|
|
for _, allowed := range api.GithubOAuth2Config.AllowOrganizations {
|
|
|
|
if *membership.Organization.Login != allowed {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
selectedMembership = membership
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if selectedMembership == nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusUnauthorized, codersdk.Response{
|
2022-05-10 21:04:23 +00:00
|
|
|
Message: "You aren't a member of the authorized Github organizations!",
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-07-22 18:54:08 +00:00
|
|
|
ghUser, err := api.GithubOAuth2Config.AuthenticatedUser(r.Context(), oauthClient)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error fetching authenticated Github user.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-07-09 02:37:18 +00:00
|
|
|
// The default if no teams are specified is to allow all.
|
|
|
|
if len(api.GithubOAuth2Config.AllowTeams) > 0 {
|
2022-07-22 18:54:08 +00:00
|
|
|
var allowedTeam *github.Membership
|
2022-07-13 00:45:43 +00:00
|
|
|
for _, allowTeam := range api.GithubOAuth2Config.AllowTeams {
|
|
|
|
if allowTeam.Organization != *selectedMembership.Organization.Login {
|
|
|
|
// This needs to continue because multiple organizations
|
|
|
|
// could exist in the allow/team listings.
|
|
|
|
continue
|
2022-07-09 02:37:18 +00:00
|
|
|
}
|
|
|
|
|
2022-07-22 18:54:08 +00:00
|
|
|
allowedTeam, err = api.GithubOAuth2Config.TeamMembership(r.Context(), oauthClient, allowTeam.Organization, allowTeam.Slug, *ghUser.Login)
|
2022-07-13 00:45:43 +00:00
|
|
|
// The calling user may not have permission to the requested team!
|
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
2022-07-09 02:37:18 +00:00
|
|
|
if allowedTeam == nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusUnauthorized, codersdk.Response{
|
2022-07-09 02:37:18 +00:00
|
|
|
Message: fmt.Sprintf("You aren't a member of an authorized team in the %s Github organization!", *selectedMembership.Organization.Login),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-04-23 22:58:57 +00:00
|
|
|
emails, err := api.GithubOAuth2Config.ListEmails(r.Context(), oauthClient)
|
|
|
|
if err != nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error fetching personal Github user.",
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var user database.User
|
|
|
|
// Search for existing users with matching and verified emails.
|
|
|
|
// If a verified GitHub email matches a Coder user, we will return.
|
|
|
|
for _, email := range emails {
|
2022-04-29 20:13:35 +00:00
|
|
|
if !email.GetVerified() {
|
2022-04-23 22:58:57 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
user, err = api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
|
|
|
|
Email: *email.Email,
|
|
|
|
})
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if err != nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: fmt.Sprintf("Internal error fetching user by email %q.", *email.Email),
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if !*email.Verified {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
|
2022-04-23 22:58:57 +00:00
|
|
|
Message: fmt.Sprintf("Verify the %q email address on Github to authenticate!", *email.Email),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the user doesn't exist, create a new one!
|
|
|
|
if user.ID == uuid.Nil {
|
|
|
|
if !api.GithubOAuth2Config.AllowSignups {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
|
2022-04-23 22:58:57 +00:00
|
|
|
Message: "Signups are disabled for Github authentication!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var organizationID uuid.UUID
|
|
|
|
organizations, _ := api.Database.GetOrganizations(r.Context())
|
|
|
|
if len(organizations) > 0 {
|
|
|
|
// Add the user to the first organization. Once multi-organization
|
|
|
|
// support is added, we should enable a configuration map of user
|
|
|
|
// email to organization.
|
|
|
|
organizationID = organizations[0].ID
|
|
|
|
}
|
2022-04-29 20:13:35 +00:00
|
|
|
var verifiedEmail *github.UserEmail
|
|
|
|
for _, email := range emails {
|
|
|
|
if !email.GetPrimary() || !email.GetVerified() {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
verifiedEmail = email
|
|
|
|
break
|
|
|
|
}
|
|
|
|
if verifiedEmail == nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusPreconditionRequired, codersdk.Response{
|
2022-04-29 20:13:35 +00:00
|
|
|
Message: "Your primary email must be verified on GitHub!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2022-04-23 22:58:57 +00:00
|
|
|
user, _, err = api.createUser(r.Context(), codersdk.CreateUserRequest{
|
2022-04-29 20:13:35 +00:00
|
|
|
Email: *verifiedEmail.Email,
|
2022-04-23 22:58:57 +00:00
|
|
|
Username: *ghUser.Login,
|
|
|
|
OrganizationID: organizationID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2022-07-13 00:15:02 +00:00
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
2022-06-07 14:33:06 +00:00
|
|
|
Message: "Internal error creating user.",
|
2022-06-03 21:48:09 +00:00
|
|
|
Detail: err.Error(),
|
2022-04-23 22:58:57 +00:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{
|
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: database.LoginTypeGithub,
|
|
|
|
OAuthAccessToken: state.Token.AccessToken,
|
|
|
|
OAuthRefreshToken: state.Token.RefreshToken,
|
|
|
|
OAuthExpiry: state.Token.Expiry,
|
|
|
|
})
|
|
|
|
if !created {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
redirect := state.Redirect
|
|
|
|
if redirect == "" {
|
|
|
|
redirect = "/"
|
|
|
|
}
|
|
|
|
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
|
|
|
}
|
2022-08-01 04:05:35 +00:00
|
|
|
|
|
|
|
type OIDCConfig struct {
|
|
|
|
httpmw.OAuth2Config
|
|
|
|
|
|
|
|
Verifier *oidc.IDTokenVerifier
|
|
|
|
// EmailDomain is the domain to enforce when a user authenticates.
|
|
|
|
EmailDomain string
|
|
|
|
AllowSignups bool
|
|
|
|
}
|
|
|
|
|
|
|
|
func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|
|
|
state := httpmw.OAuth2(r)
|
|
|
|
|
|
|
|
// See the example here: https://github.com/coreos/go-oidc
|
|
|
|
rawIDToken, ok := state.Token.Extra("id_token").(string)
|
|
|
|
if !ok {
|
|
|
|
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "id_token not found in response payload. Ensure your OIDC callback is configured correctly!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
idToken, err := api.OIDCConfig.Verifier.Verify(r.Context(), rawIDToken)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "Failed to verify OIDC token.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var claims struct {
|
|
|
|
Email string `json:"email"`
|
|
|
|
Verified bool `json:"email_verified"`
|
|
|
|
Username string `json:"preferred_username"`
|
|
|
|
}
|
|
|
|
err = idToken.Claims(&claims)
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to extract OIDC claims.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if claims.Email == "" {
|
|
|
|
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
|
|
|
|
Message: "No email found in OIDC payload!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if !claims.Verified {
|
|
|
|
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
|
|
|
|
Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", claims.Email),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// The username is a required property in Coder. We make a best-effort
|
|
|
|
// attempt at using what the claims provide, but if that fails we will
|
|
|
|
// generate a random username.
|
|
|
|
if !httpapi.UsernameValid(claims.Username) {
|
|
|
|
// If no username is provided, we can default to use the email address.
|
|
|
|
// This will be converted in the from function below, so it's safe
|
|
|
|
// to keep the domain.
|
|
|
|
if claims.Username == "" {
|
|
|
|
claims.Username = claims.Email
|
|
|
|
}
|
|
|
|
claims.Username = httpapi.UsernameFrom(claims.Username)
|
|
|
|
}
|
|
|
|
if api.OIDCConfig.EmailDomain != "" {
|
|
|
|
if !strings.HasSuffix(claims.Email, api.OIDCConfig.EmailDomain) {
|
|
|
|
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
|
|
|
|
Message: fmt.Sprintf("Your email %q is not a part of the %q domain!", claims.Email, api.OIDCConfig.EmailDomain),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var user database.User
|
|
|
|
user, err = api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
|
|
|
|
Email: claims.Email,
|
|
|
|
})
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
if !api.OIDCConfig.AllowSignups {
|
|
|
|
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
|
|
|
|
Message: "Signups are disabled for OIDC authentication!",
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
var organizationID uuid.UUID
|
|
|
|
organizations, _ := api.Database.GetOrganizations(r.Context())
|
|
|
|
if len(organizations) > 0 {
|
|
|
|
// Add the user to the first organization. Once multi-organization
|
|
|
|
// support is added, we should enable a configuration map of user
|
|
|
|
// email to organization.
|
|
|
|
organizationID = organizations[0].ID
|
|
|
|
}
|
|
|
|
user, _, err = api.createUser(r.Context(), codersdk.CreateUserRequest{
|
|
|
|
Email: claims.Email,
|
|
|
|
Username: claims.Username,
|
|
|
|
OrganizationID: organizationID,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Internal error creating user.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
|
|
|
Message: "Failed to get user by email.",
|
|
|
|
Detail: err.Error(),
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
_, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{
|
|
|
|
UserID: user.ID,
|
|
|
|
LoginType: database.LoginTypeOIDC,
|
|
|
|
OAuthAccessToken: state.Token.AccessToken,
|
|
|
|
OAuthRefreshToken: state.Token.RefreshToken,
|
|
|
|
OAuthExpiry: state.Token.Expiry,
|
|
|
|
})
|
|
|
|
if !created {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
redirect := state.Redirect
|
|
|
|
if redirect == "" {
|
|
|
|
redirect = "/"
|
|
|
|
}
|
|
|
|
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
|
|
|
}
|