feat: Add OIDC authentication (#3314)

* feat: Add OIDC authentication

* Extract username into a separate package and add OIDC tests

* Add test case for invalid tokens

* Add test case for username as email

* Add OIDC to the frontend

* Improve comments from self-review

* Add authentication docs

* Add telemetry

* Update docs/install/auth.md

Co-authored-by: Ammar Bandukwala <ammar@ammar.io>

* Update docs/install/auth.md

Co-authored-by: Ammar Bandukwala <ammar@ammar.io>

* Remove username package

Co-authored-by: Ammar Bandukwala <ammar@ammar.io>
This commit is contained in:
Kyle Carberry
2022-07-31 23:05:35 -05:00
committed by GitHub
parent 8b17bf98ea
commit 3d0febdd90
28 changed files with 733 additions and 137 deletions

View File

@ -6,7 +6,9 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"golang.org/x/oauth2"
@ -40,6 +42,7 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, _ *http.Request) {
httpapi.Write(rw, http.StatusOK, codersdk.AuthMethods{
Password: true,
Github: api.GithubOAuth2Config != nil,
OIDC: api.OIDCConfig != nil,
})
}
@ -205,3 +208,137 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
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)
}