auth/internal/ticket/usecase/ticket_ucase.go

176 lines
5.0 KiB
Go

package usecase
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"time"
json "github.com/goccy/go-json"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/httputil"
"source.toby3d.me/toby3d/auth/internal/ticket"
)
type (
//nolint:tagliatelle // https://indieauth.net/source/#access-token-response
AccessToken struct {
Me *domain.Me `json:"me"`
Profile *Profile `json:"profile,omitempty"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in,omitempty"`
}
Profile struct {
Email *domain.Email `json:"email,omitempty"`
Photo *domain.URL `json:"photo,omitempty"`
URL *domain.URL `json:"url,omitempty"`
Name string `json:"name,omitempty"`
}
ticketUseCase struct {
config *domain.Config
client *http.Client
tickets ticket.Repository
}
)
func NewTicketUseCase(tickets ticket.Repository, client *http.Client, config *domain.Config) ticket.UseCase {
return &ticketUseCase{
client: client,
tickets: tickets,
config: config,
}
}
func (useCase *ticketUseCase) Generate(ctx context.Context, tkt domain.Ticket) error {
resp, err := useCase.client.Get(tkt.Subject.String())
if err != nil {
return fmt.Errorf("cannot discovery ticket subject: %w", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("cannot read response body: %w", err)
}
buf := bytes.NewReader(body)
ticketEndpoint := new(url.URL)
// NOTE(toby3d): find metadata first
metadata, err := httputil.ExtractFromMetadata(useCase.client, tkt.Subject.String())
if err == nil && metadata != nil {
ticketEndpoint = metadata.TicketEndpoint
} else { // NOTE(toby3d): fallback to old links searching
endpoints := httputil.ExtractEndpoints(buf, tkt.Subject.URL(), resp.Header.Get(common.HeaderLink),
"ticket_endpoint")
if len(endpoints) > 0 {
ticketEndpoint = endpoints[len(endpoints)-1]
}
}
if ticketEndpoint == nil {
return ticket.ErrTicketEndpointNotExist
}
if err := useCase.tickets.Create(ctx, tkt); err != nil {
return fmt.Errorf("cannot save ticket in store: %w", err)
}
payload := make(url.Values)
payload.Set("ticket", tkt.Ticket)
payload.Set("subject", tkt.Subject.String())
payload.Set("resource", tkt.Resource.String())
if _, err = useCase.client.PostForm(ticketEndpoint.String(), payload); err != nil {
return fmt.Errorf("cannot send ticket to subject ticket_endpoint: %w", err)
}
return nil
}
func (useCase *ticketUseCase) Redeem(ctx context.Context, tkt domain.Ticket) (*domain.Token, error) {
resp, err := useCase.client.Get(tkt.Resource.String())
if err != nil {
return nil, fmt.Errorf("cannot discovery ticket resource: %w", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read response body: %w", err)
}
buf := bytes.NewReader(body)
tokenEndpoint := new(url.URL)
// NOTE(toby3d): find metadata first
metadata, err := httputil.ExtractFromMetadata(useCase.client, tkt.Resource.String())
if err == nil && metadata != nil {
tokenEndpoint = metadata.TokenEndpoint
} else { // NOTE(toby3d): fallback to old links searching
endpoints := httputil.ExtractEndpoints(buf, tkt.Resource, resp.Header.Get(common.HeaderLink),
"token_endpoint")
if len(endpoints) > 0 {
tokenEndpoint = endpoints[len(endpoints)-1]
}
}
if tokenEndpoint == nil || tokenEndpoint.String() == "" {
return nil, ticket.ErrTokenEndpointNotExist
}
payload := make(url.Values)
payload.Set("grant_type", domain.GrantTypeTicket.String())
payload.Set("ticket", tkt.Ticket)
resp, err = useCase.client.PostForm(tokenEndpoint.String(), payload)
if err != nil {
return nil, fmt.Errorf("cannot exchange ticket on token_endpoint: %w", err)
}
data := new(AccessToken)
if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return nil, fmt.Errorf("cannot unmarshal access token response: %w", err)
}
return &domain.Token{
CreatedAt: time.Now().UTC(),
Expiry: time.Unix(data.ExpiresIn, 0),
Scope: nil, // TODO(toby3d)
// TODO(toby3d): should this also include client_id?
// https://github.com/indieweb/indieauth/issues/85
ClientID: domain.ClientID{},
Me: *data.Me,
AccessToken: data.AccessToken,
RefreshToken: "", // TODO(toby3d)
}, nil
}
func (useCase *ticketUseCase) Exchange(ctx context.Context, ticket string) (*domain.Token, error) {
tkt, err := useCase.tickets.GetAndDelete(ctx, ticket)
if err != nil {
return nil, fmt.Errorf("cannot find provided ticket: %w", err)
}
token, err := domain.NewToken(domain.NewTokenOptions{
Expiration: useCase.config.JWT.Expiry,
Scope: domain.Scopes{domain.ScopeRead},
Issuer: domain.ClientID{},
Subject: *tkt.Subject,
Secret: []byte(useCase.config.JWT.Secret),
Algorithm: useCase.config.JWT.Algorithm,
NonceLength: useCase.config.JWT.NonceLength,
})
if err != nil {
return nil, fmt.Errorf("cannot generate a new access token: %w", err)
}
return token, nil
}