coder/coderd/oauth2_test.go

1116 lines
33 KiB
Go

package coderd_test
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/identityprovider"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestOAuth2ProviderApps(t *testing.T) {
t.Parallel()
t.Run("Validation", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
topCtx := testutil.Context(t, testutil.WaitLong)
tests := []struct {
name string
req codersdk.PostOAuth2ProviderAppRequest
}{
{
name: "NameMissing",
req: codersdk.PostOAuth2ProviderAppRequest{
CallbackURL: "http://localhost:3000",
},
},
{
name: "NameSpaces",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo bar",
CallbackURL: "http://localhost:3000",
},
},
{
name: "NameTooLong",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "too loooooooooooooooooooooooooong",
CallbackURL: "http://localhost:3000",
},
},
{
name: "NameTaken",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "taken",
CallbackURL: "http://localhost:3000",
},
},
{
name: "URLMissing",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
},
},
{
name: "URLLocalhostNoScheme",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "localhost:3000",
},
},
{
name: "URLNoScheme",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "coder.com",
},
},
{
name: "URLNoColon",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "http//coder",
},
},
{
name: "URLJustBar",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "bar",
},
},
{
name: "URLPathOnly",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "/bar/baz/qux",
},
},
{
name: "URLJustHttp",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "http",
},
},
{
name: "URLNoHost",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "http://",
},
},
{
name: "URLSpaces",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "bar baz qux",
},
},
}
// Generate an application for testing name conflicts.
req := codersdk.PostOAuth2ProviderAppRequest{
Name: "taken",
CallbackURL: "http://coder.com",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(topCtx, req)
require.NoError(t, err)
// Generate an application for testing PUTs.
req = codersdk.PostOAuth2ProviderAppRequest{
Name: "quark",
CallbackURL: "http://coder.com",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
existingApp, err := client.PostOAuth2ProviderApp(topCtx, req)
require.NoError(t, err)
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, test.req)
require.Error(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, existingApp.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: test.req.Name,
CallbackURL: test.req.CallbackURL,
})
require.Error(t, err)
})
}
})
t.Run("DeleteNonExisting", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := another.OAuth2ProviderApp(ctx, uuid.New())
require.Error(t, err)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
// No apps yet.
apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
require.NoError(t, err)
require.Len(t, apps, 0)
// Should be able to add apps.
expected := generateApps(ctx, t, client, "get-apps")
expectedOrder := []codersdk.OAuth2ProviderApp{
expected.Default, expected.NoPort, expected.Subdomain,
expected.Extra[0], expected.Extra[1],
}
// Should get all the apps now.
apps, err = another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
require.NoError(t, err)
require.Len(t, apps, 5)
require.Equal(t, expectedOrder, apps)
// Should be able to keep the same name when updating.
req := codersdk.PutOAuth2ProviderAppRequest{
Name: expected.Default.Name,
CallbackURL: "http://coder.com",
Icon: "test",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
newApp, err := client.PutOAuth2ProviderApp(ctx, expected.Default.ID, req)
require.NoError(t, err)
require.Equal(t, req.Name, newApp.Name)
require.Equal(t, req.CallbackURL, newApp.CallbackURL)
require.Equal(t, req.Icon, newApp.Icon)
require.Equal(t, expected.Default.ID, newApp.ID)
// Should be able to update name.
req = codersdk.PutOAuth2ProviderAppRequest{
Name: "new-foo",
CallbackURL: "http://coder.com",
Icon: "test",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
newApp, err = client.PutOAuth2ProviderApp(ctx, expected.Default.ID, req)
require.NoError(t, err)
require.Equal(t, req.Name, newApp.Name)
require.Equal(t, req.CallbackURL, newApp.CallbackURL)
require.Equal(t, req.Icon, newApp.Icon)
require.Equal(t, expected.Default.ID, newApp.ID)
// Should be able to get a single app.
got, err := another.OAuth2ProviderApp(ctx, expected.Default.ID)
require.NoError(t, err)
require.Equal(t, newApp, got)
// Should be able to delete an app.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderApp(ctx, expected.Default.ID)
require.NoError(t, err)
// Should show the new count.
newApps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
require.NoError(t, err)
require.Len(t, newApps, 4)
require.Equal(t, expectedOrder[1:], newApps)
})
t.Run("ByUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
another, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
_ = generateApps(ctx, t, client, "by-user")
apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{
UserID: user.ID,
})
require.NoError(t, err)
require.Len(t, apps, 0)
})
}
func TestOAuth2ProviderAppSecrets(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
topCtx := testutil.Context(t, testutil.WaitLong)
// Make some apps.
apps := generateApps(topCtx, t, client, "app-secrets")
t.Run("DeleteNonExisting", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Should not be able to create secrets for a non-existent app.
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err := client.OAuth2ProviderAppSecrets(ctx, uuid.New())
require.Error(t, err)
// Should not be able to delete non-existing secrets when there is no app.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, uuid.New(), uuid.New())
require.Error(t, err)
// Should not be able to delete non-existing secrets when the app exists.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, uuid.New())
require.Error(t, err)
// Should not be able to delete an existing secret with the wrong app ID.
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := client.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
require.NoError(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, secret.ID)
require.Error(t, err)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// No secrets yet.
//nolint:gocritic // OAauth2 app management requires owner permission.
secrets, err := client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
require.NoError(t, err)
require.Len(t, secrets, 0)
// Should be able to create secrets.
for i := 0; i < 5; i++ {
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := client.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
require.NoError(t, err)
require.NotEmpty(t, secret.ClientSecretFull)
require.True(t, len(secret.ClientSecretFull) > 6)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID)
require.NoError(t, err)
}
// Should get secrets now, but only for the one app.
//nolint:gocritic // OAauth2 app management requires owner permission.
secrets, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
require.NoError(t, err)
require.Len(t, secrets, 5)
for _, secret := range secrets {
require.Len(t, secret.ClientSecretTruncated, 6)
}
// Should be able to delete a secret.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, secrets[0].ID)
require.NoError(t, err)
secrets, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
require.NoError(t, err)
require.Len(t, secrets, 4)
// No secrets once the app is deleted.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderApp(ctx, apps.Default.ID)
require.NoError(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID)
require.Error(t, err)
})
}
func TestOAuth2ProviderTokenExchange(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
ownerClient := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
})
owner := coderdtest.CreateFirstUser(t, ownerClient)
topCtx := testutil.Context(t, testutil.WaitLong)
apps := generateApps(topCtx, t, ownerClient, "token-exchange")
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := ownerClient.PostOAuth2ProviderAppSecret(topCtx, apps.Default.ID)
require.NoError(t, err)
// The typical oauth2 flow from this point is:
// Create an oauth2.Config using the id, secret, endpoints, and redirect:
// cfg := oauth2.Config{ ... }
// Display url for the user to click:
// userClickURL := cfg.AuthCodeURL("random_state")
// userClickURL looks like: https://idp url/authorize?
// client_id=...
// response_type=code
// redirect_uri=.. (back to backstage url) ..
// scope=...
// state=...
// *1* User clicks "Allow" on provided page above
// The redirect_uri is followed which sends back to backstage with the code and state
// Now backstage has the info to do a cfg.Exchange() in the back to get an access token.
//
// ---NOTE---: If the user has already approved this oauth app, then *1* is optional.
// Coder can just immediately redirect back to backstage without user intervention.
tests := []struct {
name string
app codersdk.OAuth2ProviderApp
// The flow is setup(ctx, client, user) -> preAuth(cfg) -> cfg.AuthCodeURL() -> preToken(cfg) -> cfg.Exchange()
setup func(context.Context, *codersdk.Client, codersdk.User) error
preAuth func(valid *oauth2.Config)
authError string
preToken func(valid *oauth2.Config)
tokenError string
// If null, assume the code should be valid.
defaultCode *string
// custom allows some more advanced manipulation of the oauth2 exchange.
exchangeMutate []oauth2.AuthCodeOption
}{
{
name: "AuthInParams",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
valid.Endpoint.AuthStyle = oauth2.AuthStyleInParams
},
},
{
name: "AuthInvalidAppID",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
valid.ClientID = uuid.NewString()
},
authError: "Resource not found",
},
{
name: "TokenInvalidAppID",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientID = uuid.NewString()
},
tokenError: "Resource not found",
},
{
name: "InvalidPort",
app: apps.NoPort,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Host = newURL.Hostname() + ":8081"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
name: "WrongAppHost",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
valid.RedirectURL = apps.NoPort.CallbackURL
},
authError: "Invalid query params",
},
{
name: "InvalidHostPrefix",
app: apps.NoPort,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Host = "prefix" + newURL.Hostname()
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
name: "InvalidHost",
app: apps.NoPort,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Host = "invalid"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
name: "InvalidHostAndPort",
app: apps.NoPort,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Host = "invalid:8080"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
name: "InvalidPath",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Path = path.Join("/prepend", newURL.Path)
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
name: "MissingPath",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Path = "/"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
// TODO: This is valid for now, but should it be?
name: "DifferentProtocol",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Scheme = "https"
valid.RedirectURL = newURL.String()
},
},
{
name: "NestedPath",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Path = path.Join(newURL.Path, "nested")
valid.RedirectURL = newURL.String()
},
},
{
// Some oauth implementations allow this, but our users can host
// at subdomains. So we should not.
name: "Subdomain",
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Host = "sub." + newURL.Host
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
},
{
name: "NoSecretScheme",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "1234_4321"
},
tokenError: "Invalid client secret",
},
{
name: "InvalidSecretScheme",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "notcoder_1234_4321"
},
tokenError: "Invalid client secret",
},
{
name: "MissingSecretSecret",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "coder_1234"
},
tokenError: "Invalid client secret",
},
{
name: "MissingSecretPrefix",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "coder__1234"
},
tokenError: "Invalid client secret",
},
{
name: "InvalidSecretPrefix",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "coder_1234_4321"
},
tokenError: "Invalid client secret",
},
{
name: "MissingSecret",
app: apps.Default,
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = ""
},
tokenError: "Invalid query params",
},
{
name: "NoCodeScheme",
app: apps.Default,
defaultCode: ptr.Ref("1234_4321"),
tokenError: "Invalid code",
},
{
name: "InvalidCodeScheme",
app: apps.Default,
defaultCode: ptr.Ref("notcoder_1234_4321"),
tokenError: "Invalid code",
},
{
name: "MissingCodeSecret",
app: apps.Default,
defaultCode: ptr.Ref("coder_1234"),
tokenError: "Invalid code",
},
{
name: "MissingCodePrefix",
app: apps.Default,
defaultCode: ptr.Ref("coder__1234"),
tokenError: "Invalid code",
},
{
name: "InvalidCodePrefix",
app: apps.Default,
defaultCode: ptr.Ref("coder_1234_4321"),
tokenError: "Invalid code",
},
{
name: "MissingCode",
app: apps.Default,
defaultCode: ptr.Ref(""),
tokenError: "Invalid query params",
},
{
name: "InvalidGrantType",
app: apps.Default,
tokenError: "Invalid query params",
exchangeMutate: []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("grant_type", "foobar"),
},
},
{
name: "EmptyGrantType",
app: apps.Default,
tokenError: "Invalid query params",
exchangeMutate: []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("grant_type", ""),
},
},
{
name: "ExpiredCode",
app: apps.Default,
defaultCode: ptr.Ref("coder_prefix_code"),
tokenError: "Invalid code",
setup: func(ctx context.Context, client *codersdk.Client, user codersdk.User) error {
// Insert an expired code.
hashedCode, err := userpassword.Hash("prefix_code")
if err != nil {
return err
}
_, err = db.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
ID: uuid.New(),
CreatedAt: dbtime.Now().Add(-time.Minute * 11),
ExpiresAt: dbtime.Now().Add(-time.Minute),
SecretPrefix: []byte("prefix"),
HashedSecret: []byte(hashedCode),
AppID: apps.Default.ID,
UserID: user.ID,
})
return err
},
},
{
name: "OK",
app: apps.Default,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Each test gets its own user, since we allow only one code per user and
// app at a time and running tests in parallel could clobber each other.
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
if test.setup != nil {
err := test.setup(ctx, userClient, user)
require.NoError(t, err)
}
// Each test gets its own oauth2.Config so they can run in parallel.
// In practice, you would only use 1 as a singleton.
valid := &oauth2.Config{
ClientID: test.app.ID.String(),
ClientSecret: secret.ClientSecretFull,
Endpoint: oauth2.Endpoint{
AuthURL: test.app.Endpoints.Authorization,
DeviceAuthURL: test.app.Endpoints.DeviceAuth,
TokenURL: test.app.Endpoints.Token,
// TODO: @emyrk we should support both types.
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: test.app.CallbackURL,
Scopes: []string{},
}
if test.preAuth != nil {
test.preAuth(valid)
}
var code string
if test.defaultCode != nil {
code = *test.defaultCode
} else {
var err error
code, err = authorizationFlow(ctx, userClient, valid)
if test.authError != "" {
require.Error(t, err)
require.ErrorContains(t, err, test.authError)
// If this errors the token exchange will fail. So end here.
return
}
require.NoError(t, err)
}
// Mutate the valid config for the exchange.
if test.preToken != nil {
test.preToken(valid)
}
// Do the actual exchange.
token, err := valid.Exchange(ctx, code, test.exchangeMutate...)
if test.tokenError != "" {
require.Error(t, err)
require.ErrorContains(t, err, test.tokenError)
} else {
require.NoError(t, err)
require.NotEmpty(t, token.AccessToken)
require.True(t, time.Now().After(token.Expiry))
// Check that the token works.
newClient := codersdk.New(userClient.URL)
newClient.SetSessionToken(token.AccessToken)
gotUser, err := newClient.User(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, user.ID, gotUser.ID)
}
})
}
}
func TestOAuth2ProviderTokenRefresh(t *testing.T) {
t.Parallel()
topCtx := testutil.Context(t, testutil.WaitLong)
db, pubsub := dbtestutil.NewDB(t)
ownerClient := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
})
owner := coderdtest.CreateFirstUser(t, ownerClient)
apps := generateApps(topCtx, t, ownerClient, "token-refresh")
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := ownerClient.PostOAuth2ProviderAppSecret(topCtx, apps.Default.ID)
require.NoError(t, err)
// One path not tested here is when the token is empty, because Go's OAuth2
// client library will not even try to make the request.
tests := []struct {
name string
app codersdk.OAuth2ProviderApp
// If null, assume the token should be valid.
defaultToken *string
error string
expires time.Time
}{
{
name: "NoTokenScheme",
app: apps.Default,
defaultToken: ptr.Ref("1234_4321"),
error: "Invalid token",
},
{
name: "InvalidTokenScheme",
app: apps.Default,
defaultToken: ptr.Ref("notcoder_1234_4321"),
error: "Invalid token",
},
{
name: "MissingTokenSecret",
app: apps.Default,
defaultToken: ptr.Ref("coder_1234"),
error: "Invalid token",
},
{
name: "MissingTokenPrefix",
app: apps.Default,
defaultToken: ptr.Ref("coder__1234"),
error: "Invalid token",
},
{
name: "InvalidTokenPrefix",
app: apps.Default,
defaultToken: ptr.Ref("coder_1234_4321"),
error: "Invalid token",
},
{
name: "Expired",
app: apps.Default,
expires: time.Now().Add(time.Minute * -1),
error: "Invalid token",
},
{
name: "OK",
app: apps.Default,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Insert the token and its key.
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
UserID: user.ID,
LoginType: database.LoginTypeOAuth2ProviderApp,
ExpiresAt: time.Now().Add(time.Hour * 10),
})
require.NoError(t, err)
newKey, err := db.InsertAPIKey(ctx, key)
require.NoError(t, err)
token, err := identityprovider.GenerateSecret()
require.NoError(t, err)
expires := test.expires
if expires.IsZero() {
expires = time.Now().Add(time.Hour * 10)
}
_, err = db.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
ExpiresAt: expires,
HashPrefix: []byte(token.Prefix),
RefreshHash: []byte(token.Hashed),
AppSecretID: secret.ID,
APIKeyID: newKey.ID,
})
require.NoError(t, err)
// Check that the key works.
newClient := codersdk.New(userClient.URL)
newClient.SetSessionToken(sessionToken)
gotUser, err := newClient.User(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, user.ID, gotUser.ID)
cfg := &oauth2.Config{
ClientID: test.app.ID.String(),
ClientSecret: secret.ClientSecretFull,
Endpoint: oauth2.Endpoint{
AuthURL: test.app.Endpoints.Authorization,
DeviceAuthURL: test.app.Endpoints.DeviceAuth,
TokenURL: test.app.Endpoints.Token,
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: test.app.CallbackURL,
Scopes: []string{},
}
// Test whether it can be refreshed.
refreshToken := token.Formatted
if test.defaultToken != nil {
refreshToken = *test.defaultToken
}
refreshed, err := cfg.TokenSource(ctx, &oauth2.Token{
AccessToken: sessionToken,
RefreshToken: refreshToken,
Expiry: time.Now().Add(time.Minute * -1),
}).Token()
if test.error != "" {
require.Error(t, err)
require.ErrorContains(t, err, test.error)
} else {
require.NoError(t, err)
require.NotEmpty(t, refreshed.AccessToken)
// Old token is now invalid.
_, err = newClient.User(ctx, codersdk.Me)
require.Error(t, err)
require.ErrorContains(t, err, "401")
// Refresh token is valid.
newClient := codersdk.New(userClient.URL)
newClient.SetSessionToken(refreshed.AccessToken)
gotUser, err := newClient.User(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, user.ID, gotUser.ID)
}
})
}
}
type exchangeSetup struct {
cfg *oauth2.Config
app codersdk.OAuth2ProviderApp
secret codersdk.OAuth2ProviderAppSecretFull
code string
}
func TestOAuth2ProviderRevoke(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
tests := []struct {
name string
// fn performs some action that removes the user's code and token.
fn func(context.Context, *codersdk.Client, exchangeSetup)
// replacesToken specifies whether the action replaces the token or only
// deletes it.
replacesToken bool
}{
{
name: "DeleteApp",
fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) {
//nolint:gocritic // OAauth2 app management requires owner permission.
err := client.DeleteOAuth2ProviderApp(ctx, s.app.ID)
require.NoError(t, err)
},
},
{
name: "DeleteSecret",
fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) {
//nolint:gocritic // OAauth2 app management requires owner permission.
err := client.DeleteOAuth2ProviderAppSecret(ctx, s.app.ID, s.secret.ID)
require.NoError(t, err)
},
},
{
name: "DeleteToken",
fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID)
require.NoError(t, err)
},
},
{
name: "OverrideCodeAndToken",
fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) {
// Generating a new code should wipe out the old code.
code, err := authorizationFlow(ctx, client, s.cfg)
require.NoError(t, err)
// Generating a new token should wipe out the old token.
_, err = s.cfg.Exchange(ctx, code)
require.NoError(t, err)
},
replacesToken: true,
},
}
setup := func(ctx context.Context, testClient *codersdk.Client, name string) exchangeSetup {
// We need a new app each time because we only allow one code and token per
// app and user at the moment and because the test might delete the app.
//nolint:gocritic // OAauth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: name,
CallbackURL: "http://localhost",
})
require.NoError(t, err)
// We need a new secret every time because the test might delete the secret.
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err)
cfg := &oauth2.Config{
ClientID: app.ID.String(),
ClientSecret: secret.ClientSecretFull,
Endpoint: oauth2.Endpoint{
AuthURL: app.Endpoints.Authorization,
DeviceAuthURL: app.Endpoints.DeviceAuth,
TokenURL: app.Endpoints.Token,
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: app.CallbackURL,
Scopes: []string{},
}
// Go through the auth flow to get a code.
code, err := authorizationFlow(ctx, testClient, cfg)
require.NoError(t, err)
return exchangeSetup{
cfg: cfg,
app: app,
secret: secret,
code: code,
}
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
testClient, testUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
testEntities := setup(ctx, testClient, test.name+"-1")
// Delete before the exchange completes (code should delete and attempting
// to finish the exchange should fail).
test.fn(ctx, testClient, testEntities)
// Exchange should fail because the code should be gone.
_, err := testEntities.cfg.Exchange(ctx, testEntities.code)
require.Error(t, err)
// Try again, this time letting the exchange complete first.
testEntities = setup(ctx, testClient, test.name+"-2")
token, err := testEntities.cfg.Exchange(ctx, testEntities.code)
require.NoError(t, err)
// Validate the returned access token and that the app is listed.
newClient := codersdk.New(client.URL)
newClient.SetSessionToken(token.AccessToken)
gotUser, err := newClient.User(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, testUser.ID, gotUser.ID)
filter := codersdk.OAuth2ProviderAppFilter{UserID: testUser.ID}
apps, err := testClient.OAuth2ProviderApps(ctx, filter)
require.NoError(t, err)
require.Contains(t, apps, testEntities.app)
// Should not show up for another user.
apps, err = client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{UserID: owner.UserID})
require.NoError(t, err)
require.Len(t, apps, 0)
// Perform the deletion.
test.fn(ctx, testClient, testEntities)
// App should no longer show up for the user unless it was replaced.
if !test.replacesToken {
apps, err = testClient.OAuth2ProviderApps(ctx, filter)
require.NoError(t, err)
require.NotContains(t, apps, testEntities.app, fmt.Sprintf("contains %q", testEntities.app.Name))
}
// The token should no longer be valid.
_, err = newClient.User(ctx, codersdk.Me)
require.Error(t, err)
require.ErrorContains(t, err, "401")
})
}
}
type provisionedApps struct {
Default codersdk.OAuth2ProviderApp
NoPort codersdk.OAuth2ProviderApp
Subdomain codersdk.OAuth2ProviderApp
// For sorting purposes these are included. You will likely never touch them.
Extra []codersdk.OAuth2ProviderApp
}
func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, suffix string) provisionedApps {
create := func(name, callback string) codersdk.OAuth2ProviderApp {
name = fmt.Sprintf("%s-%s", name, suffix)
//nolint:gocritic // OAauth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: name,
CallbackURL: callback,
Icon: "",
})
require.NoError(t, err)
require.Equal(t, name, app.Name)
require.Equal(t, callback, app.CallbackURL)
return app
}
return provisionedApps{
Default: create("razzle-dazzle-a", "http://localhost1:8080/foo/bar"),
NoPort: create("razzle-dazzle-b", "http://localhost2"),
Subdomain: create("razzle-dazzle-z", "http://30.localhost:3000"),
Extra: []codersdk.OAuth2ProviderApp{
create("second-to-last", "http://20.localhost:3000"),
create("woo-10", "http://10.localhost:3000"),
},
}
}
func authorizationFlow(ctx context.Context, client *codersdk.Client, cfg *oauth2.Config) (string, error) {
state := uuid.NewString()
return oidctest.OAuth2GetCode(
cfg.AuthCodeURL(state),
func(req *http.Request) (*http.Response, error) {
// TODO: Would be better if client had a .Do() method.
// TODO: Is this the best way to handle redirects?
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return client.Request(ctx, req.Method, req.URL.String(), nil, func(req *http.Request) {
// Set the referer so the request bypasses the HTML page (normally you
// have to click "allow" first, and the way we detect that is using the
// referer header).
req.Header.Set("Referer", req.URL.String())
})
},
)
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}