Files
coder/coderd/userauth_test.go
T
Bobby Ho 166d92ba73 fix: bound request body size on JSON API endpoints (#28168)
## Summary

`httpapi.Read` decoded request bodies with no size limit, so a single
request could allocate memory without bound. This adds a 4 MiB default
ceiling, leaves the endpoints that legitimately need more explicitly
exempted, and counts the rejections so a limit set too tight is visible.

This is the first of three PRs split out of #28048, covering the
endpoints that answer in `codersdk.Response` shape. The OAuth2 decode
paths (RFC 6749, RFC 7591) and the SCIM ones (RFC 7644) answer in their
own error shapes and follow in separate PRs, along with the lint rule
that pins the invariant.

Closes PLAT-463. Remediates SEC-416 (CWE-770, CVSS 7.5) and SEC-392.

## Problem

`httpapi.Read` calls `json.NewDecoder(r.Body).Decode(value)` with no
ceiling, and no middleware in the chain bounds body size. The exposure
is pre-authentication: login, OTP, and first-user creation all read a
body before any authorization decision is reached. The existing rate
limiter bounds request *rate*, which is orthogonal to the memory a
single admitted request may consume.

## Fix

`Read` is split into `Read` and `ReadLimit`. `ReadLimit` wraps `r.Body`
in an `http.MaxBytesReader` and keeps the existing decode and validate
logic; `Read` delegates to it with a new `DefaultMaxRequestBodyBytes` of
4 MiB, which covers the 124 remaining non-test callers at a single site.

`http.MaxBytesReader` composes as tightest-wins, so the handlers that
pre-wrapped their own bodies pass their limit to `ReadLimit` rather than
wrapping, and each keeps its previous ceiling byte for byte. That
matters most for the bulk secrets import at `8 * MaxSecretsFileBytes`:
an unconditional wrap inside `Read` would have silently halved it to the
default. `TestImportUserSecretsBodyLargerThanDefaultLimit` is the
regression guard for that specific failure, and
`TestMaxBytesReaderNesting` pins the composition behavior the whole
requirement rests on.

Every rejection site calls `httpapi.RecordRequestBodyLimit`, which names
the limit that tripped on the request's existing log line and marks the
request so `coderd_api_requests_too_large_total{reason="request_body"}`
counts body rejections apart from the 413s coderd answers for other
causes, such as agent log storage overflow. A limit set too tight for a
legitimate payload therefore surfaces without waiting for a user report.

The limit is a constant rather than a deployment option: an operator
raising it to unblock something would reopen the vulnerability as
configuration, where a security scan will not find it. A legitimate 413
is answered with a targeted `ReadLimit` on that endpoint.

## Behavior change

`POST /api/v2/files` now answers 413 rather than 400 when a request body
exceeds `HTTPFileMaxBytes`. It installed that bound already but reported
the rejection as a read failure, which leaked the stdlib `http: request
body too large` string through `Detail` and kept the largest limit in
the tree off the metric. The separate 413 for an oversized expanded
archive is unchanged.

The task log snapshot endpoint now answers 413 rather than 400 when its
64 KiB cap is exceeded. Routing it through `ReadLimit` also changes its
decode-failure message from "Failed to decode request payload." to
"Request body must be valid JSON.", which is what every other endpoint
answers. Its tests are updated to match both.

`coderd_api_requests_too_large_total` is new, so there is no existing
query to migrate. It counts the 413s coderd answers, labeled `method`,
`path`, and `reason`. `reason="request_body"` is a rejection by one of
the limits above; `reason="other"` is a 413 that has nothing to do with
body size, such as agent log storage overflow.

## Reading this

The commits are ordered to be read in sequence. Commits 1 and 2 are the
security fix; commits 3 to 5 are the observability consequences, and
commit 3 is the one that touches dashboards. Commit 7 documents the
limit on the REST API reference index. Commits 6 and 8 add and revert an
exhaustive `@Failure 413` annotation pass, which buried the fix under
its regenerated swagger, and cancel out.
2026-08-18 12:54:45 -07:00

3222 lines
106 KiB
Go

package coderd_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/coderdtest/testjar"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/testutil"
)
// This test specifically tests logging in with OIDC when an expired
// OIDC session token exists.
// The token refreshing should not happen since we are reauthenticating.
// nolint:bodyclose
func TestOIDCOauthLoginWithExisting(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.SecondaryClaims = coderd.MergedClaimsSourceNone
})
certificates := []tls.Certificate{testutil.GenerateTLSCertificate(t, "localhost")}
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
OIDCConfig: cfg,
TLSCertificates: certificates,
DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) {
values.HTTPCookies = codersdk.HTTPCookieConfig{
Secure: true,
SameSite: "none",
}
}),
})
const username = "alice"
claims := jwt.MapClaims{
"email": "alice@coder.com",
"email_verified": true,
"preferred_username": username,
"sub": uuid.NewString(),
}
// Signup alice
freshClient := func() *codersdk.Client {
cli := codersdk.New(client.URL)
cli.HTTPClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
//nolint:gosec
InsecureSkipVerify: true,
},
}
cli.HTTPClient.Jar = testjar.New()
return cli
}
unauthenticated := freshClient()
userClient, _ := fake.Login(t, unauthenticated, claims)
cookies := unauthenticated.HTTPClient.Jar.Cookies(client.URL)
require.True(t, len(cookies) > 0)
for _, c := range cookies {
require.Truef(t, c.Secure, "cookie %q", c.Name)
require.Equalf(t, http.SameSiteNoneMode, c.SameSite, "cookie %q", c.Name)
}
// Expire the link. This will force the client to refresh the token.
helper := oidctest.NewLoginHelper(userClient, fake)
helper.ExpireOauthToken(t, api.Database, userClient)
// Instead of refreshing, just log in again.
unauthenticated = freshClient()
fake.Login(t, unauthenticated, claims)
}
func TestUserLogin(t *testing.T) {
t.Parallel()
// Single instance shared across all sub-tests. Each sub-test
// creates its own separate user for isolation.
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
t.Run("OK", func(t *testing.T) {
t.Parallel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: anotherUser.Email,
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
})
// Login is reachable without credentials, so it is the endpoint where an
// unbounded body costs the most. The payload here carries valid credentials
// and is padded with a field the request type ignores, so a server that read
// the whole body would answer 201 with a session token. A 413 and no cookie
// is therefore evidence the body was cut off before authentication ran.
t.Run("BodyTooLarge", func(t *testing.T) {
t.Parallel()
_, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
body := fmt.Sprintf(`{"email":%q,"password":%q,"padding":%q}`,
anotherUser.Email, "SomeSecurePassword!",
strings.Repeat("a", httpapi.DefaultMaxRequestBodyBytes))
require.Greater(t, len(body), httpapi.DefaultMaxRequestBodyBytes)
unauthenticated := codersdk.New(client.URL)
res, err := unauthenticated.Request(ctx, http.MethodPost, "/api/v2/users/login", strings.NewReader(body))
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusRequestEntityTooLarge, res.StatusCode)
for _, cookie := range res.Cookies() {
require.NotEqual(t, codersdk.SessionTokenCookie, cookie.Name, "no session may be issued")
}
var apiResp codersdk.Response
require.NoError(t, json.NewDecoder(res.Body).Decode(&apiResp))
require.Equal(t, "Request body too large.", apiResp.Message)
require.Contains(t, apiResp.Detail, strconv.Itoa(httpapi.DefaultMaxRequestBodyBytes))
})
t.Run("UserDeleted", func(t *testing.T) {
t.Parallel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
client.DeleteUser(context.Background(), anotherUser.ID)
_, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: anotherUser.Email,
Password: "SomeSecurePassword!",
})
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
})
t.Run("LoginTypeNone", func(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)
first := coderdtest.CreateFirstUser(t, client)
noneUser := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeNone,
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: first.OrganizationID,
UserID: noneUser.ID,
})
_, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: noneUser.Email,
Password: "SomeSecurePassword!",
})
require.Error(t, err)
})
}
func TestUserAuthMethods(t *testing.T) {
t.Parallel()
t.Run("Password", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
methods, err := client.AuthMethods(ctx)
require.NoError(t, err)
require.True(t, methods.Password.Enabled)
require.False(t, methods.Github.Enabled)
})
t.Run("Github", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
methods, err := client.AuthMethods(ctx)
require.NoError(t, err)
require.True(t, methods.Password.Enabled)
require.True(t, methods.Github.Enabled)
})
}
// nolint:bodyclose
func TestUserOAuth2Github(t *testing.T) {
t.Parallel()
stateActive := "active"
statePending := "pending"
t.Run("NotInAllowedOrganization", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("kyle"),
},
}}, nil
},
},
})
resp := oauth2Callback(t, client)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("NotInAllowedTeam", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowOrganizations: []string{"coder"},
AllowTeams: []coderd.GithubOAuth2Team{{"another", "something"}, {"coder", "frontend"}},
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("kyle"),
Name: github.String("Kylium Carbonate"),
}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return nil, xerrors.New("no perms")
},
},
})
resp := oauth2Callback(t, client)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("UnverifiedEmail", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("testuser@coder.com"),
Verified: github.Bool(false),
}}, nil
},
},
})
_ = coderdtest.CreateFirstUser(t, client)
resp := oauth2Callback(t, client)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("BlockSignups", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
id := atomic.NewInt64(100)
login := atomic.NewString("testuser")
email := atomic.NewString("testuser@coder.com")
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
id := id.Load()
login := login.Load()
return &github.User{
ID: &id,
Login: &login,
Name: github.String("The Right Honorable Sir Test McUser"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
email := email.Load()
return []*github.UserEmail{{
Email: &email,
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
// The first user in a deployment with signups disabled will be allowed to sign up,
// but all the other users will not.
resp := oauth2Callback(t, client)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
ctx := testutil.Context(t, testutil.WaitLong)
count, err := db.GetUserCount(dbauthz.AsSystemRestricted(ctx), false)
require.NoError(t, err)
require.Equal(t, int64(1), count)
id.Store(101)
email.Store("someotheruser@coder.com")
login.Store("someotheruser")
resp = oauth2Callback(t, client)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("MultiLoginNotAllowed", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("testuser"),
Name: github.String("The Right Honorable Sir Test McUser"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("testuser@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
// Creates the first user with login_type 'password'.
_ = coderdtest.CreateFirstUser(t, client)
// Attempting to login should give us a 403 since the user
// already has a login_type of 'password'.
resp := oauth2Callback(t, client)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("EmailFallbackBlockedByExistingLink", func(t *testing.T) {
t.Parallel()
// A victim already has a GitHub link bound to a specific GitHub user
// ID. An attacker authenticates with a different GitHub user ID but
// the victim's verified email. The email fallback must not hand the
// attacker the victim's account, even with signups enabled.
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowSignups: true,
AllowEveryone: true,
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{}, nil
},
TeamMembership: func(_ context.Context, _ *http.Client, _, _, _ string) (*github.Membership, error) {
return nil, xerrors.New("no teams")
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
// Attacker's GitHub ID differs from the victim's link.
return &github.User{
ID: github.Int64(200),
Login: github.String("attacker"),
Name: github.String("Attacker"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("victim@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
// Seed the victim with an existing GitHub link (a different linked_id).
victim := dbgen.User(t, db, database.User{
Email: "victim@coder.com",
LoginType: database.LoginTypeGithub,
})
const victimLinkedID = "100"
dbgen.UserLink(t, db, database.UserLink{
UserID: victim.ID,
LoginType: database.LoginTypeGithub,
LinkedID: victimLinkedID,
})
resp := oauth2Callback(t, owner)
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"attacker with a different GitHub ID must not authenticate as the victim")
// The victim's link must be untouched.
victimLink, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: victim.ID,
LoginType: database.LoginTypeGithub,
})
require.NoError(t, err)
require.Equal(t, victimLinkedID, victimLink.LinkedID,
"victim's linked_id must remain unchanged")
})
t.Run("Signup", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
AllowSignups: true,
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(ctx context.Context, _ *http.Client) (*github.User, error) {
return &github.User{
AvatarURL: github.String("/hello-world"),
ID: ptr.Ref[int64](1234),
Login: github.String("kyle"),
Name: github.String("Kylium Carbonate"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("kyle@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
// Validate that attempting to redirect away from the
// site does not work.
maliciousHost := "https://malicious.com"
expectedPath := "/my/path"
resp := oauth2Callback(t, client, func(req *http.Request) {
// Add the cookie to bypass the parsing in httpmw/oauth2.go
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2RedirectCookie,
Value: maliciousHost + expectedPath,
})
})
numLogs++ // add an audit log for login
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
redirect, err := resp.Location()
require.NoError(t, err)
require.Equal(t, expectedPath, redirect.Path)
require.Equal(t, client.URL.Host, redirect.Host)
require.NotContains(t, redirect.String(), maliciousHost)
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "kyle@coder.com", user.Email)
require.Equal(t, "kyle", user.Username)
require.Equal(t, "Kylium Carbonate", user.Name)
require.Equal(t, "/hello-world", user.AvatarURL)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Len(t, auditor.AuditLogs(), numLogs)
require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
t.Run("SignupWeirdName", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
AllowSignups: true,
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
return &github.User{
AvatarURL: github.String("/hello-world"),
ID: ptr.Ref[int64](1234),
Login: github.String("kyle"),
Name: github.String(" " + strings.Repeat("a", 129) + " "),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("kyle@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
resp := oauth2Callback(t, client)
numLogs++ // add an audit log for login
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "kyle@coder.com", user.Email)
require.Equal(t, "kyle", user.Username)
require.Equal(t, strings.Repeat("a", 128), user.Name)
require.Equal(t, "/hello-world", user.AvatarURL)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Len(t, auditor.AuditLogs(), numLogs)
require.NotEqual(t, auditor.AuditLogs()[numLogs-1].UserID, uuid.Nil)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
t.Run("SignupAllowedTeam", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowOrganizations: []string{"coder"},
AllowTeams: []coderd.GithubOAuth2Team{{"coder", "frontend"}},
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return &github.Membership{}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
AvatarURL: github.String("/hello-world"),
ID: github.Int64(100),
Login: github.String("kyle"),
Name: github.String("Kylium Carbonate"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("kyle@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
resp := oauth2Callback(t, client)
numLogs++ // add an audit log for login
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "kyle@coder.com", user.Email)
require.Equal(t, "kyle", user.Username)
require.Equal(t, "Kylium Carbonate", user.Name)
require.Equal(t, "/hello-world", user.AvatarURL)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
// nolint: dupl
t.Run("SignupAllowedTeamInFirstOrganization", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowOrganizations: []string{"coder", "nil"},
AllowTeams: []coderd.GithubOAuth2Team{{"coder", "backend"}},
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{
{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
},
{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("nil"),
},
},
}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return &github.Membership{}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias"),
Name: github.String("Mathias Mathias"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("mathias@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
resp := oauth2Callback(t, client)
numLogs++ // add an audit log for login
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "mathias@coder.com", user.Email)
require.Equal(t, "mathias", user.Username)
require.Equal(t, "Mathias Mathias", user.Name)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
// nolint: dupl
t.Run("SignupAllowedTeamInSecondOrganization", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowOrganizations: []string{"coder", "nil"},
AllowTeams: []coderd.GithubOAuth2Team{{"nil", "null"}},
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{
{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
},
{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("nil"),
},
},
}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return &github.Membership{}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias"),
Name: github.String("Mathias Mathias"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("mathias@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
resp := oauth2Callback(t, client)
numLogs++ // add an audit log for login
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "mathias@coder.com", user.Email)
require.Equal(t, "mathias", user.Username)
require.Equal(t, "Mathias Mathias", user.Name)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
t.Run("SignupAllowEveryone", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowEveryone: true,
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return nil, xerrors.New("no teams")
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias"),
Name: github.String("Mathias Mathias"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("mathias@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
resp := oauth2Callback(t, client)
numLogs++ // add an audit log for login
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "mathias@coder.com", user.Email)
require.Equal(t, "mathias", user.Username)
require.Equal(t, "Mathias Mathias", user.Name)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
t.Run("SignupReplaceUnderscores", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowEveryone: true,
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{}, nil
},
TeamMembership: func(_ context.Context, _ *http.Client, _, _, _ string) (*github.Membership, error) {
return nil, xerrors.New("no teams")
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias_coder"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("mathias@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
numLogs := len(auditor.AuditLogs())
resp := oauth2Callback(t, client)
numLogs++ // add an audit log for login
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
require.Len(t, auditor.AuditLogs(), numLogs)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
client.SetSessionToken(authCookieValue(resp.Cookies()))
user, err := client.User(context.Background(), "me")
require.NoError(t, err)
require.Equal(t, "mathias-coder", user.Username)
})
t.Run("SignupFailedInactiveInOrg", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowOrganizations: []string{"coder"},
AllowTeams: []coderd.GithubOAuth2Team{{"coder", "frontend"}},
OAuth2Config: &testutil.OAuth2Config{},
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &statePending,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return &github.Membership{}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("kyle"),
Name: github.String("Kylium Carbonate"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("kyle@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
},
})
resp := oauth2Callback(t, client)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
// The bug only is exercised when a deleted user with the same linked_id exists.
// Still related open issues:
// - https://github.com/coder/coder/issues/12116
// - https://github.com/coder/coder/issues/12115
t.Run("ChangedEmail", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithServing(),
oidctest.WithCallbackPath("/api/v2/users/oauth2/github/callback"),
)
const ghID = int64(7777)
auditor := audit.NewMock()
coderEmail := &github.UserEmail{
Email: github.String("alice@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}
gmailEmail := &github.UserEmail{
Email: github.String("alice@gmail.com"),
Verified: github.Bool(true),
Primary: github.Bool(false),
}
emails := []*github.UserEmail{
gmailEmail,
coderEmail,
}
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowEveryone: true,
OAuth2Config: promoauth.NewFactory(prometheus.NewRegistry()).NewGithub("test-github", fake.OIDCConfig(t, []string{})),
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return nil, xerrors.New("no teams")
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
Login: github.String("alice"),
ID: github.Int64(ghID),
Name: github.String("Alice Liddell"),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return emails, nil
},
},
})
coderdtest.CreateFirstUser(t, owner)
ctx := testutil.Context(t, testutil.WaitLong)
ownerUser, err := owner.User(context.Background(), "me")
require.NoError(t, err)
// Create the user, then delete the user, then create again.
// This causes the email change to fail.
client := codersdk.New(owner.URL)
client, _ = fake.Login(t, client, jwt.MapClaims{})
deleted, err := client.User(ctx, "me")
require.NoError(t, err)
err = owner.DeleteUser(ctx, deleted.ID)
require.NoError(t, err)
// Check no user links for the user
links, err := db.GetUserLinksByUserID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(ownerUser)), deleted.ID)
require.NoError(t, err)
require.Empty(t, links)
// Make sure a user_link cannot be created with a deleted user.
_, err = db.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{
UserID: deleted.ID,
LoginType: "github",
LinkedID: "100",
OAuthAccessToken: "random",
OAuthRefreshToken: "random",
OAuthExpiry: time.Now(),
Claims: database.UserLinkClaims{},
})
require.ErrorContains(t, err, "Cannot create user_link for deleted user")
// Create the user again.
client, _ = fake.Login(t, client, jwt.MapClaims{})
user, err := client.User(ctx, "me")
require.NoError(t, err)
userID := user.ID
require.Equal(t, user.Email, *coderEmail.Email)
// Now the user is registered, let's change their primary email.
coderEmail.Primary = github.Bool(false)
gmailEmail.Primary = github.Bool(true)
client, _ = fake.Login(t, client, jwt.MapClaims{})
user, err = client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created")
require.Equal(t, user.Email, *gmailEmail.Email)
// Entirely change emails.
newEmail := "alice@newdomain.com"
emails = []*github.UserEmail{
{
Email: github.String("alice@newdomain.com"),
Primary: github.Bool(true),
Verified: github.Bool(true),
},
}
client, _ = fake.Login(t, client, jwt.MapClaims{})
user, err = client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created")
require.Equal(t, user.Email, newEmail)
})
t.Run("DeviceFlow", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
AllowSignups: true,
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("testuser"),
Name: github.String("The Right Honorable Sir Test McUser"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("testuser@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
DeviceFlowEnabled: true,
ExchangeDeviceCode: func(_ context.Context, _ string) (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: "access_token",
RefreshToken: "refresh_token",
Expiry: time.Now().Add(time.Hour),
}, nil
},
AuthorizeDevice: func(_ context.Context) (*codersdk.ExternalAuthDevice, error) {
return &codersdk.ExternalAuthDevice{
DeviceCode: "device_code",
UserCode: "user_code",
}, nil
},
},
})
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
// Ensure that we redirect to the device login page when the user is not logged in.
oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback")
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
require.Equal(t, "/login/device", location.Path)
query := location.Query()
require.NotEmpty(t, query.Get("state"))
// Ensure that we return a JSON response when the code is successfully exchanged.
oauthURL, err = client.URL.Parse("/api/v2/users/oauth2/github/callback?code=hey&state=somestate")
require.NoError(t, err)
req, err = http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
req.AddCookie(&http.Cookie{
Name: "oauth_state",
Value: "somestate",
})
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2PKCEVerifier,
Value: oauth2.GenerateVerifier(),
})
require.NoError(t, err)
res, err = client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var resp codersdk.OAuth2DeviceFlowCallbackResponse
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
require.Equal(t, "/", resp.RedirectURL)
})
}
// nolint:bodyclose
func TestUserOIDC(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
Name string
IDTokenClaims jwt.MapClaims
UserInfoClaims jwt.MapClaims
AccessTokenClaims jwt.MapClaims
AllowSignups bool
EmailDomain []string
AssertUser func(t testing.TB, u codersdk.User)
StatusCode int
AssertResponse func(t testing.TB, resp *http.Response)
IgnoreEmailVerified bool
IgnoreUserInfo bool
UseAccessToken bool
PrecreateFirstUser bool
}{
{
Name: "NoSub",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
},
{
Name: "AccessTokenMerge",
IDTokenClaims: jwt.MapClaims{
"sub": uuid.NewString(),
},
AccessTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
},
IgnoreUserInfo: true,
AllowSignups: true,
UseAccessToken: true,
StatusCode: http.StatusOK,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle@kwc.io", u.Email)
},
},
{
Name: "AccessTokenMergeNotJWT",
IDTokenClaims: jwt.MapClaims{
"sub": uuid.NewString(),
},
IgnoreUserInfo: true,
AllowSignups: true,
UseAccessToken: true,
StatusCode: http.StatusBadRequest,
},
{
Name: "EmailOnly",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
},
},
{
Name: "EmailVerifiedAsStringTrue",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": "true",
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
},
},
{
Name: "EmailVerifiedAsStringFalse",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": "false",
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusForbidden,
},
{
Name: "EmailNotVerified",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": false,
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusForbidden,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(data)
// Should be an HTML error page, not JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, body, "<!doctype html>")
require.Contains(t, body, "Email not verified")
require.Contains(t, body, "Verify the")
require.Contains(t, body, "Back to login")
require.NotContains(t, body, `"message"`)
},
},
{
Name: "EmailNotAString", IDTokenClaims: jwt.MapClaims{
"email": 3.14159,
"email_verified": false,
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
},
{
Name: "EmailNotVerifiedIgnored",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": false,
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, u.Username, "kyle")
},
IgnoreEmailVerified: true,
},
{
Name: "NotInRequiredEmailDomain",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
"coder.com",
},
StatusCode: http.StatusForbidden,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(data)
// Should be an HTML error page, not JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, body, "<!doctype html>")
require.Contains(t, body, "Unauthorized email")
require.Contains(t, body, "is not from an authorized domain")
require.Contains(t, body, "Back to login")
require.NotContains(t, body, `"message"`)
},
},
{
Name: "EmailDomainWithLeadingAt",
IDTokenClaims: jwt.MapClaims{
"email": "cian@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
"@coder.com",
},
StatusCode: http.StatusOK,
},
{
Name: "EmailDomainForbiddenWithLeadingAt",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
"@coder.com",
},
StatusCode: http.StatusForbidden,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(data)
// Should be an HTML error page, not JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, body, "<!doctype html>")
require.Contains(t, body, "Unauthorized email")
require.Contains(t, body, "is not from an authorized domain")
require.Contains(t, body, "Back to login")
require.NotContains(t, body, `"message"`)
},
},
{
Name: "EmailDomainCaseInsensitive",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@KWC.io",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, u.Username, "kyle")
},
EmailDomain: []string{
"kwc.io",
},
StatusCode: http.StatusOK,
},
{
Name: "EmailDomainSubset",
IDTokenClaims: jwt.MapClaims{
"email": "colin@gmail.com",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
EmailDomain: []string{
"mail.com",
},
StatusCode: http.StatusForbidden,
},
{
Name: "EmptyClaims",
IDTokenClaims: jwt.MapClaims{},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
},
{
Name: "NoSignups",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
StatusCode: http.StatusForbidden,
PrecreateFirstUser: true,
},
{
Name: "FirstSignup",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
StatusCode: http.StatusOK,
},
{
Name: "UsernameFromEmail",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "UsernameFromClaims",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"preferred_username": "hotdog",
"sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "hotdog", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "FullNameFromClaims",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"name": "Hot Dog",
"sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "Hot Dog", u.Name)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "InvalidFullNameFromClaims",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
// Full names must be less or equal to than 128 characters in length.
// However, we should not fail to log someone in if their name is too long.
// Just truncate it.
"name": strings.Repeat("a", 129),
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, strings.Repeat("a", 128), u.Name)
},
},
{
Name: "FullNameWhitespace",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
// Full names must not have leading or trailing whitespace, but this is a
// daft reason to fail a login.
"name": " Bobby Whitespace ",
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "Bobby Whitespace", u.Name)
},
},
{
// Services like Okta return the email as the username:
// https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present
Name: "UsernameAsEmail",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"name": "Kylium Carbonate",
"preferred_username": "kyle@kwc.io",
"sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
// See: https://github.com/coder/coder/issues/4472
Name: "UsernameIsEmail",
IDTokenClaims: jwt.MapClaims{
"email_verified": true,
"preferred_username": "kyle@kwc.io",
"sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "kyle", u.Username)
assert.Empty(t, u.Name)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "WithPicture",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"preferred_username": "kyle",
"picture": "/example.png",
"sub": uuid.NewString(),
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "/example.png", u.AvatarURL)
assert.Equal(t, "kyle", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "WithUserInfoClaims",
IDTokenClaims: jwt.MapClaims{
"email": "kyle@kwc.io",
"email_verified": true,
"sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"preferred_username": "potato",
"picture": "/example.png",
"name": "Kylium Carbonate",
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "/example.png", u.AvatarURL)
assert.Equal(t, "Kylium Carbonate", u.Name)
assert.Equal(t, "potato", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "GroupsDoesNothing",
IDTokenClaims: jwt.MapClaims{
"email": "coolin@coder.com",
"email_verified": true,
"groups": []string{"pingpong"},
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "UserInfoOverridesIDTokenClaims",
IDTokenClaims: jwt.MapClaims{
"email": "internaluser@internal.domain",
"email_verified": false,
"sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"email": "externaluser@external.domain",
"email_verified": true,
"preferred_username": "user",
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "user", u.Username)
},
AllowSignups: true,
IgnoreEmailVerified: false,
StatusCode: http.StatusOK,
},
{
Name: "InvalidUserInfo",
IDTokenClaims: jwt.MapClaims{
"email": "internaluser@internal.domain",
"email_verified": false,
"sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"email": 1,
},
AllowSignups: true,
IgnoreEmailVerified: false,
StatusCode: http.StatusInternalServerError,
},
{
Name: "IgnoreUserInfo",
IDTokenClaims: jwt.MapClaims{
"email": "user@internal.domain",
"email_verified": true,
"name": "User McName",
"preferred_username": "user",
"sub": uuid.NewString(),
},
UserInfoClaims: jwt.MapClaims{
"email": "user.mcname@external.domain",
"name": "Mr. User McName",
"preferred_username": "Mr. User McName",
},
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "user", u.Username)
assert.Equal(t, "User McName", u.Name)
},
IgnoreUserInfo: true,
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "HugeIDToken",
IDTokenClaims: inflateClaims(t, jwt.MapClaims{
"email": "user@domain.tld",
"email_verified": true,
"sub": uuid.NewString(),
}, 65536),
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "user", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "HugeClaims",
IDTokenClaims: jwt.MapClaims{
"email": "user@domain.tld",
"email_verified": true,
"sub": uuid.NewString(),
},
UserInfoClaims: inflateClaims(t, jwt.MapClaims{}, 65536),
AssertUser: func(t testing.TB, u codersdk.User) {
assert.Equal(t, "user", u.Username)
},
AllowSignups: true,
StatusCode: http.StatusOK,
},
{
Name: "IssuerMismatch",
IDTokenClaims: jwt.MapClaims{
"iss": "https://mismatch.com",
"email": "user@domain.tld",
"email_verified": true,
"sub": uuid.NewString(),
},
AllowSignups: true,
StatusCode: http.StatusBadRequest,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(data), "id token issued by a different provider")
},
},
} {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
opts := []oidctest.FakeIDPOpt{
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
oidctest.WithStaticUserInfo(tc.UserInfoClaims),
}
if len(tc.AccessTokenClaims) > 0 {
opts = append(opts, oidctest.WithAccessTokenJWTHook(func(email string, exp time.Time) jwt.MapClaims {
return tc.AccessTokenClaims
}))
}
fake := oidctest.NewFakeIDP(t, opts...)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = tc.AllowSignups
cfg.EmailDomain = tc.EmailDomain
cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified
cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo
if tc.IgnoreUserInfo {
cfg.SecondaryClaims = coderd.MergedClaimsSourceNone
}
if tc.UseAccessToken {
cfg.SecondaryClaims = coderd.MergedClaimsSourceAccessToken
}
cfg.NameField = "name"
})
auditor := audit.NewMock()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
OIDCConfig: cfg,
Logger: &logger,
})
numLogs := len(auditor.AuditLogs())
ctx := testutil.Context(t, testutil.WaitShort)
if tc.PrecreateFirstUser {
owner.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "precreated@coder.com",
Username: "precreated",
Password: "SomeSecurePassword!",
})
}
client, resp := fake.AttemptLogin(t, owner, tc.IDTokenClaims)
numLogs++ // add an audit log for login
require.Equal(t, tc.StatusCode, resp.StatusCode)
if tc.AssertResponse != nil {
tc.AssertResponse(t, resp)
}
if tc.AssertUser != nil {
user, err := client.User(ctx, "me")
require.NoError(t, err)
tc.AssertUser(t, user)
require.Len(t, auditor.AuditLogs(), numLogs)
require.NotEqual(t, uuid.Nil, auditor.AuditLogs()[numLogs-1].UserID)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
require.Equal(t, 1, len(user.OrganizationIDs), "in the default org")
}
})
}
// Absent email_verified claim tests use a FakeIDP that suppresses the
// default email_verified=true injection so the handler's absent-claim
// branch is exercised end-to-end.
t.Run("EmailVerifiedMissing", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
oidctest.WithOmitEmailVerifiedDefault(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
_, resp := fake.AttemptLogin(t, client, jwt.MapClaims{
"email": "kyle@kwc.io",
"sub": uuid.NewString(),
})
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("EmailVerifiedMissingIgnored", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
oidctest.WithOmitEmailVerifiedDefault(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.IgnoreEmailVerified = true
})
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
userClient, _ := fake.Login(t, client, jwt.MapClaims{
"email": "kyle@kwc.io",
"sub": uuid.NewString(),
})
ctx := testutil.Context(t, testutil.WaitShort)
user, err := userClient.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, "kyle", user.Username)
})
t.Run("OIDCDormancy", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
auditor := audit.NewMock()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
Auditor: auditor,
OIDCConfig: cfg,
Logger: &logger,
})
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
Status: database.UserStatusDormant,
})
auditor.ResetLogs()
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"email_verified": true,
"sub": uuid.NewString(),
})
require.Equal(t, http.StatusOK, resp.StatusCode)
require.True(t, auditor.Contains(t, database.AuditLog{
ResourceType: database.ResourceTypeUser,
AdditionalFields: json.RawMessage(`{"automatic_actor":"coder","automatic_subsystem":"dormancy"}`),
}))
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, codersdk.UserStatusActive, me.Status)
})
// Tests that an attacker with a different OIDC subject but the same
// email cannot hijack an existing linked account. The email fallback
// must be restricted to first-time linking only.
t.Run("OIDCEmailFallbackBlockedByExistingLink", func(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
allowSignups bool
}{
{"SignupsDisabled", false},
{"SignupsEnabled", true},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = tc.allowSignups
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Create a victim user with an existing OIDC link.
// Use the fake IDP's issuer so the linked_id format is
// realistic (same issuer, different subject).
victim := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
victimLinkedID := fake.IssuerURL().String() + "||" + "victim-subject"
dbgen.UserLink(t, db, database.UserLink{
UserID: victim.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: victimLinkedID,
})
// Attacker tries to login with a different subject but the
// same email. The email fallback is blocked because the victim
// already has a user_link with a different linked_id.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": victim.Email,
"sub": "attacker-subject",
})
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"attacker must not authenticate as the victim")
// Verify the victim's link is unchanged.
victimLink, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: victim.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, victimLinkedID, victimLink.LinkedID,
"victim's linked_id must remain unchanged")
})
}
})
// Tests that a first-time OIDC user can still link via email when no
// user_link exists (e.g. a dormant OIDC user created via SCIM or API).
t.Run("OIDCFirstTimeLinkByEmailAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Create a user with OIDC login type but NO user_link.
// This simulates a user created via SCIM or the API.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
// Login with a new OIDC subject and matching email.
// This should succeed because no user_link exists.
sub := uuid.NewString()
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID,
"should authenticate as the existing user")
// Verify the created link has a populated linked_id.
link, err := db.GetUserLinkByUserIDLoginType(
dbauthz.AsSystemRestricted(context.Background()),
database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
expectedLinkedID := fake.IssuerURL().String() + "||" + sub
require.Equal(t, expectedLinkedID, link.LinkedID,
"link should have the correct linked_id after first-time linking")
})
// Tests that a legacy user with an empty linked_id can still login
// and that their linked_id is backfilled with the correct value.
t.Run("OIDCLegacyLinkBackfill", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Create a legacy user with an empty linked_id.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: "", // Legacy: empty linked_id
})
sub := uuid.NewString()
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": sub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID,
"legacy user should still be able to login via email fallback")
// Verify the linked_id was backfilled with the correct value.
link, err := db.GetUserLinkByUserIDLoginType(
dbauthz.AsSystemRestricted(context.Background()),
database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
expectedLinkedID := fake.IssuerURL().String() + "||" + sub
require.Equal(t, expectedLinkedID, link.LinkedID,
"linked_id should be backfilled with the correct value after login")
})
// Tests that changing the OIDC issuer URL blocks an existing user whose
// linked_id was recorded under the old issuer. This is a deliberate
// breaking change: before this fix the email fallback silently rescued
// such users. Now the login is rejected because the existing link's
// linked_id (old issuer) differs from the newly computed one (new issuer).
t.Run("OIDCEmailFallbackBlockedByIssuerChange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Seed a user whose link was created under a different (old) issuer
// but with the same subject the IdP presents on login.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
const sub = "stable-subject"
oldLinkedID := "https://old-issuer.example.com||" + sub
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: oldLinkedID,
})
// Login presents the same subject but the current issuer, so the
// computed linked_id differs from the stored one and is blocked.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": sub,
})
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"issuer change must block the email fallback for an existing link")
// The stored link must remain unchanged.
link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, oldLinkedID, link.LinkedID,
"linked_id must not be modified when the login is blocked")
})
// Tests the INSECURE OIDC email fallback escape hatch. When the
// deployment flag is set, an OIDC login whose subject differs from an
// existing user_link's linked_id but whose email matches must be
// allowed through. The original linked_id is preserved (no overwrite),
// so the user can keep logging in with either subject.
t.Run("OIDCInsecureEmailFallbackAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.EmailFallback = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Seed a user whose link records the IdP's first connection.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
originalLinkedID := fake.IssuerURL().String() + "||" + "first-connection-sub"
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: originalLinkedID,
})
// Login with a different subject (the broker emitted a new `sub`
// for the same user) but the same email. With EmailFallback
// enabled the email match resolves the login despite the linked_id
// mismatch.
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": "second-connection-sub",
})
require.Equal(t, http.StatusOK, resp.StatusCode,
"insecure email fallback must allow login with a mismatched subject")
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID,
"should authenticate as the existing user")
// The original linked_id must be preserved, not overwritten by the
// new subject. This keeps the next login from the original subject
// (which hits the primary linked_id path) working.
link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, originalLinkedID, link.LinkedID,
"linked_id must be preserved on insecure email fallback")
})
// Tests that with the INSECURE OIDC email fallback enabled, the
// original subject's login still resolves via the primary linked_id
// path after a fallback login from a different subject. The fallback
// path does not overwrite the link, so the original subject keeps
// matching directly.
t.Run("OIDCInsecureEmailFallbackPreservesOriginalLogin", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.EmailFallback = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
const originalSub = "first-connection-sub"
originalLinkedID := fake.IssuerURL().String() + "||" + originalSub
dbgen.UserLink(t, db, database.UserLink{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
LinkedID: originalLinkedID,
})
// Fallback login with a different subject.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": "second-connection-sub",
})
require.Equal(t, http.StatusOK, resp.StatusCode)
// Subsequent login with the original subject must hit the primary
// linked_id match and succeed.
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": originalSub,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
me, err := client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, me.ID)
link, err := db.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(context.Background()), database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
})
require.NoError(t, err)
require.Equal(t, originalLinkedID, link.LinkedID,
"linked_id must stay anchored to the original subject")
})
// Tests that the INSECURE OIDC email fallback does NOT extend to
// signups: an attacker logging in with a brand-new email (no existing
// user) still goes through the normal signup gate. The escape hatch is
// only about resolving subject-mismatch on existing accounts.
t.Run("OIDCInsecureEmailFallbackDoesNotCreateUsers", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = false
cfg.EmailFallback = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Seed an existing user so the deployment's user count is > 0;
// otherwise the first signup is always allowed.
dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
})
// New email, no existing user. Signups are disabled, so the
// fallback flag must not let the login through.
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": "stranger@example.com",
"sub": "stranger-subject",
})
require.Equal(t, http.StatusForbidden, resp.StatusCode,
"insecure email fallback must not bypass the signup gate")
})
t.Run("OIDCSuspended", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
OIDCConfig: cfg,
Logger: &logger,
})
// Pre-existing OIDC user that has been suspended by an admin.
user := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeOIDC,
Status: database.UserStatusSuspended,
})
_, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
"email": user.Email,
"sub": uuid.NewString(),
})
// The OIDC handler should reject the login with an explanatory
// 403 instead of silently issuing a session and letting the SPA
// bounce the user back to /login with no message.
require.Equal(t, http.StatusForbidden, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "suspended", "error page should explain why login was rejected")
// The user's status must remain suspended; nothing in the OAuth
// transaction should have been committed.
//nolint:gocritic // System read for verification.
dbUser, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), user.ID)
require.NoError(t, err)
require.Equal(t, database.UserStatusSuspended, dbUser.Status)
})
t.Run("OIDCConvert", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
OIDCConfig: cfg,
})
owner := coderdtest.CreateFirstUser(t, client)
user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
require.Equal(t, codersdk.LoginTypePassword, userData.LoginType)
claims := jwt.MapClaims{
"email": userData.Email,
"email_verified": true,
"sub": uuid.NewString(),
}
var err error
user.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
user.HTTPClient.Transport = http.DefaultTransport.(*http.Transport).Clone()
ctx := testutil.Context(t, testutil.WaitShort)
convertResponse, err := user.ConvertLoginType(ctx, codersdk.ConvertLoginRequest{
ToType: codersdk.LoginTypeOIDC,
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
_, _ = fake.LoginWithClient(t, user, claims, func(r *http.Request) {
r.URL.RawQuery = url.Values{
"oidc_merge_state": {convertResponse.StateString},
}.Encode()
r.Header.Set(codersdk.SessionTokenHeader, user.SessionToken())
cookies := user.HTTPClient.Jar.Cookies(r.URL)
for _, cookie := range cookies {
r.AddCookie(cookie)
}
})
info, err := client.User(ctx, userData.ID.String())
require.NoError(t, err)
require.Equal(t, codersdk.LoginTypeOIDC, info.LoginType)
})
t.Run("BadJWT", func(t *testing.T) {
t.Parallel()
var (
ctx = testutil.Context(t, testutil.WaitMedium)
logger = testutil.Logger(t)
)
auditor := audit.NewMock()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
db, ps := dbtestutil.NewDB(t)
fetcher := &cryptokeys.DBFetcher{
DB: db,
}
kc, err := cryptokeys.NewSigningCache(ctx, logger, fetcher, codersdk.CryptoKeyFeatureOIDCConvert)
require.NoError(t, err)
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
OIDCConfig: cfg,
Database: db,
Pubsub: ps,
OIDCConvertKeyCache: kc,
})
owner := coderdtest.CreateFirstUser(t, client)
user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
claims := jwt.MapClaims{
"email": userData.Email,
"email_verified": true,
"sub": uuid.NewString(),
}
user.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
user.HTTPClient.Transport = http.DefaultTransport.(*http.Transport).Clone()
convertResponse, err := user.ConvertLoginType(ctx, codersdk.ConvertLoginRequest{
ToType: codersdk.LoginTypeOIDC,
Password: "SomeSecurePassword!",
})
require.NoError(t, err)
// Update the cookie to use a bad signing key. We're asserting the behavior of the scenario
// where a JWT gets minted on an old version of Coder but gets verified on a new version.
_, resp := fake.AttemptLogin(t, user, claims, func(r *http.Request) {
r.URL.RawQuery = url.Values{
"oidc_merge_state": {convertResponse.StateString},
}.Encode()
r.Header.Set(codersdk.SessionTokenHeader, user.SessionToken())
cookies := user.HTTPClient.Jar.Cookies(user.URL)
for i, cookie := range cookies {
if cookie.Name != coderd.OAuthConvertCookieValue {
continue
}
jwt := cookie.Value
var claims coderd.OAuthConvertStateClaims
err := jwtutils.Verify(ctx, kc, jwt, &claims)
require.NoError(t, err)
badJWT := generateBadJWT(t, claims)
cookie.Value = badJWT
cookies[i] = cookie
}
user.HTTPClient.Jar.SetCookies(user.URL, cookies)
for _, cookie := range cookies {
fmt.Printf("cookie: %+v\n", cookie)
r.AddCookie(cookie)
}
})
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
var respErr codersdk.Response
err = json.NewDecoder(resp.Body).Decode(&respErr)
require.NoError(t, err)
require.Contains(t, respErr.Message, "Using an invalid jwt to authorize this action.")
})
t.Run("AlternateUsername", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
OIDCConfig: cfg,
})
numLogs := len(auditor.AuditLogs())
claims := jwt.MapClaims{
"email": "jon@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
}
userClient, _ := fake.Login(t, client, claims)
numLogs++ // add an audit log for login
ctx := testutil.Context(t, testutil.WaitLong)
user, err := userClient.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, "jon", user.Username)
// Pass a different subject field so that we prompt creating a
// new user
userClient, _ = fake.Login(t, client, jwt.MapClaims{
"email": "jon@example2.com",
"email_verified": true,
"sub": "diff",
})
numLogs++ // add an audit log for login
user, err = userClient.User(ctx, "me")
require.NoError(t, err)
require.True(t, strings.HasPrefix(user.Username, "jon-"), "username %q should have prefix %q", user.Username, "jon-")
require.Len(t, auditor.AuditLogs(), numLogs)
require.Equal(t, database.AuditActionRegister, auditor.AuditLogs()[numLogs-1].Action)
})
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback")
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
resp, err := client.HTTPClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("NoIDToken", func(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
_, resp := fake.AttemptLogin(t, client, jwt.MapClaims{})
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("BadVerify", func(t *testing.T) {
t.Parallel()
badVerifier := oidc.NewVerifier("", &oidc.StaticKeySet{
PublicKeys: []crypto.PublicKey{},
}, &oidc.Config{})
badProvider := &oidc.Provider{}
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
cfg.Provider = badProvider
cfg.Verifier = badVerifier
})
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
_, resp := fake.AttemptLogin(t, client, jwt.MapClaims{})
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("StripRedirectHost", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
expectedRedirect := "/foo/bar?hello=world&bar=baz"
redirectURL := "https://malicious" + expectedRedirect
callbackPath := fmt.Sprintf("/api/v2/users/oidc/callback?redirect=%s", url.QueryEscape(redirectURL))
fake := oidctest.NewFakeIDP(t,
oidctest.WithRefresh(func(_ string) error {
return xerrors.New("refreshing token should never occur")
}),
oidctest.WithServing(),
oidctest.WithCallbackPath(callbackPath),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
client.HTTPClient.Transport = http.DefaultTransport
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
claims := jwt.MapClaims{
"email": "user@example.com",
"email_verified": true,
"sub": uuid.NewString(),
}
// Perform the login
loginClient, resp := fake.LoginWithClient(t, client, claims)
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
// Get the location from the response
location, err := resp.Location()
require.NoError(t, err)
// Check that the redirect URL has been stripped of its malicious host
require.Equal(t, expectedRedirect, location.RequestURI())
require.Equal(t, client.URL.Host, location.Host)
require.NotContains(t, location.String(), "malicious")
// Verify the user was created
user, err := loginClient.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, "user@example.com", user.Email)
})
}
func TestUserLogout(t *testing.T) {
t.Parallel()
// Create a custom database so it's easier to make scoped tokens for
// testing.
db, pubSub := dbtestutil.NewDB(t)
dv := coderdtest.DeploymentValues(t)
dv.HTTPCookies.EnableHostPrefix = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
Database: db,
Pubsub: pubSub,
})
firstUser := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Create a user with built-in auth.
const (
email = "dean.was.here@test.coder.com"
username = "dean"
//nolint:gosec
password = "SomeSecurePassword123!"
)
newUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: email,
Username: username,
Password: password,
OrganizationIDs: []uuid.UUID{firstUser.OrganizationID},
})
require.NoError(t, err)
// Log in with basic auth and keep the the session token (but don't use it).
userClient := codersdk.New(client.URL)
loginRes1, err := userClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: email,
Password: password,
})
require.NoError(t, err)
// Log in again but actually set the token this time.
loginRes2, err := userClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: email,
Password: password,
})
require.NoError(t, err)
userClient.SetSessionToken(loginRes2.SessionToken)
// Add the user's second session token to the list of API keys that should
// be deleted.
shouldBeDeleted := map[string]string{
"user login 2 (logging out with this)": loginRes2.SessionToken,
}
// Add the user's first token, and the admin's session token to the list of
// API keys that should not be deleted.
shouldNotBeDeleted := map[string]string{
"user login 1 (not logging out of)": loginRes1.SessionToken,
"admin login": client.SessionToken(),
}
// Create a few application_connect-scoped API keys that should be deleted.
for i := range 3 {
key, _ := dbgen.APIKey(t, db, database.APIKey{
UserID: newUser.ID,
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect},
})
shouldBeDeleted[fmt.Sprintf("application_connect key owned by logout user %d", i)] = key.ID
}
// Create a few application_connect-scoped API keys for the admin user that
// should not be deleted.
for i := range 3 {
key, _ := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect},
})
shouldNotBeDeleted[fmt.Sprintf("application_connect key owned by admin user %d", i)] = key.ID
}
// Log out of the new user.
err = userClient.Logout(ctx)
require.NoError(t, err)
// Ensure the new user's session token is no longer valid.
_, err = userClient.User(ctx, codersdk.Me)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
// Check that the deleted keys are gone.
for name, id := range shouldBeDeleted {
id := strings.Split(id, "-")[0]
_, err := db.GetAPIKeyByID(ctx, id)
require.Error(t, err, name)
}
// Check that the other keys are still there.
for name, id := range shouldNotBeDeleted {
id := strings.Split(id, "-")[0]
_, err := db.GetAPIKeyByID(ctx, id)
require.NoError(t, err, name)
}
}
// TestOIDCSkipIssuer verifies coderd can run without checking the issuer url
// in the OIDC exchange. This means the CODER_OIDC_ISSUER_URL does not need
// to match the id_token `iss` field, or the value returned in the well-known
// config.
//
// So this test has:
// - OIDC at http://localhost:<port>
// - well-known config with issuer https://primary.com
// - JWT with issuer https://secondary.com
//
// Without this security check disabled, all three above would have to match.
// TestOIDCDomainErrorMessage ensures that when a user with an unauthorized domain
// attempts to login, the error message doesn't expose the list of authorized domains.
func TestOIDCDomainErrorMessage(t *testing.T) {
t.Parallel()
allowedDomains := []string{"allowed1.com", "allowed2.org", "company.internal"}
setup := func() (*oidctest.FakeIDP, *codersdk.Client) {
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.EmailDomain = allowedDomains
cfg.AllowSignups = true
})
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
return fake, client
}
// Test case 1: Email domain not in allowed list
t.Run("ErrorMessageOmitsDomains", func(t *testing.T) {
t.Parallel()
fake, client := setup()
// Prepare claims with email from unauthorized domain
claims := jwt.MapClaims{
"email": "user@unauthorized.com",
"email_verified": true,
"sub": uuid.NewString(),
}
_, resp := fake.AttemptLogin(t, client, claims)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(data), "is not from an authorized domain")
require.Contains(t, string(data), "Please contact your administrator")
// Verify the response is a rendered HTML error page, not raw JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, string(data), "<!doctype html>")
require.Contains(t, string(data), "Unauthorized email")
require.Contains(t, string(data), "Back to login")
require.NotContains(t, string(data), `"message"`)
for _, domain := range allowedDomains {
require.NotContains(t, string(data), domain)
}
})
// Test case 2: Malformed email without @ symbol
t.Run("MalformedEmailErrorOmitsDomains", func(t *testing.T) {
t.Parallel()
fake, client := setup()
// Prepare claims with an invalid email format (no @ symbol)
claims := jwt.MapClaims{
"email": "invalid-email-without-domain",
"email_verified": true,
"sub": uuid.NewString(),
}
_, resp := fake.AttemptLogin(t, client, claims)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(data), "is not from an authorized domain")
require.Contains(t, string(data), "Please contact your administrator")
// Verify the response is a rendered HTML error page, not raw JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, string(data), "<!doctype html>")
require.Contains(t, string(data), "Unauthorized email")
require.Contains(t, string(data), "Back to login")
require.NotContains(t, string(data), `"message"`)
for _, domain := range allowedDomains {
require.NotContains(t, string(data), domain)
}
})
}
func TestOIDCSkipIssuer(t *testing.T) {
t.Parallel()
const primaryURLString = "https://primary.com"
const secondaryURLString = "https://secondary.com"
primaryURL := must(url.Parse(primaryURLString))
fake := oidctest.NewFakeIDP(t,
oidctest.WithServing(),
oidctest.WithDefaultIDClaims(jwt.MapClaims{}),
oidctest.WithHookWellKnown(func(r *http.Request, j *oidctest.ProviderJSON) error {
assert.NotEqual(t, r.URL.Host, primaryURL.Host, "request went to wrong host")
j.Issuer = primaryURLString
return nil
}),
)
owner := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: fake.OIDCConfigSkipIssuerChecks(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
}),
})
// User can login and use their token.
ctx := testutil.Context(t, testutil.WaitShort)
//nolint:bodyclose
userClient, _ := fake.Login(t, owner, jwt.MapClaims{
"iss": secondaryURLString,
"email": "alice@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
})
found, err := userClient.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC)
}
func TestUserForgotPassword(t *testing.T) {
t.Parallel()
const oldPassword = "SomeSecurePassword!"
const newPassword = "SomeNewSecurePassword!"
requireOneTimePasscodeNotification := func(t *testing.T, notif *notificationstest.FakeNotification, userID uuid.UUID) {
require.Equal(t, notifications.TemplateUserRequestedOneTimePasscode, notif.TemplateID)
require.Equal(t, userID, notif.UserID)
require.Equal(t, 1, len(notif.Targets))
require.Equal(t, userID, notif.Targets[0])
}
requireCanLogin := func(t *testing.T, ctx context.Context, client *codersdk.Client, email string, password string) {
_, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: email,
Password: password,
})
require.NoError(t, err)
}
requireCannotLogin := func(t *testing.T, ctx context.Context, client *codersdk.Client, email string, password string) {
_, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: email,
Password: password,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Incorrect email or password.")
}
requireRequestOneTimePasscode := func(t *testing.T, ctx context.Context, client *codersdk.Client, notifyEnq *notificationstest.FakeEnqueuer, email string, userID uuid.UUID) string {
notifyEnq.Clear()
err := client.RequestOneTimePasscode(ctx, codersdk.RequestOneTimePasscodeRequest{Email: email})
require.NoError(t, err)
sent := notifyEnq.Sent()
require.Len(t, sent, 1)
requireOneTimePasscodeNotification(t, sent[0], userID)
return sent[0].Labels["one_time_passcode"]
}
requireChangePasswordWithOneTimePasscode := func(t *testing.T, ctx context.Context, client *codersdk.Client, email string, passcode string, password string) {
err := client.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: email,
OneTimePasscode: passcode,
Password: password,
})
require.NoError(t, err)
}
t.Run("CanChangePassword", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
// First try to login before changing our password. We expected this to error
// as we haven't change the password yet.
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
// Create an API token to confirm the password reset revokes the
// user's existing keys.
token, tokenErr := anotherClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
require.NoError(t, tokenErr)
tokenClient := codersdk.New(client.URL, codersdk.WithSessionToken(token.Key))
_, tokenErr = tokenClient.User(ctx, codersdk.Me)
require.NoError(t, tokenErr, "token should authenticate before the password reset")
oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)
requireChangePasswordWithOneTimePasscode(t, ctx, anotherClient, anotherUser.Email, oneTimePasscode, newPassword)
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
// The password reset must revoke every API key owned by the user.
_, tokenErr = tokenClient.User(ctx, codersdk.Me)
var tokenAPIErr *codersdk.Error
require.ErrorAs(t, tokenErr, &tokenAPIErr)
require.Equal(t, http.StatusUnauthorized, tokenAPIErr.StatusCode())
// We now need to check that the one-time passcode isn't valid.
err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: oneTimePasscode,
Password: newPassword + "!",
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode.")
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword+"!")
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
})
t.Run("OneTimePasscodeExpires", func(t *testing.T) {
t.Parallel()
const oneTimePasscodeValidityPeriod = 1 * time.Millisecond
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
OneTimePasscodeValidityPeriod: oneTimePasscodeValidityPeriod,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)
// Wait for long enough so that the token expires
time.Sleep(oneTimePasscodeValidityPeriod + 1*time.Millisecond)
// Try to change password with an expired one time passcode.
err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: oneTimePasscode,
Password: newPassword,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode.")
// Ensure that the password was not changed.
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})
t.Run("CannotChangePasswordWithoutRequestingOneTimePasscode", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: uuid.New().String(),
Password: newPassword,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode")
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})
t.Run("CannotChangePasswordWithInvalidOneTimePasscode", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_ = requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)
err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: uuid.New().String(), // Use a different UUID to the one expected
Password: newPassword,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode")
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})
t.Run("CannotChangePasswordWithNoOneTimePasscode", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_ = requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)
err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: "",
Password: newPassword,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Validation failed.")
require.Equal(t, 1, len(apiErr.Validations))
require.Equal(t, "one_time_passcode", apiErr.Validations[0].Field)
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, newPassword)
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})
t.Run("CannotChangePasswordWithWeakPassword", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)
err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: oneTimePasscode,
Password: "notstrong",
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Invalid password.")
require.Equal(t, 1, len(apiErr.Validations))
require.Equal(t, "password", apiErr.Validations[0].Field)
requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, "notstrong")
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})
t.Run("CannotChangePasswordOfAnotherUser", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
thirdClient, thirdUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
// Request a One-Time Passcode for `anotherUser`
oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)
// Ensure we cannot change the password for `thirdUser` with `anotherUser`'s One-Time Passcode.
err := thirdClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: thirdUser.Email,
OneTimePasscode: oneTimePasscode,
Password: newPassword,
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Incorrect email or one-time passcode")
requireCannotLogin(t, ctx, thirdClient, thirdUser.Email, newPassword)
requireCanLogin(t, ctx, thirdClient, thirdUser.Email, oldPassword)
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})
t.Run("GivenOKResponseWithInvalidEmail", func(t *testing.T) {
t.Parallel()
notifyEnq := &notificationstest.FakeEnqueuer{}
client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
anotherClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
err := anotherClient.RequestOneTimePasscode(ctx, codersdk.RequestOneTimePasscodeRequest{
Email: "not-a-member@coder.com",
})
require.NoError(t, err)
sent := notifyEnq.Sent()
require.Len(t, notifyEnq.Sent(), 1)
require.NotEqual(t, notifications.TemplateUserRequestedOneTimePasscode, sent[0].TemplateID)
})
}
func oauth2Callback(t *testing.T, client *codersdk.Client, opts ...func(*http.Request)) *http.Response {
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
state := "somestate"
oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback?code=asd&state=" + state)
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
for _, opt := range opts {
opt(req)
}
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2StateCookie,
Value: state,
})
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2PKCEVerifier,
Value: oauth2.GenerateVerifier(),
})
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
t.Cleanup(func() {
_ = res.Body.Close()
})
return res
}
func authCookieValue(cookies []*http.Cookie) string {
for _, cookie := range cookies {
if cookie.Name == codersdk.SessionTokenCookie {
return cookie.Value
}
}
return ""
}
// inflateClaims 'inflates' a jwt.MapClaims from a seed by
// adding a ridiculously large key-value pair of length size.
func inflateClaims(t testing.TB, seed jwt.MapClaims, size int) jwt.MapClaims {
t.Helper()
junk, err := cryptorand.String(size)
require.NoError(t, err)
seed["random_data"] = junk
return seed
}
// generateBadJWT generates a JWT with a random key. It's intended to emulate the old-style JWT's we generated.
func generateBadJWT(t *testing.T, claims interface{}) string {
t.Helper()
var buf [64]byte
_, err := rand.Read(buf[:])
require.NoError(t, err)
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.HS512,
Key: buf[:],
}, nil)
require.NoError(t, err)
payload, err := json.Marshal(claims)
require.NoError(t, err)
signed, err := signer.Sign(payload)
require.NoError(t, err)
compact, err := signed.CompactSerialize()
require.NoError(t, err)
return compact
}