Skip to content

Commit 50ee7ad

Browse files
committed
feat: Add OIDC authentication
1 parent 1f2ead8 commit 50ee7ad

File tree

13 files changed

+222
-2
lines changed

13 files changed

+222
-2
lines changed

cli/server.go

+45
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"sync"
2424
"time"
2525

26+
"github.com/coreos/go-oidc/v3/oidc"
2627
"github.com/coreos/go-systemd/daemon"
2728
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
2829
"github.com/google/go-github/v43/github"
@@ -84,6 +85,12 @@ func server() *cobra.Command {
8485
oauth2GithubAllowedOrganizations []string
8586
oauth2GithubAllowedTeams []string
8687
oauth2GithubAllowSignups bool
88+
oidcAllowSignups bool
89+
oidcClientID string
90+
oidcClientSecret string
91+
oidcEmailDomain string
92+
oidcIssuerURL string
93+
oidcScopes []string
8794
telemetryEnable bool
8895
telemetryURL string
8996
tlsCertFile string
@@ -282,6 +289,32 @@ func server() *cobra.Command {
282289
}
283290
}
284291

292+
if oidcClientSecret != "" {
293+
oidcProvider, err := oidc.NewProvider(ctx, oidcIssuerURL)
294+
if err != nil {
295+
return xerrors.Errorf("configure oidc provider: %w", err)
296+
}
297+
redirectURL, err := accessURLParsed.Parse("/api/v2/users/oidc/callback")
298+
if err != nil {
299+
return xerrors.Errorf("parse oidc oauth callback url: %w", err)
300+
}
301+
options.OIDCConfig = &coderd.OIDCConfig{
302+
OAuth2Config: &oauth2.Config{
303+
ClientID: oidcClientID,
304+
ClientSecret: oidcClientSecret,
305+
RedirectURL: redirectURL.String(),
306+
Endpoint: oidcProvider.Endpoint(),
307+
Scopes: oidcScopes,
308+
},
309+
Provider: oidcProvider,
310+
Verifier: oidcProvider.Verifier(&oidc.Config{
311+
ClientID: oidcClientID,
312+
}),
313+
EmailDomain: oidcEmailDomain,
314+
AllowSignups: oidcAllowSignups,
315+
}
316+
}
317+
285318
if inMemoryDatabase {
286319
options.Database = databasefake.New()
287320
options.Pubsub = database.NewPubsubInMemory()
@@ -636,6 +669,18 @@ func server() *cobra.Command {
636669
"Specifies teams inside organizations the user must be a member of to authenticate with GitHub. Formatted as: <organization-name>/<team-slug>.")
637670
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
638671
"Specifies whether new users can sign up with GitHub.")
672+
cliflag.BoolVarP(root.Flags(), &oidcAllowSignups, "oidc-allow-signups", "", "CODER_OIDC_ALLOW_SIGNUPS", true,
673+
"Specifies whether new users can sign up with OIDC.")
674+
cliflag.StringVarP(root.Flags(), &oidcClientID, "oidc-client-id", "", "CODER_OIDC_CLIENT_ID", "",
675+
"Specifies a client ID to use for OIDC.")
676+
cliflag.StringVarP(root.Flags(), &oidcClientSecret, "oidc-client-secret", "", "CODER_OIDC_CLIENT_SECRET", "",
677+
"Specifies a client secret to use for OIDC.")
678+
cliflag.StringVarP(root.Flags(), &oidcEmailDomain, "oidc-email-domain", "", "CODER_OIDC_EMAIL_DOMAIN", "",
679+
"Specifies an email domain that clients authenticating with OIDC must match.")
680+
cliflag.StringVarP(root.Flags(), &oidcIssuerURL, "oidc-issuer-url", "", "CODER_OIDC_ISSUER_URL", "",
681+
"Specifies an issuer URL to use for OIDC.")
682+
cliflag.StringArrayVarP(root.Flags(), &oidcScopes, "oidc-scopes", "", "CODER_OIDC_SCOPES", []string{oidc.ScopeOpenID, "profile", "email"},
683+
"Specifies scopes to grant when authenticating with OIDC.")
639684
enableTelemetryByDefault := !isTest()
640685
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.")
641686
cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.")

coderd/coderd.go

+6
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type Options struct {
5757
AzureCertificates x509.VerifyOptions
5858
GoogleTokenValidator *idtoken.Validator
5959
GithubOAuth2Config *GithubOAuth2Config
60+
OIDCConfig *OIDCConfig
6061
ICEServers []webrtc.ICEServer
6162
SecureAuthCookie bool
6263
SSHKeygenAlgorithm gitsshkey.Algorithm
@@ -105,6 +106,7 @@ func New(options *Options) *API {
105106
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)
106107
oauthConfigs := &httpmw.OAuth2Configs{
107108
Github: options.GithubOAuth2Config,
109+
OIDC: options.OIDCConfig,
108110
}
109111
apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false)
110112

@@ -259,6 +261,10 @@ func New(options *Options) *API {
259261
r.Get("/callback", api.userOAuth2Github)
260262
})
261263
})
264+
r.Route("/oidc/callback", func(r chi.Router) {
265+
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig))
266+
r.Get("/", api.userOIDC)
267+
})
262268
r.Group(func(r chi.Router) {
263269
r.Use(
264270
apiKeyMiddleware,

coderd/database/dump.sql

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dump/main.go

+5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ func main() {
3131
}
3232

3333
cmd := exec.Command(
34+
"docker",
35+
"run",
36+
"--rm",
37+
"--network=host",
38+
"postgres:13",
3439
"pg_dump",
3540
"--schema-only",
3641
connection,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TYPE old_login_type AS ENUM (
2+
'password',
3+
'github'
4+
);
5+
ALTER TABLE api_keys ALTER COLUMN login_type TYPE old_login_type USING (login_type::text::old_login_type);
6+
DROP TYPE login_type;
7+
ALTER TYPE old_login_type RENAME TO login_type;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TYPE new_login_type AS ENUM (
2+
'password',
3+
'github',
4+
'oidc'
5+
);
6+
ALTER TABLE api_keys ALTER COLUMN login_type TYPE new_login_type USING (login_type::text::new_login_type);
7+
DROP TYPE login_type;
8+
ALTER TYPE new_login_type RENAME TO login_type;

coderd/database/models.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/httpmw/apikey.go

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func AuthorizationUserRoles(r *http.Request) database.GetAuthorizationUserRolesR
4949
// This should be extended to support other authentication types in the future.
5050
type OAuth2Configs struct {
5151
Github OAuth2Config
52+
OIDC OAuth2Config
5253
}
5354

5455
const (
@@ -155,6 +156,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool
155156
switch key.LoginType {
156157
case database.LoginTypeGithub:
157158
oauthConfig = oauth.Github
159+
case database.LoginTypeOIDC:
160+
oauthConfig = oauth.OIDC
158161
default:
159162
write(http.StatusInternalServerError, codersdk.Response{
160163
Message: internalErrorMessage,

coderd/userauth.go

+133
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"errors"
77
"fmt"
88
"net/http"
9+
"strings"
910

11+
"github.com/coreos/go-oidc/v3/oidc"
1012
"github.com/google/go-github/v43/github"
1113
"github.com/google/uuid"
1214
"golang.org/x/oauth2"
@@ -40,10 +42,18 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, _ *http.Request) {
4042
httpapi.Write(rw, http.StatusOK, codersdk.AuthMethods{
4143
Password: true,
4244
Github: api.GithubOAuth2Config != nil,
45+
OIDC: api.OIDCConfig != nil,
4346
})
4447
}
4548

4649
func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
50+
if api.GithubOAuth2Config == nil {
51+
httpapi.Write(rw, http.StatusPreconditionRequired, codersdk.Response{
52+
Message: "GitHub authentication is not enabled!",
53+
})
54+
return
55+
}
56+
4757
state := httpmw.OAuth2(r)
4858

4959
oauthClient := oauth2.NewClient(r.Context(), oauth2.StaticTokenSource(state.Token))
@@ -205,3 +215,126 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
205215
}
206216
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
207217
}
218+
219+
type OIDCConfig struct {
220+
httpmw.OAuth2Config
221+
222+
Provider *oidc.Provider
223+
Verifier *oidc.IDTokenVerifier
224+
// EmailDomain is an optional domain to require when authenticating.
225+
EmailDomain string
226+
AllowSignups bool
227+
}
228+
229+
func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
230+
if api.OIDCConfig == nil {
231+
httpapi.Write(rw, http.StatusPreconditionRequired, codersdk.Response{
232+
Message: "OpenID Connect authentication is not enabled!",
233+
})
234+
return
235+
}
236+
237+
state := httpmw.OAuth2(r)
238+
239+
// See the example here: https://github.com/coreos/go-oidc
240+
rawIDToken, ok := state.Token.Extra("id_token").(string)
241+
if !ok {
242+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
243+
Message: "id_token not found in response payload. Ensure your OIDC callback is configured correctly!",
244+
})
245+
return
246+
}
247+
248+
idToken, err := api.OIDCConfig.Verifier.Verify(r.Context(), rawIDToken)
249+
if err != nil {
250+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
251+
Message: "Failed to verify OIDC token.",
252+
Detail: err.Error(),
253+
})
254+
return
255+
}
256+
257+
var claims struct {
258+
Email string `json:"email"`
259+
Verified bool `json:"email_verified"`
260+
Username string `json:"preferred_username"`
261+
}
262+
err = idToken.Claims(&claims)
263+
if err != nil {
264+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
265+
Message: "Failed to extract OIDC claims.",
266+
Detail: err.Error(),
267+
})
268+
return
269+
}
270+
if !claims.Verified {
271+
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
272+
Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", claims.Email),
273+
})
274+
return
275+
}
276+
if api.OIDCConfig.EmailDomain != "" {
277+
if !strings.HasSuffix(claims.Email, api.OIDCConfig.EmailDomain) {
278+
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
279+
Message: fmt.Sprintf("Your email %q is not a part of the %q domain!", claims.Email, api.OIDCConfig.EmailDomain),
280+
})
281+
return
282+
}
283+
}
284+
285+
var user database.User
286+
user, err = api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
287+
Email: claims.Email,
288+
})
289+
if errors.Is(err, sql.ErrNoRows) {
290+
if !api.OIDCConfig.AllowSignups {
291+
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
292+
Message: "Signups are disabled for OIDC authentication!",
293+
})
294+
return
295+
}
296+
var organizationID uuid.UUID
297+
organizations, _ := api.Database.GetOrganizations(r.Context())
298+
if len(organizations) > 0 {
299+
// Add the user to the first organization. Once multi-organization
300+
// support is added, we should enable a configuration map of user
301+
// email to organization.
302+
organizationID = organizations[0].ID
303+
}
304+
user, _, err = api.createUser(r.Context(), codersdk.CreateUserRequest{
305+
Email: claims.Email,
306+
Username: claims.Username,
307+
OrganizationID: organizationID,
308+
})
309+
if err != nil {
310+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
311+
Message: "Internal error creating user.",
312+
Detail: err.Error(),
313+
})
314+
return
315+
}
316+
}
317+
if err != nil {
318+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
319+
Message: "Failed to get user by email.",
320+
Detail: err.Error(),
321+
})
322+
}
323+
324+
_, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{
325+
UserID: user.ID,
326+
LoginType: database.LoginTypeOIDC,
327+
OAuthAccessToken: state.Token.AccessToken,
328+
OAuthRefreshToken: state.Token.RefreshToken,
329+
OAuthExpiry: state.Token.Expiry,
330+
})
331+
if !created {
332+
return
333+
}
334+
335+
redirect := state.Redirect
336+
if redirect == "" {
337+
redirect = "/"
338+
}
339+
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
340+
}

codersdk/users.go

+1
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ type CreateOrganizationRequest struct {
174174
type AuthMethods struct {
175175
Password bool `json:"password"`
176176
Github bool `json:"github"`
177+
OIDC bool `json:"oidc"`
177178
}
178179

179180
// HasFirstUser returns whether the first user has been created.

go.mod

+5-1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ require (
5252
github.com/charmbracelet/lipgloss v0.5.0
5353
github.com/cli/safeexec v1.0.0
5454
github.com/coder/retry v1.3.0
55+
github.com/coreos/go-oidc/v3 v3.2.0
5556
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
5657
github.com/creack/pty v1.1.18
5758
github.com/elastic/go-sysinfo v1.8.1
@@ -135,7 +136,10 @@ require (
135136
tailscale.com v1.26.2
136137
)
137138

138-
require github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
139+
require (
140+
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
141+
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
142+
)
139143

140144
require (
141145
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect

go.sum

+5
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,8 @@ github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka
464464
github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk=
465465
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
466466
github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
467+
github.com/coreos/go-oidc/v3 v3.2.0 h1:2eR2MGR7thBXSQ2YbODlF0fcmgtliLCfr9iX6RW11fc=
468+
github.com/coreos/go-oidc/v3 v3.2.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
467469
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
468470
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
469471
github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@@ -2115,6 +2117,7 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL
21152117
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
21162118
golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
21172119
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
2120+
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
21182121
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
21192122
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
21202123
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@@ -2778,6 +2781,8 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
27782781
gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
27792782
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
27802783
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
2784+
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
2785+
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
27812786
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
27822787
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
27832788
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=

site/src/api/typesGenerated.ts

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface AgentGitSSHKey {
2828
export interface AuthMethods {
2929
readonly password: boolean
3030
readonly github: boolean
31+
readonly oidc: boolean
3132
}
3233

3334
// From codersdk/workspaceagents.go

0 commit comments

Comments
 (0)