Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3d0febd

Browse files
kylecarbsammario
andauthored
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 <[email protected]> * Update docs/install/auth.md Co-authored-by: Ammar Bandukwala <[email protected]> * Remove username package Co-authored-by: Ammar Bandukwala <[email protected]>
1 parent 8b17bf9 commit 3d0febd

28 files changed

+733
-137
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"mattn",
4343
"mitchellh",
4444
"moby",
45+
"namesgenerator",
4546
"nfpms",
4647
"nhooyr",
4748
"nolint",

cli/server.go

+53
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
@@ -283,6 +290,38 @@ func server() *cobra.Command {
283290
}
284291
}
285292

293+
if oidcClientSecret != "" {
294+
if oidcClientID == "" {
295+
return xerrors.Errorf("OIDC client ID be set!")
296+
}
297+
if oidcIssuerURL == "" {
298+
return xerrors.Errorf("OIDC issuer URL must be set!")
299+
}
300+
301+
oidcProvider, err := oidc.NewProvider(ctx, oidcIssuerURL)
302+
if err != nil {
303+
return xerrors.Errorf("configure oidc provider: %w", err)
304+
}
305+
redirectURL, err := accessURLParsed.Parse("/api/v2/users/oidc/callback")
306+
if err != nil {
307+
return xerrors.Errorf("parse oidc oauth callback url: %w", err)
308+
}
309+
options.OIDCConfig = &coderd.OIDCConfig{
310+
OAuth2Config: &oauth2.Config{
311+
ClientID: oidcClientID,
312+
ClientSecret: oidcClientSecret,
313+
RedirectURL: redirectURL.String(),
314+
Endpoint: oidcProvider.Endpoint(),
315+
Scopes: oidcScopes,
316+
},
317+
Verifier: oidcProvider.Verifier(&oidc.Config{
318+
ClientID: oidcClientID,
319+
}),
320+
EmailDomain: oidcEmailDomain,
321+
AllowSignups: oidcAllowSignups,
322+
}
323+
}
324+
286325
if inMemoryDatabase {
287326
options.Database = databasefake.New()
288327
options.Pubsub = database.NewPubsubInMemory()
@@ -341,6 +380,8 @@ func server() *cobra.Command {
341380
Logger: logger.Named("telemetry"),
342381
URL: telemetryURL,
343382
GitHubOAuth: oauth2GithubClientID != "",
383+
OIDCAuth: oidcClientID != "",
384+
OIDCIssuerURL: oidcIssuerURL,
344385
Prometheus: promEnabled,
345386
STUN: len(stunServers) != 0,
346387
Tunnel: tunnel,
@@ -637,6 +678,18 @@ func server() *cobra.Command {
637678
"Specifies teams inside organizations the user must be a member of to authenticate with GitHub. Formatted as: <organization-name>/<team-slug>.")
638679
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
639680
"Specifies whether new users can sign up with GitHub.")
681+
cliflag.BoolVarP(root.Flags(), &oidcAllowSignups, "oidc-allow-signups", "", "CODER_OIDC_ALLOW_SIGNUPS", true,
682+
"Specifies whether new users can sign up with OIDC.")
683+
cliflag.StringVarP(root.Flags(), &oidcClientID, "oidc-client-id", "", "CODER_OIDC_CLIENT_ID", "",
684+
"Specifies a client ID to use for OIDC.")
685+
cliflag.StringVarP(root.Flags(), &oidcClientSecret, "oidc-client-secret", "", "CODER_OIDC_CLIENT_SECRET", "",
686+
"Specifies a client secret to use for OIDC.")
687+
cliflag.StringVarP(root.Flags(), &oidcEmailDomain, "oidc-email-domain", "", "CODER_OIDC_EMAIL_DOMAIN", "",
688+
"Specifies an email domain that clients authenticating with OIDC must match.")
689+
cliflag.StringVarP(root.Flags(), &oidcIssuerURL, "oidc-issuer-url", "", "CODER_OIDC_ISSUER_URL", "",
690+
"Specifies an issuer URL to use for OIDC.")
691+
cliflag.StringArrayVarP(root.Flags(), &oidcScopes, "oidc-scopes", "", "CODER_OIDC_SCOPES", []string{oidc.ScopeOpenID, "profile", "email"},
692+
"Specifies scopes to grant when authenticating with OIDC.")
640693
enableTelemetryByDefault := !isTest()
641694
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.")
642695
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/coderd_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
248248

249249
// Has it's own auth
250250
"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},
251+
"GET:/api/v2/users/oidc/callback": {NoAuthorize: true},
251252

252253
// All workspaceagents endpoints do not use rbac
253254
"POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true},

coderd/coderdtest/coderdtest.go

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type Options struct {
6363
Authorizer rbac.Authorizer
6464
AzureCertificates x509.VerifyOptions
6565
GithubOAuth2Config *coderd.GithubOAuth2Config
66+
OIDCConfig *coderd.OIDCConfig
6667
GoogleTokenValidator *idtoken.Validator
6768
SSHKeygenAlgorithm gitsshkey.Algorithm
6869
APIRateLimit int
@@ -189,6 +190,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer)
189190
AWSCertificates: options.AWSCertificates,
190191
AzureCertificates: options.AzureCertificates,
191192
GithubOAuth2Config: options.GithubOAuth2Config,
193+
OIDCConfig: options.OIDCConfig,
192194
GoogleTokenValidator: options.GoogleTokenValidator,
193195
SSHKeygenAlgorithm: options.SSHKeygenAlgorithm,
194196
TURNServer: turnServer,

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/httpapi/httpapi.go

+2-10
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"fmt"
88
"net/http"
99
"reflect"
10-
"regexp"
1110
"strings"
1211

1312
"github.com/go-playground/validator/v10"
@@ -16,8 +15,7 @@ import (
1615
)
1716

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

2321
// This init is used to create a validator and register validation-specific
@@ -39,13 +37,7 @@ func init() {
3937
if !ok {
4038
return false
4139
}
42-
if len(str) > 32 {
43-
return false
44-
}
45-
if len(str) < 1 {
46-
return false
47-
}
48-
return usernameRegex.MatchString(str)
40+
return UsernameValid(str)
4941
})
5042
if err != nil {
5143
panic(err)

coderd/httpapi/httpapi_test.go

-65
Original file line numberDiff line numberDiff line change
@@ -81,71 +81,6 @@ func TestRead(t *testing.T) {
8181
})
8282
}
8383

84-
func TestReadUsername(t *testing.T) {
85-
t.Parallel()
86-
// Tests whether usernames are valid or not.
87-
testCases := []struct {
88-
Username string
89-
Valid bool
90-
}{
91-
{"1", true},
92-
{"12", true},
93-
{"123", true},
94-
{"12345678901234567890", true},
95-
{"123456789012345678901", true},
96-
{"a", true},
97-
{"a1", true},
98-
{"a1b2", true},
99-
{"a1b2c3d4e5f6g7h8i9j0", true},
100-
{"a1b2c3d4e5f6g7h8i9j0k", true},
101-
{"aa", true},
102-
{"abc", true},
103-
{"abcdefghijklmnopqrst", true},
104-
{"abcdefghijklmnopqrstu", true},
105-
{"wow-test", true},
106-
107-
{"", false},
108-
{" ", false},
109-
{" a", false},
110-
{" a ", false},
111-
{" 1", false},
112-
{"1 ", false},
113-
{" aa", false},
114-
{"aa ", false},
115-
{" 12", false},
116-
{"12 ", false},
117-
{" a1", false},
118-
{"a1 ", false},
119-
{" abcdefghijklmnopqrstu", false},
120-
{"abcdefghijklmnopqrstu ", false},
121-
{" 123456789012345678901", false},
122-
{" a1b2c3d4e5f6g7h8i9j0k", false},
123-
{"a1b2c3d4e5f6g7h8i9j0k ", false},
124-
{"bananas_wow", false},
125-
{"test--now", false},
126-
127-
{"123456789012345678901234567890123", false},
128-
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
129-
{"123456789012345678901234567890123123456789012345678901234567890123", false},
130-
}
131-
type toValidate struct {
132-
Username string `json:"username" validate:"username"`
133-
}
134-
for _, testCase := range testCases {
135-
testCase := testCase
136-
t.Run(testCase.Username, func(t *testing.T) {
137-
t.Parallel()
138-
rw := httptest.NewRecorder()
139-
data, err := json.Marshal(toValidate{testCase.Username})
140-
require.NoError(t, err)
141-
r := httptest.NewRequest("POST", "/", bytes.NewBuffer(data))
142-
143-
var validate toValidate
144-
require.Equal(t, testCase.Valid, httpapi.Read(rw, r, &validate))
145-
})
146-
}
147-
}
148-
14984
func WebsocketCloseMsg(t *testing.T) {
15085
t.Parallel()
15186

coderd/httpapi/username.go

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package httpapi
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/moby/moby/pkg/namesgenerator"
8+
)
9+
10+
var (
11+
usernameValid = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$")
12+
usernameReplace = regexp.MustCompile("[^a-zA-Z0-9-]*")
13+
)
14+
15+
// UsernameValid returns whether the input string is a valid username.
16+
func UsernameValid(str string) bool {
17+
if len(str) > 32 {
18+
return false
19+
}
20+
if len(str) < 1 {
21+
return false
22+
}
23+
return usernameValid.MatchString(str)
24+
}
25+
26+
// UsernameFrom returns a best-effort username from the provided string.
27+
//
28+
// It first attempts to validate the incoming string, which will
29+
// be returned if it is valid. It then will attempt to extract
30+
// the username from an email address. If no success happens during
31+
// these steps, a random username will be returned.
32+
func UsernameFrom(str string) string {
33+
if UsernameValid(str) {
34+
return str
35+
}
36+
emailAt := strings.LastIndex(str, "@")
37+
if emailAt >= 0 {
38+
str = str[:emailAt]
39+
}
40+
str = usernameReplace.ReplaceAllString(str, "")
41+
if UsernameValid(str) {
42+
return str
43+
}
44+
return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-")
45+
}

0 commit comments

Comments
 (0)