Skip to content

feat: Add OIDC authentication #3314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 1, 2022
Prev Previous commit
Next Next commit
Extract username into a separate package and add OIDC tests
  • Loading branch information
kylecarbs committed Jul 31, 2022
commit 7eb897ab0f3b4716a61f7dbafa27536a6deef6a0
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"mattn",
"mitchellh",
"moby",
"namesgenerator",
"nfpms",
"nhooyr",
"nolint",
Expand Down
1 change: 0 additions & 1 deletion cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,6 @@ func server() *cobra.Command {
Endpoint: oidcProvider.Endpoint(),
Scopes: oidcScopes,
},
Provider: oidcProvider,
Verifier: oidcProvider.Verifier(&oidc.Config{
ClientID: oidcClientID,
}),
Expand Down
2 changes: 2 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type Options struct {
Authorizer rbac.Authorizer
AzureCertificates x509.VerifyOptions
GithubOAuth2Config *coderd.GithubOAuth2Config
OIDCConfig *coderd.OIDCConfig
GoogleTokenValidator *idtoken.Validator
SSHKeygenAlgorithm gitsshkey.Algorithm
APIRateLimit int
Expand Down Expand Up @@ -189,6 +190,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer)
AWSCertificates: options.AWSCertificates,
AzureCertificates: options.AzureCertificates,
GithubOAuth2Config: options.GithubOAuth2Config,
OIDCConfig: options.OIDCConfig,
GoogleTokenValidator: options.GoogleTokenValidator,
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
TURNServer: turnServer,
Expand Down
13 changes: 3 additions & 10 deletions coderd/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@ import (
"fmt"
"net/http"
"reflect"
"regexp"
"strings"

"github.com/go-playground/validator/v10"

"github.com/coder/coder/coderd/username"
"github.com/coder/coder/codersdk"
)

var (
validate *validator.Validate
usernameRegex = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$")
validate *validator.Validate
)

// This init is used to create a validator and register validation-specific
Expand All @@ -39,13 +38,7 @@ func init() {
if !ok {
return false
}
if len(str) > 32 {
return false
}
if len(str) < 1 {
return false
}
return usernameRegex.MatchString(str)
return username.Valid(str)
})
if err != nil {
panic(err)
Expand Down
65 changes: 0 additions & 65 deletions coderd/httpapi/httpapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,71 +81,6 @@ func TestRead(t *testing.T) {
})
}

func TestReadUsername(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These have been moved to the username package!

t.Parallel()
// Tests whether usernames are valid or not.
testCases := []struct {
Username string
Valid bool
}{
{"1", true},
{"12", true},
{"123", true},
{"12345678901234567890", true},
{"123456789012345678901", true},
{"a", true},
{"a1", true},
{"a1b2", true},
{"a1b2c3d4e5f6g7h8i9j0", true},
{"a1b2c3d4e5f6g7h8i9j0k", true},
{"aa", true},
{"abc", true},
{"abcdefghijklmnopqrst", true},
{"abcdefghijklmnopqrstu", true},
{"wow-test", true},

{"", false},
{" ", false},
{" a", false},
{" a ", false},
{" 1", false},
{"1 ", false},
{" aa", false},
{"aa ", false},
{" 12", false},
{"12 ", false},
{" a1", false},
{"a1 ", false},
{" abcdefghijklmnopqrstu", false},
{"abcdefghijklmnopqrstu ", false},
{" 123456789012345678901", false},
{" a1b2c3d4e5f6g7h8i9j0k", false},
{"a1b2c3d4e5f6g7h8i9j0k ", false},
{"bananas_wow", false},
{"test--now", false},

{"123456789012345678901234567890123", false},
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
{"123456789012345678901234567890123123456789012345678901234567890123", false},
}
type toValidate struct {
Username string `json:"username" validate:"username"`
}
for _, testCase := range testCases {
testCase := testCase
t.Run(testCase.Username, func(t *testing.T) {
t.Parallel()
rw := httptest.NewRecorder()
data, err := json.Marshal(toValidate{testCase.Username})
require.NoError(t, err)
r := httptest.NewRequest("POST", "/", bytes.NewBuffer(data))

var validate toValidate
require.Equal(t, testCase.Valid, httpapi.Read(rw, r, &validate))
})
}
}

func WebsocketCloseMsg(t *testing.T) {
t.Parallel()

Expand Down
21 changes: 20 additions & 1 deletion coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/username"
"github.com/coder/coder/codersdk"
)

Expand Down Expand Up @@ -219,7 +220,6 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
type OIDCConfig struct {
httpmw.OAuth2Config

Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
// EmailDomain is an optional domain to require when authenticating.
EmailDomain string
Expand Down Expand Up @@ -267,12 +267,30 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
})
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 !username.Valid(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 = username.From(claims.Username)
}
if api.OIDCConfig.EmailDomain != "" {
if !strings.HasSuffix(claims.Email, api.OIDCConfig.EmailDomain) {
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
Expand Down Expand Up @@ -319,6 +337,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
Message: "Failed to get user by email.",
Detail: err.Error(),
})
return
}

_, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{
Expand Down
151 changes: 149 additions & 2 deletions coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ package coderd_test

import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"io"
"net/http"
"net/url"
"testing"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt"
"github.com/google/go-github/v43/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
Expand All @@ -16,13 +24,18 @@ import (
"github.com/coder/coder/codersdk"
)

type oauth2Config struct{}
type oauth2Config struct {
token *oauth2.Token
}

func (*oauth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string {
return "/?state=" + url.QueryEscape(state)
}

func (*oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
if o.token != nil {
return o.token, nil
}
return &oauth2.Token{
AccessToken: "token",
}, nil
Expand Down Expand Up @@ -249,6 +262,117 @@ func TestUserOAuth2Github(t *testing.T) {
})
}

func TestUserOIDC(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
Name string
Claims jwt.MapClaims
AllowSignups bool
EmailDomain string
Username string
StatusCode int
}{{
Name: "EmailNotVerified",
Claims: jwt.MapClaims{
"email": "kyle@kwc.io",
},
AllowSignups: true,
StatusCode: http.StatusForbidden,
}, {
Name: "NotInRequiredEmailDomain",
Claims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
},
AllowSignups: true,
EmailDomain: "coder.com",
StatusCode: http.StatusForbidden,
}, {
Name: "EmptyClaims",
Claims: jwt.MapClaims{},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
}, {
Name: "NoSignups",
Claims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
},
StatusCode: http.StatusForbidden,
}, {
Name: "UsernameFromEmail",
Claims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
},
Username: "kyle",
AllowSignups: true,
StatusCode: http.StatusTemporaryRedirect,
}, {
Name: "UsernameFromClaims",
Claims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"preferred_username": "hotdog",
},
Username: "hotdog",
AllowSignups: true,
StatusCode: http.StatusTemporaryRedirect,
}} {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
config := createOIDCConfig(t, tc.Claims)
config.AllowSignups = tc.AllowSignups
config.EmailDomain = tc.EmailDomain
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: config,
})
resp := oidcCallback(t, client)
assert.Equal(t, tc.StatusCode, resp.StatusCode)

if tc.Username != "" {
client.SessionToken = resp.Cookies()[0].Value
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, tc.Username, user.Username)
}
})
}
}

// createOIDCConfig generates a new OIDCConfig that returns a static token
// with the claims provided.
func createOIDCConfig(t *testing.T, claims jwt.MapClaims) *coderd.OIDCConfig {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)

// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
claims["exp"] = time.Now().Add(time.Hour).UnixMilli()

signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key)
require.NoError(t, err)

verifier := oidc.NewVerifier("", &oidc.StaticKeySet{
PublicKeys: []crypto.PublicKey{key.Public()},
}, &oidc.Config{
SkipClientIDCheck: true,
})

return &coderd.OIDCConfig{
OAuth2Config: &oauth2Config{
token: (&oauth2.Token{
AccessToken: "token",
}).WithExtra(map[string]interface{}{
"id_token": signed,
}),
},
Verifier: verifier,
}
}

func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response {
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
Expand All @@ -269,3 +393,26 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response {
})
return res
}

func oidcCallback(t *testing.T, client *codersdk.Client) *http.Response {
t.Helper()
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
state := "somestate"
oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback?code=asd&state=" + state)
require.NoError(t, err)
req, err := http.NewRequest("GET", oauthURL.String(), nil)
require.NoError(t, err)
req.AddCookie(&http.Cookie{
Name: "oauth_state",
Value: state,
})
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
require.NoError(t, err)
t.Log(string(data))
return res
}
Loading