Skip to content
Merged
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
91 changes: 91 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,94 @@ packages:
pkgname: authkitmocks
dir: mocks/authkit
filename: authorizer.go
PrincipalCreator:
config:
template: testify
structname: PrincipalCreator
pkgname: authkitmocks
dir: mocks/authkit
filename: principal_creator.go
PrincipalLister:
config:
template: testify
structname: PrincipalLister
pkgname: authkitmocks
dir: mocks/authkit
filename: principal_lister.go
RoleCreator:
config:
template: testify
structname: RoleCreator
pkgname: authkitmocks
dir: mocks/authkit
filename: role_creator.go
RoleActionGranter:
config:
template: testify
structname: RoleActionGranter
pkgname: authkitmocks
dir: mocks/authkit
filename: role_action_granter.go
PrincipalRoleAssigner:
config:
template: testify
structname: PrincipalRoleAssigner
pkgname: authkitmocks
dir: mocks/authkit
filename: principal_role_assigner.go
PrincipalRoleUnassigner:
config:
template: testify
structname: PrincipalRoleUnassigner
pkgname: authkitmocks
dir: mocks/authkit
filename: principal_role_unassigner.go
PrincipalRoleAssignmentLister:
config:
template: testify
structname: PrincipalRoleAssignmentLister
pkgname: authkitmocks
dir: mocks/authkit
filename: principal_role_assignment_lister.go
ProvisioningRuleCreator:
config:
template: testify
structname: ProvisioningRuleCreator
pkgname: authkitmocks
dir: mocks/authkit
filename: provisioning_rule_creator.go
ProvisioningRuleUpdater:
config:
template: testify
structname: ProvisioningRuleUpdater
pkgname: authkitmocks
dir: mocks/authkit
filename: provisioning_rule_updater.go
ProvisioningRuleDeleter:
config:
template: testify
structname: ProvisioningRuleDeleter
pkgname: authkitmocks
dir: mocks/authkit
filename: provisioning_rule_deleter.go
ProvisioningRuleFinder:
config:
template: testify
structname: ProvisioningRuleFinder
pkgname: authkitmocks
dir: mocks/authkit
filename: provisioning_rule_finder.go
ProvisioningRuleLister:
config:
template: testify
structname: ProvisioningRuleLister
pkgname: authkitmocks
dir: mocks/authkit
filename: provisioning_rule_lister.go
IdentityLinker:
config:
template: testify
structname: IdentityLinker
pkgname: authkitmocks
dir: mocks/authkit
filename: identity_linker.go
110 changes: 110 additions & 0 deletions management/apitoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package management

import (
"context"
"errors"
"fmt"
"time"

"github.com/meigma/authkit/proof/apikey"
)

// APITokens issues and revokes opaque API tokens.
type APITokens interface {
// IssueToken issues an opaque API token for an existing principal.
IssueToken(ctx context.Context, req apikey.IssueRequest) (apikey.IssuedToken, error)

// RevokeToken revokes tokenID.
RevokeToken(ctx context.Context, tokenID string) error
}

// IssueAPITokenRequest describes a request to issue an API token.
type IssueAPITokenRequest struct {
// PrincipalID identifies the principal the token should authenticate as.
PrincipalID string

// Name is an optional human-readable token label.
Name string

// ExpiresAt is the time after which the token must no longer authenticate.
ExpiresAt time.Time
}

// IssuedAPIToken describes an issued API token.
type IssuedAPIToken struct {
// ID is the stable lookup identifier embedded in the token.
ID string

// PrincipalID identifies the principal the token authenticates as.
PrincipalID string

// Plaintext is the full token secret shown once to the caller.
Plaintext string

// ExpiresAt is the time after which the token must no longer authenticate.
ExpiresAt time.Time
}

// IssueAPIToken issues an API token for an existing principal.
func (s *Service) IssueAPIToken(ctx context.Context, req IssueAPITokenRequest) (IssuedAPIToken, error) {
if s.apiTokens == nil {
return IssuedAPIToken{}, errors.New("management: API tokens service is required")
}
if s.principalFinder == nil {
return IssuedAPIToken{}, errors.New("management: principal finder is required")
}

// Resolve the principal before delegating to the apikey service so we never mint a token bound
// to an identifier that does not exist; without this check, a typo or deleted principal would
// produce a token that authenticates to nothing.
principal, err := s.principalFinder.FindPrincipal(ctx, req.PrincipalID)
if err != nil {
return IssuedAPIToken{}, fmt.Errorf("management: find API token principal: %w", err)
}

issued, err := s.apiTokens.IssueToken(ctx, apikey.IssueRequest{
PrincipalID: principal.ID,
Name: req.Name,
ExpiresAt: req.ExpiresAt,
})
if err != nil {
return IssuedAPIToken{}, fmt.Errorf("management: issue API token: %w", err)
}

return IssuedAPIToken{
ID: issued.ID,
PrincipalID: principal.ID,
Plaintext: issued.Plaintext,
ExpiresAt: issued.ExpiresAt,
}, nil
}

// RevokeAPIToken revokes tokenID.
func (s *Service) RevokeAPIToken(ctx context.Context, tokenID string) error {
if s.apiTokens == nil {
return errors.New("management: API tokens service is required")
}

if err := s.apiTokens.RevokeToken(ctx, tokenID); err != nil {
return fmt.Errorf("management: revoke API token: %w", err)
}

return nil
}

// ListPrincipalAPITokenMetadata returns API-token metadata for a principal.
func (s *Service) ListPrincipalAPITokenMetadata(
ctx context.Context,
principalID string,
) ([]apikey.TokenMetadata, error) {
if s.apiTokenMetadataLister == nil {
return nil, errors.New("management: API token metadata lister is required")
}

tokens, err := s.apiTokenMetadataLister.ListPrincipalTokenMetadata(ctx, principalID)
if err != nil {
return nil, fmt.Errorf("management: list principal API token metadata: %w", err)
}

return tokens, nil
}
143 changes: 143 additions & 0 deletions management/apitoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package management_test

import (
"context"
"errors"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/meigma/authkit"
"github.com/meigma/authkit/management"
authkitmocks "github.com/meigma/authkit/mocks/authkit"
"github.com/meigma/authkit/proof/apikey"
)

func TestServiceIssueAPITokenIssuesForExistingPrincipal(t *testing.T) {
now := fixedTime()
expiresAt := now.Add(time.Hour)
apiTokens := newFakeAPITokens()
apiTokens.issued = apikey.IssuedToken{
ID: testTokenID,
Plaintext: testTokenSecret,
ExpiresAt: expiresAt,
}
principal := authkit.Principal{
ID: testPrincipalID,
Kind: authkit.PrincipalKindService,
DisplayName: testPrincipalName,
}
finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID).Return(principal, nil)
service := management.NewService(management.Options{
PrincipalFinder: finder,
APITokens: apiTokens,
})
req := management.IssueAPITokenRequest{
PrincipalID: testPrincipalID,
Name: testTokenName,
ExpiresAt: expiresAt,
}

issued, err := service.IssueAPIToken(context.Background(), req)

require.NoError(t, err)
assert.Equal(t, []apikey.IssueRequest{{
PrincipalID: testPrincipalID,
Name: testTokenName,
ExpiresAt: expiresAt,
}}, apiTokens.issueRequests)
assert.Equal(t, management.IssuedAPIToken{
ID: testTokenID,
PrincipalID: testPrincipalID,
Plaintext: testTokenSecret,
ExpiresAt: expiresAt,
}, issued)
}

func TestServiceIssueAPITokenRejectsMissingPrincipal(t *testing.T) {
apiTokens := newFakeAPITokens()
finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().
FindPrincipal(mock.Anything, testPrincipalID).
Return(authkit.Principal{}, authkit.ErrPrincipalNotFound)
service := management.NewService(management.Options{
PrincipalFinder: finder,
APITokens: apiTokens,
})

issued, err := service.IssueAPIToken(context.Background(), management.IssueAPITokenRequest{
PrincipalID: testPrincipalID,
ExpiresAt: fixedTime().Add(time.Hour),
})

require.ErrorIs(t, err, authkit.ErrPrincipalNotFound)
assert.Empty(t, issued)
assert.Empty(t, apiTokens.issueRequests)
}

func TestServiceIssueAPITokenReturnsIssueErrorWithoutLinkingIdentity(t *testing.T) {
issueErr := errors.New("issue failed")
apiTokens := newFakeAPITokens()
apiTokens.issueErr = issueErr
finder := authkitmocks.NewPrincipalFinder(t)
finder.EXPECT().
FindPrincipal(mock.Anything, testPrincipalID).
Return(authkit.Principal{ID: testPrincipalID}, nil)
service := management.NewService(management.Options{
PrincipalFinder: finder,
APITokens: apiTokens,
})

issued, err := service.IssueAPIToken(context.Background(), management.IssueAPITokenRequest{
PrincipalID: testPrincipalID,
ExpiresAt: fixedTime().Add(time.Hour),
})

require.ErrorIs(t, err, issueErr)
assert.Empty(t, issued)
assert.Empty(t, apiTokens.revokedIDs)
}

func TestServiceRevokeAPIToken(t *testing.T) {
apiTokens := newFakeAPITokens()
service := management.NewService(management.Options{APITokens: apiTokens})

err := service.RevokeAPIToken(context.Background(), testTokenID)

require.NoError(t, err)
assert.Equal(t, []string{testTokenID}, apiTokens.revokedIDs)
}

func TestServiceRevokeAPITokenReturnsError(t *testing.T) {
revokeErr := errors.New("revoke failed")
apiTokens := newFakeAPITokens()
apiTokens.revokeErr = revokeErr
service := management.NewService(management.Options{APITokens: apiTokens})

err := service.RevokeAPIToken(context.Background(), testTokenID)

require.ErrorIs(t, err, revokeErr)
}

func TestServiceListPrincipalAPITokenMetadata(t *testing.T) {
apiTokens := newFakeAPITokens()
apiTokens.metadata = []apikey.TokenMetadata{{
ID: testTokenID,
PrincipalID: testPrincipalID,
Name: testTokenName,
ExpiresAt: fixedTime().Add(time.Hour),
}}
service := management.NewService(management.Options{
APITokenMetadataLister: apiTokens,
})

tokens, err := service.ListPrincipalAPITokenMetadata(context.Background(), testPrincipalID)

require.NoError(t, err)
assert.Equal(t, apiTokens.metadata, tokens)
assert.Equal(t, []string{testPrincipalID}, apiTokens.metadataPrincipalIDs)
}
Loading