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

Skip to content

chore: ticket provider interface #6915

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ type API struct {
metricsCache *metricscache.Cache
workspaceAgentCache *wsconncache.Cache
updateChecker *updatecheck.Checker
WorkspaceAppsProvider *workspaceapps.Provider
WorkspaceAppsProvider *workspaceapps.DBTicketProvider

// Experiments contains the list of experiments currently enabled.
// This is used to gate features that are not yet ready for production.
Expand Down
124 changes: 71 additions & 53 deletions coderd/workspaceapps/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,9 @@ const (
RedirectURIQueryParam = "redirect_uri"
)

// ResolveRequest takes an app request, checks if it's valid and authenticated,
// and returns a ticket with details about the app.
//
// The ticket is written as a signed JWT into a cookie and will be automatically
// used in the next request to the same app to avoid database calls.
//
// Upstream code should avoid any database calls ever.
func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appReq Request) (*Ticket, bool) {
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
// // dangerousSystemCtx is only used for database calls. The actual authentication
// // logic is handled in Provider.authorizeWorkspaceApp which directly checks the actor's
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(r.Context())
err := appReq.Validate()
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "invalid app request")
return nil, false
}

// TODO: remove this temporary shim
func (p *DBTicketProvider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appReq Request) (*Ticket, bool) {
// TODO: this needs to be some sort of normalize function or something
if appReq.WorkspaceAndAgent != "" {
// workspace.agent
workspaceAndAgent := strings.SplitN(appReq.WorkspaceAndAgent, ".", 2)
Expand All @@ -66,23 +49,68 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
}
}

ticket, ok := p.TicketFromRequest(r)
if ok && ticket.MatchesRequest(appReq) {
// The request has a valid ticket and it matches the request.
return ticket, true
}

ticket, ticketStr, ok := p.CreateTicket(r.Context(), rw, r, appReq)
if !ok {
return nil, false
}

// Write the ticket cookie. We always want this to apply to the current
// hostname (even for subdomain apps, without any wildcard shenanigans,
// because the ticket is only valid for a single app).
http.SetCookie(rw, &http.Cookie{
Name: codersdk.DevURLSessionTicketCookie,
Value: ticketStr,
Path: appReq.BasePath,
Expires: ticket.Expiry,
})

return ticket, true
}

func (p *DBTicketProvider) TicketFromRequest(r *http.Request) (*Ticket, bool) {
// Get the existing ticket from the request.
ticketCookie, err := r.Cookie(codersdk.DevURLSessionTicketCookie)
if err == nil {
ticket, err := p.ParseTicket(ticketCookie.Value)
if err == nil {
err := ticket.Request.Validate()
if err == nil && ticket.MatchesRequest(appReq) {
if err == nil {
// The request has a ticket, which is a valid ticket signed by
// us, and matches the app that the user was trying to access.
// us. The caller must check that it matches the request.
return &ticket, true
}
}
}

// There's no ticket or it's invalid, so we need to check auth using the
// session token, validate auth and access to the app, then generate a new
// ticket.
return nil, false
}

// ResolveRequest takes an app request, checks if it's valid and authenticated,
// and returns a ticket with details about the app.
//
// The ticket is written as a signed JWT into a cookie and will be automatically
// used in the next request to the same app to avoid database calls.
//
// Upstream code should avoid any database calls ever.
func (p *DBTicketProvider) CreateTicket(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*Ticket, string, bool) {
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
// // dangerousSystemCtx is only used for database calls. The actual authentication
// // logic is handled in Provider.authorizeWorkspaceApp which directly checks the actor's
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
err := appReq.Validate()
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "invalid app request")
return nil, "", false
}

ticket := Ticket{
Request: appReq,
}
Expand All @@ -101,17 +129,17 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
Optional: true,
})
if !ok {
return nil, false
return nil, "", false
}

// Lookup workspace app details from DB.
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
if xerrors.Is(err, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, &appReq, err.Error())
return nil, false
return nil, "", false
} else if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get app details from database")
return nil, false
return nil, "", false
}
ticket.UserID = dbReq.User.ID
ticket.WorkspaceID = dbReq.Workspace.ID
Expand All @@ -124,19 +152,20 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
// Verify the user has access to the app.
authed, ok := p.verifyAuthz(rw, r, authz, dbReq)
if !ok {
return nil, false
return nil, "", false
}
if !authed {
if apiKey != nil {
// The request has a valid API key but insufficient permissions.
p.writeWorkspaceApp404(rw, r, &appReq, "insufficient permissions")
return nil, false
return nil, "", false
}

// Redirect to login as they don't have permission to access the app
// and they aren't signed in.
switch appReq.AccessMethod {
case AccessMethodPath:
// TODO(@deansheather): this doesn't work on moons
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
case AccessMethodSubdomain:
// Redirect to the app auth redirect endpoint with a valid redirect
Expand All @@ -156,52 +185,41 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
// Return an error.
httpapi.ResourceNotFound(rw)
}
return nil, false
return nil, "", false
}

// Check that the agent is online.
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
p.writeWorkspaceAppOffline(rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
return nil, false
return nil, "", false
}

// Check that the app is healthy.
if dbReq.AppHealth != "" && dbReq.AppHealth != database.WorkspaceAppHealthDisabled && dbReq.AppHealth != database.WorkspaceAppHealthHealthy {
p.writeWorkspaceAppOffline(rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
return nil, false
return nil, "", false
}

// As a sanity check, ensure the ticket we just made is valid for this
// request.
if !ticket.MatchesRequest(appReq) {
p.writeWorkspaceApp500(rw, r, &appReq, nil, "fresh ticket does not match request")
return nil, false
return nil, "", false
}

// Sign the ticket.
ticketExpiry := time.Now().Add(TicketExpiry)
ticket.Expiry = ticketExpiry.Unix()
ticket.Expiry = time.Now().Add(TicketExpiry)
ticketStr, err := p.GenerateTicket(ticket)
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "generate ticket")
return nil, false
return nil, "", false
}

// Write the ticket cookie. We always want this to apply to the current
// hostname (even for subdomain apps, without any wildcard shenanigans,
// because the ticket is only valid for a single app).
http.SetCookie(rw, &http.Cookie{
Name: codersdk.DevURLSessionTicketCookie,
Value: ticketStr,
Path: appReq.BasePath,
Expires: ticketExpiry,
})

return &ticket, true
return &ticket, ticketStr, true
}

func (p *Provider) authorizeRequest(ctx context.Context, roles *httpmw.Authorization, dbReq *databaseRequest) (bool, error) {
func (p *DBTicketProvider) authorizeRequest(ctx context.Context, roles *httpmw.Authorization, dbReq *databaseRequest) (bool, error) {
accessMethod := dbReq.AccessMethod
if accessMethod == "" {
accessMethod = AccessMethodPath
Expand Down Expand Up @@ -293,7 +311,7 @@ func (p *Provider) authorizeRequest(ctx context.Context, roles *httpmw.Authoriza
// given app share level in the given workspace. The user's authorization status
// is returned. If a server error occurs, a HTML error page is rendered and
// false is returned so the caller can return early.
func (p *Provider) verifyAuthz(rw http.ResponseWriter, r *http.Request, authz *httpmw.Authorization, dbReq *databaseRequest) (authed bool, ok bool) {
func (p *DBTicketProvider) verifyAuthz(rw http.ResponseWriter, r *http.Request, authz *httpmw.Authorization, dbReq *databaseRequest) (authed bool, ok bool) {
ok, err := p.authorizeRequest(r.Context(), authz, dbReq)
if err != nil {
p.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err))
Expand All @@ -312,7 +330,7 @@ func (p *Provider) verifyAuthz(rw http.ResponseWriter, r *http.Request, authz *h

// writeWorkspaceApp404 writes a HTML 404 error page for a workspace app. If
// appReq is not nil, it will be used to log the request details at debug level.
func (p *Provider) writeWorkspaceApp404(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
func (p *DBTicketProvider) writeWorkspaceApp404(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
if appReq != nil {
slog.Helper()
p.Logger.Debug(r.Context(),
Expand All @@ -336,7 +354,7 @@ func (p *Provider) writeWorkspaceApp404(rw http.ResponseWriter, r *http.Request,

// writeWorkspaceApp500 writes a HTML 500 error page for a workspace app. If
// appReq is not nil, it's fields will be added to the logged error message.
func (p *Provider) writeWorkspaceApp500(rw http.ResponseWriter, r *http.Request, appReq *Request, err error, msg string) {
func (p *DBTicketProvider) writeWorkspaceApp500(rw http.ResponseWriter, r *http.Request, appReq *Request, err error, msg string) {
slog.Helper()
ctx := r.Context()
if appReq != nil {
Expand Down Expand Up @@ -364,7 +382,7 @@ func (p *Provider) writeWorkspaceApp500(rw http.ResponseWriter, r *http.Request,

// writeWorkspaceAppOffline writes a HTML 502 error page for a workspace app. If
// appReq is not nil, it will be used to log the request details at debug level.
func (p *Provider) writeWorkspaceAppOffline(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
func (p *DBTicketProvider) writeWorkspaceAppOffline(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
if appReq != nil {
slog.Helper()
p.Logger.Debug(r.Context(),
Expand Down
10 changes: 8 additions & 2 deletions coderd/workspaceapps/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func Test_ResolveRequest(t *testing.T) {
AppURL: appURL,
}, ticket)
require.NotZero(t, ticket.Expiry)
require.InDelta(t, time.Now().Add(workspaceapps.TicketExpiry).Unix(), ticket.Expiry, time.Minute.Seconds())
require.WithinDuration(t, time.Now().Add(workspaceapps.TicketExpiry), ticket.Expiry, time.Minute)

// Check that the ticket was set in the response and is valid.
require.Len(t, w.Cookies(), 1)
Expand All @@ -265,6 +265,9 @@ func Test_ResolveRequest(t *testing.T) {

parsedTicket, err := api.WorkspaceAppsProvider.ParseTicket(cookie.Value)
require.NoError(t, err)
// normalize expiry
require.WithinDuration(t, ticket.Expiry, parsedTicket.Expiry, 2*time.Second)
parsedTicket.Expiry = ticket.Expiry
require.Equal(t, ticket, &parsedTicket)

// Try resolving the request with the ticket only.
Expand All @@ -274,6 +277,9 @@ func Test_ResolveRequest(t *testing.T) {

secondTicket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.True(t, ok)
// normalize expiry
require.WithinDuration(t, ticket.Expiry, secondTicket.Expiry, 2*time.Second)
secondTicket.Expiry = ticket.Expiry
require.Equal(t, ticket, secondTicket)
}
})
Expand Down Expand Up @@ -470,7 +476,7 @@ func Test_ResolveRequest(t *testing.T) {
// App name differs
AppSlugOrPort: appNamePublic,
},
Expiry: time.Now().Add(time.Minute).Unix(),
Expiry: time.Now().Add(time.Minute),
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
Expand Down
51 changes: 45 additions & 6 deletions coderd/workspaceapps/provider.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package workspaceapps

import (
"context"
"net/http"
"net/url"
"time"

Expand All @@ -11,10 +13,45 @@ import (
"github.com/coder/coder/codersdk"
)

// Provider provides authentication and authorization for workspace apps.
// TODO(@deansheather): also provide workspace apps as a whole to remove all app
// code from coderd.
type Provider struct {
/*
POST /api/v2/moons/app-auth-ticket

{
"session_token": "xxxx",
"request": { ... }
}

type moonRes struct {
Ticket *Ticket
TicketStr string
}
*/

// TicketProvider provides workspace app tickets.
//
// write a funny comment that says a ridiculous amount of fees will be incurred:
//
// Please keep in mind that all transactions incur a service fee, handling fee,
// order processing fee, delivery fee,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might rename this from TicketProvider to something like RefreshingTokenProvider or something. Just because we already use the token wording.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call the session token a token and we already call the ticket a ticket. I'd rather use a different term than token to avoid people thinking it's the same thing as a session token.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also invisible to the end user so the naming of it doesn't matter that much. I like TicketProvider more than RefreshingTokenProvider

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is really a fast-expiring token, so I'm just trying to reduce the number of terms people need to consume to understand what's going on.

I'm proposing renaming Ticket to something like SignedToken (or something else token related, so that it doesn't confuse people).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignedToken is a valid name imo.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we do SignedExpiringToken to make it verbose but super obvious!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignedExpiringToken isn't obvious because it doesn't say what it's for. SignedAppToken would be better

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to SignedToken and the cookie says coder_devurl_signed_app_token.

type TicketProvider interface {
// TicketFromRequest returns a ticket from the request. If the request does
// not contain a ticket or the ticket is invalid (expired, invalid
// signature, etc.), it returns false.
TicketFromRequest(r *http.Request) (*Ticket, bool)
// CreateTicket creates a ticket for the given app request. It uses the
// long-lived session token in the HTTP request to authenticate the user.
// The ticket is returned in struct and string form. The string form should
// be written as a cookie.
//
// If the request is invalid or the user is not authorized to access the
// app, false is returned. An error page is written to the response writer
// in this case.
CreateTicket(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*Ticket, string, bool)
}

// DBTicketProvider provides authentication and authorization for workspace apps
// by querying the database if the request is missing a valid ticket.
type DBTicketProvider struct {
Logger slog.Logger

AccessURL *url.URL
Expand All @@ -26,7 +63,9 @@ type Provider struct {
TicketSigningKey []byte
}

func New(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, ticketSigningKey []byte) *Provider {
var _ TicketProvider = &DBTicketProvider{}

func New(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, ticketSigningKey []byte) *DBTicketProvider {
if len(ticketSigningKey) != 64 {
panic("ticket signing key must be 64 bytes")
}
Expand All @@ -35,7 +74,7 @@ func New(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database
workspaceAgentInactiveTimeout = 1 * time.Minute
}

return &Provider{
return &DBTicketProvider{
Logger: log,
AccessURL: accessURL,
Authorizer: authz,
Expand Down
Loading