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
Next Next commit
feat: Add OIDC authentication
  • Loading branch information
kylecarbs committed Jul 29, 2022
commit 50ee7ade3289d0063d8ad423cae4a59565526d5e
45 changes: 45 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"sync"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-systemd/daemon"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/go-github/v43/github"
Expand Down Expand Up @@ -84,6 +85,12 @@ func server() *cobra.Command {
oauth2GithubAllowedOrganizations []string
oauth2GithubAllowedTeams []string
oauth2GithubAllowSignups bool
oidcAllowSignups bool
oidcClientID string
oidcClientSecret string
oidcEmailDomain string
oidcIssuerURL string
oidcScopes []string
telemetryEnable bool
telemetryURL string
tlsCertFile string
Expand Down Expand Up @@ -282,6 +289,32 @@ func server() *cobra.Command {
}
}

if oidcClientSecret != "" {
oidcProvider, err := oidc.NewProvider(ctx, oidcIssuerURL)
if err != nil {
return xerrors.Errorf("configure oidc provider: %w", err)
}
redirectURL, err := accessURLParsed.Parse("/api/v2/users/oidc/callback")
if err != nil {
return xerrors.Errorf("parse oidc oauth callback url: %w", err)
}
options.OIDCConfig = &coderd.OIDCConfig{
OAuth2Config: &oauth2.Config{
ClientID: oidcClientID,
ClientSecret: oidcClientSecret,
RedirectURL: redirectURL.String(),
Endpoint: oidcProvider.Endpoint(),
Scopes: oidcScopes,
},
Provider: oidcProvider,
Verifier: oidcProvider.Verifier(&oidc.Config{
ClientID: oidcClientID,
}),
EmailDomain: oidcEmailDomain,
AllowSignups: oidcAllowSignups,
}
}

if inMemoryDatabase {
options.Database = databasefake.New()
options.Pubsub = database.NewPubsubInMemory()
Expand Down Expand Up @@ -636,6 +669,18 @@ func server() *cobra.Command {
"Specifies teams inside organizations the user must be a member of to authenticate with GitHub. Formatted as: <organization-name>/<team-slug>.")
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
"Specifies whether new users can sign up with GitHub.")
cliflag.BoolVarP(root.Flags(), &oidcAllowSignups, "oidc-allow-signups", "", "CODER_OIDC_ALLOW_SIGNUPS", true,
"Specifies whether new users can sign up with OIDC.")
cliflag.StringVarP(root.Flags(), &oidcClientID, "oidc-client-id", "", "CODER_OIDC_CLIENT_ID", "",
"Specifies a client ID to use for OIDC.")
cliflag.StringVarP(root.Flags(), &oidcClientSecret, "oidc-client-secret", "", "CODER_OIDC_CLIENT_SECRET", "",
"Specifies a client secret to use for OIDC.")
cliflag.StringVarP(root.Flags(), &oidcEmailDomain, "oidc-email-domain", "", "CODER_OIDC_EMAIL_DOMAIN", "",
"Specifies an email domain that clients authenticating with OIDC must match.")
cliflag.StringVarP(root.Flags(), &oidcIssuerURL, "oidc-issuer-url", "", "CODER_OIDC_ISSUER_URL", "",
"Specifies an issuer URL to use for OIDC.")
cliflag.StringArrayVarP(root.Flags(), &oidcScopes, "oidc-scopes", "", "CODER_OIDC_SCOPES", []string{oidc.ScopeOpenID, "profile", "email"},
"Specifies scopes to grant when authenticating with OIDC.")
enableTelemetryByDefault := !isTest()
cliflag.BoolVarP(root.Flags(), &telemetryEnable, "telemetry", "", "CODER_TELEMETRY", enableTelemetryByDefault, "Specifies whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.")
cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.")
Expand Down
6 changes: 6 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type Options struct {
AzureCertificates x509.VerifyOptions
GoogleTokenValidator *idtoken.Validator
GithubOAuth2Config *GithubOAuth2Config
OIDCConfig *OIDCConfig
ICEServers []webrtc.ICEServer
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
Expand Down Expand Up @@ -105,6 +106,7 @@ func New(options *Options) *API {
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)
oauthConfigs := &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
OIDC: options.OIDCConfig,
}
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false)

Expand Down Expand Up @@ -259,6 +261,10 @@ func New(options *Options) *API {
r.Get("/callback", api.userOAuth2Github)
})
})
r.Route("/oidc/callback", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig))
r.Get("/", api.userOIDC)
})
r.Group(func(r chi.Router) {
r.Use(
apiKeyMiddleware,
Expand Down
3 changes: 2 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/dump/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ func main() {
}

cmd := exec.Command(
"docker",
"run",
"--rm",
"--network=host",
"postgres:13",
Comment on lines +34 to +38
Copy link
Member Author

Choose a reason for hiding this comment

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

This was a bit out of scope, but makes everything use Docker, which makes changing our versions simpler!

"pg_dump",
"--schema-only",
connection,
Expand Down
7 changes: 7 additions & 0 deletions coderd/database/migrations/000032_oidc.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TYPE old_login_type AS ENUM (
'password',
'github'
);
ALTER TABLE api_keys ALTER COLUMN login_type TYPE old_login_type USING (login_type::text::old_login_type);
DROP TYPE login_type;
ALTER TYPE old_login_type RENAME TO login_type;
8 changes: 8 additions & 0 deletions coderd/database/migrations/000032_oidc.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TYPE new_login_type AS ENUM (
'password',
'github',
'oidc'
);
ALTER TABLE api_keys ALTER COLUMN login_type TYPE new_login_type USING (login_type::text::new_login_type);
DROP TYPE login_type;
ALTER TYPE new_login_type RENAME TO login_type;
1 change: 1 addition & 0 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions coderd/httpmw/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func AuthorizationUserRoles(r *http.Request) database.GetAuthorizationUserRolesR
// This should be extended to support other authentication types in the future.
type OAuth2Configs struct {
Github OAuth2Config
OIDC OAuth2Config
}

const (
Expand Down Expand Up @@ -155,6 +156,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool
switch key.LoginType {
case database.LoginTypeGithub:
oauthConfig = oauth.Github
case database.LoginTypeOIDC:
oauthConfig = oauth.OIDC
default:
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Expand Down
133 changes: 133 additions & 0 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -40,10 +42,18 @@ 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,
})
}

func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
if api.GithubOAuth2Config == nil {
httpapi.Write(rw, http.StatusPreconditionRequired, codersdk.Response{
Message: "GitHub authentication is not enabled!",
})
return
}

state := httpmw.OAuth2(r)

oauthClient := oauth2.NewClient(r.Context(), oauth2.StaticTokenSource(state.Token))
Expand Down Expand Up @@ -205,3 +215,126 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
}
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}

type OIDCConfig struct {
httpmw.OAuth2Config

Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
// EmailDomain is an optional domain to require when authenticating.
EmailDomain string
AllowSignups bool
}

func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
if api.OIDCConfig == nil {
httpapi.Write(rw, http.StatusPreconditionRequired, codersdk.Response{
Message: "OpenID Connect authentication is not enabled!",
})
return
}

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.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
}
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{
Copy link
Collaborator

@sreya sreya Jul 31, 2022

Choose a reason for hiding this comment

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

This is pretty unlikely to happen but if an existing, unrelated user (say with built-in auth) already has this email address, then it's possible for a new OIDC user to impersonate them by providing the same email address.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup. The issue I attached expands on that a bit. We'll have to make the user-identification with account links better.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want to add a column to the users table that indicates the sort of authentication they used? Probably not necessary to do in this PR

Copy link
Member Author

Choose a reason for hiding this comment

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

We will in a future PR, this one just gets it out the door

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(),
})
}

_, 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)
}
1 change: 1 addition & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type CreateOrganizationRequest struct {
type AuthMethods struct {
Password bool `json:"password"`
Github bool `json:"github"`
OIDC bool `json:"oidc"`
}

// HasFirstUser returns whether the first user has been created.
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ require (
github.com/charmbracelet/lipgloss v0.5.0
github.com/cli/safeexec v1.0.0
github.com/coder/retry v1.3.0
github.com/coreos/go-oidc/v3 v3.2.0
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/creack/pty v1.1.18
github.com/elastic/go-sysinfo v1.8.1
Expand Down Expand Up @@ -135,7 +136,10 @@ require (
tailscale.com v1.26.2
)

require github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
require (
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
)

require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,8 @@ github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka
github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk=
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-oidc/v3 v3.2.0 h1:2eR2MGR7thBXSQ2YbODlF0fcmgtliLCfr9iX6RW11fc=
github.com/coreos/go-oidc/v3 v3.2.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
Expand Down Expand Up @@ -2115,6 +2117,7 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
Expand Down Expand Up @@ -2778,6 +2781,8 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
Expand Down
1 change: 1 addition & 0 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface AgentGitSSHKey {
export interface AuthMethods {
readonly password: boolean
readonly github: boolean
readonly oidc: boolean
}

// From codersdk/workspaceagents.go
Expand Down