Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 27 additions & 4 deletions apps/api/internal/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@ type Service struct {
resetTokenStore *store.PasswordResetTokenStore
accountStore *store.AccountStore
apiTokenStore *store.ApiTokenStore
workspaceStore *store.WorkspaceStore // optional: enforces workspace-token scope
}

// SetWorkspaceStore wires the workspace store used to enforce that a
// workspace-scoped API token only reaches its own workspace. Optional.
func (s *Service) SetWorkspaceStore(ws *store.WorkspaceStore) { s.workspaceStore = ws }

func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service {
return &Service{userStore: userStore, sessionStore: sessionStore, resetTokenStore: resetTokenStore}
}
Expand Down Expand Up @@ -272,19 +277,37 @@ func (s *Service) ActiveUserByID(ctx context.Context, id uuid.UUID) (*model.User
// if the value isn't a recognized token (not an error — callers should fall
// back to other bearer interpretations).
func (s *Service) UserFromAPIToken(ctx context.Context, plain string) (*model.User, error) {
user, _, err := s.UserFromAPITokenScoped(ctx, plain)
return user, err
}

// UserFromAPITokenScoped resolves a bearer token to its user and also returns
// the token record, so callers can enforce a workspace-scoped token's scope.
func (s *Service) UserFromAPITokenScoped(ctx context.Context, plain string) (*model.User, *model.ApiToken, error) {
if s.apiTokenStore == nil {
return nil, nil
return nil, nil, nil
}
tok, err := s.apiTokenStore.GetActiveByHash(ctx, store.HashToken(plain))
if err != nil || tok == nil {
return nil, nil
return nil, nil, nil
}
user, err := s.ActiveUserByID(ctx, tok.UserID)
if err != nil || user == nil {
return nil, nil
return nil, nil, nil
}
_ = s.apiTokenStore.UpdateLastUsed(ctx, tok.ID)
return user, nil
return user, tok, nil
}

// SlugMatchesWorkspace reports whether the workspace addressed by slug is the
// given workspace id. Used to keep a workspace-scoped token inside its own
// workspace. Returns false if the store isn't wired or the slug is unknown.
func (s *Service) SlugMatchesWorkspace(ctx context.Context, slug string, workspaceID uuid.UUID) bool {
if s.workspaceStore == nil || slug == "" {
return false
}
w, err := s.workspaceStore.GetBySlug(ctx, slug)
return err == nil && w != nil && w.ID == workspaceID
}

func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error {
Expand Down
52 changes: 52 additions & 0 deletions apps/api/internal/handler/token_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package handler_test

import (
"context"
"net/http"
"testing"

"github.com/Devlaner/devlane/api/internal/store"
"github.com/Devlaner/devlane/api/internal/testutil"
"github.com/stretchr/testify/require"
)

func bearer(token string) http.Header {
h := http.Header{}
h.Set("Authorization", "Bearer "+token)
return h
}

// A workspace-scoped API token must only reach its own workspace's routes.
func TestWorkspaceTokenScope(t *testing.T) {
ts := testutil.NewTestServer(t)
db := ts.DB
ctx := context.Background()

user := testutil.CreateUser(t, db)
wsA := testutil.CreateWorkspace(t, db, user.ID)
wsB := testutil.CreateWorkspace(t, db, user.ID)

tokens := store.NewApiTokenStore(db)
wsToken, err := tokens.CreateForWorkspace(ctx, user.ID, wsA.ID, "ci", "", nil)
require.NoError(t, err)

// Allowed: its own workspace.
rr := ts.DoWithHeaders("GET", "/api/workspaces/"+wsA.Slug+"/", nil, bearer(wsToken))
require.Equal(t, http.StatusOK, rr.Code, "token should reach its own workspace")

// Denied: a different workspace the owner also belongs to.
rr = ts.DoWithHeaders("GET", "/api/workspaces/"+wsB.Slug+"/", nil, bearer(wsToken))
require.Equal(t, http.StatusForbidden, rr.Code, "token must not reach another workspace")

// Denied: a non-workspace route.
rr = ts.DoWithHeaders("GET", "/api/users/me/", nil, bearer(wsToken))
require.Equal(t, http.StatusForbidden, rr.Code, "workspace token must not reach /users/me")

// A personal token (no workspace scope) still works everywhere.
personal, err := tokens.Create(ctx, user.ID, "personal", "", nil)
require.NoError(t, err)
rr = ts.DoWithHeaders("GET", "/api/users/me/", nil, bearer(personal))
require.Equal(t, http.StatusOK, rr.Code, "personal token should still reach /users/me")
rr = ts.DoWithHeaders("GET", "/api/workspaces/"+wsB.Slug+"/", nil, bearer(personal))
require.Equal(t, http.StatusOK, rr.Code, "personal token should still reach any workspace")
}
10 changes: 9 additions & 1 deletion apps/api/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ func RequireAuth(authSvc *auth.Service, log *slog.Logger) gin.HandlerFunc {
}
if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") {
bearer := strings.TrimSpace(authHeader[7:])
if user, err := authSvc.UserFromAPIToken(ctx, bearer); err == nil && user != nil {
if user, tok, err := authSvc.UserFromAPITokenScoped(ctx, bearer); err == nil && user != nil {
// A workspace-scoped token may only reach its own workspace's
// routes. Deny anything else (other workspaces, /users/me,
// instance admin, minting more tokens).
if tok != nil && tok.WorkspaceID != nil &&
!authSvc.SlugMatchesWorkspace(ctx, c.Param("slug"), *tok.WorkspaceID) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Token is scoped to a different workspace"})
return
}
c.Set(UserContextKey, user)
c.Next()
return
Expand Down
1 change: 1 addition & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) {
authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore)
authSvc.SetAccountStore(accountStore)
authSvc.SetApiTokenStore(apiTokenStore)
authSvc.SetWorkspaceStore(workspaceStore)
accountSvc := service.NewAccountService(userStore, sessionStore, store.NewEmailChangeRequestStore(cfg.DB), cfg.MagicCodeSecret)
appBaseURL := cfg.AppBaseURL
if appBaseURL == "" {
Expand Down
Loading