mirror of
https://github.com/coder/coder.git
synced 2025-03-14 10:09:57 +00:00
1116 lines
33 KiB
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
|
|
}
|