- Notifications
You must be signed in to change notification settings - Fork928
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes from1 commit
50ee7ad
7eb897a
424579e
a49b491
6eae627
292d9f6
e6619ff
3a20472
5f7176c
c2a4481
4b71655
7b487be
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
- Loading branch information
Uh oh!
There was an error while loading.Please reload this page.
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
@@ -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 | ||
@@ -282,6 +289,32 @@ func server() *cobra.Command { | ||
} | ||
} | ||
if oidcClientSecret != "" { | ||
kylecarbs marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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() | ||
@@ -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.") | ||
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -31,6 +31,11 @@ func main() { | ||
} | ||
cmd := exec.Command( | ||
"docker", | ||
"run", | ||
"--rm", | ||
"--network=host", | ||
"postgres:13", | ||
Comment on lines +34 to +38 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
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; |
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; |
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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,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)) | ||
@@ -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{ | ||
Collaborator There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} |