👽 Updated token endpoinds logic due IndieAuth spec changes
This commit is contained in:
parent
f7aa6f9995
commit
6594a532fc
|
@ -3,6 +3,7 @@ package usecase
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
http "github.com/valyala/fasthttp"
|
||||
|
@ -15,11 +16,19 @@ import (
|
|||
|
||||
type (
|
||||
//nolint: tagliatelle // https://indieauth.net/source/#access-token-response
|
||||
Response struct {
|
||||
Me *domain.Me `json:"me"`
|
||||
Scope domain.Scopes `json:"scope"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
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 {
|
||||
|
@ -126,18 +135,21 @@ func (useCase *ticketUseCase) Redeem(ctx context.Context, tkt *domain.Ticket) (*
|
|||
return nil, fmt.Errorf("cannot exchange ticket on token_endpoint: %w", err)
|
||||
}
|
||||
|
||||
data := new(Response)
|
||||
data := new(AccessToken)
|
||||
if err := json.Unmarshal(resp.Body(), data); err != nil {
|
||||
return nil, fmt.Errorf("cannot unmarshal access token response: %w", err)
|
||||
}
|
||||
|
||||
// TODO(toby3d): should this also include client_id?
|
||||
// https://github.com/indieweb/indieauth/issues/85
|
||||
return &domain.Token{
|
||||
ClientID: nil,
|
||||
AccessToken: data.AccessToken,
|
||||
Me: data.Me,
|
||||
Scope: data.Scope,
|
||||
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: nil,
|
||||
Me: data.Me,
|
||||
AccessToken: data.AccessToken,
|
||||
RefreshToken: "", // TODO(toby3d)
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -2,10 +2,11 @@ package http
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"path"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
http "github.com/valyala/fasthttp"
|
||||
|
||||
"source.toby3d.me/toby3d/form"
|
||||
|
@ -25,7 +26,22 @@ type (
|
|||
CodeVerifier string `form:"code_verifier"`
|
||||
}
|
||||
|
||||
TokenRevokeRequest struct {
|
||||
TokenRefreshRequest struct {
|
||||
GrantType domain.GrantType `form:"grant_type"` // refresh_token
|
||||
|
||||
// The refresh token previously offered to the client.
|
||||
RefreshToken string `form:"refresh_token"`
|
||||
|
||||
// The client ID that was used when the refresh token was issued.
|
||||
ClientID *domain.ClientID `form:"client_id"`
|
||||
|
||||
// The client may request a token with the same or fewer scopes
|
||||
// than the original access token. If omitted, is treated as
|
||||
// equal to the original scopes granted.
|
||||
Scope domain.Scopes `form:"scope,omitempty"`
|
||||
}
|
||||
|
||||
TokenRevocationRequest struct {
|
||||
Action domain.Action `form:"action"`
|
||||
Token string `form:"token"`
|
||||
}
|
||||
|
@ -35,39 +51,86 @@ type (
|
|||
Ticket string `form:"ticket"`
|
||||
}
|
||||
|
||||
TokenIntrospectRequest struct {
|
||||
Token string `form:"token"`
|
||||
}
|
||||
|
||||
//nolint: tagliatelle // https://indieauth.net/source/#access-token-response
|
||||
TokenExchangeResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope domain.Scopes `json:"scope"`
|
||||
Me *domain.Me `json:"me"`
|
||||
Profile *TokenProfileResponse `json:"profile,omitempty"`
|
||||
// The OAuth 2.0 Bearer Token RFC6750.
|
||||
AccessToken string `json:"access_token"`
|
||||
|
||||
// The canonical user profile URL for the user this access token
|
||||
// corresponds to.
|
||||
Me string `json:"me"`
|
||||
|
||||
// The user's profile information.
|
||||
Profile *TokenProfileResponse `json:"profile,omitempty"`
|
||||
|
||||
// The lifetime in seconds of the access token.
|
||||
ExpiresIn int64 `json:"expires_in,omitempty"`
|
||||
|
||||
// The refresh token, which can be used to obtain new access
|
||||
// tokens.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
TokenProfileResponse struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
URL *domain.URL `json:"url,omitempty"`
|
||||
Photo *domain.URL `json:"photo,omitempty"`
|
||||
Email *domain.Email `json:"email,omitempty"`
|
||||
// Name the user wishes to provide to the client.
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// URL of the user's website.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// A photo or image that the user wishes clients to use as a
|
||||
// profile image.
|
||||
Photo string `json:"photo,omitempty"`
|
||||
|
||||
// The email address a user wishes to provide to the client.
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
//nolint: tagliatelle // https://indieauth.net/source/#access-token-verification-response
|
||||
TokenVerificationResponse struct {
|
||||
Me *domain.Me `json:"me"`
|
||||
ClientID *domain.ClientID `json:"client_id"`
|
||||
Scope domain.Scopes `json:"scope"`
|
||||
TokenIntrospectResponse struct {
|
||||
// Boolean indicator of whether or not the presented token is
|
||||
// currently active.
|
||||
Active bool `json:"active"`
|
||||
|
||||
// The profile URL of the user corresponding to this token.
|
||||
Me string `json:"me"`
|
||||
|
||||
// The client ID associated with this token.
|
||||
ClientID string `json:"client_id"`
|
||||
|
||||
// A space-separated list of scopes associated with this token.
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// Integer timestamp, measured in the number of seconds since
|
||||
// January 1 1970 UTC, indicating when this token will expire.
|
||||
Exp int64 `json:"exp,omitempty"`
|
||||
|
||||
// Integer timestamp, measured in the number of seconds since
|
||||
// January 1 1970 UTC, indicating when this token was originally
|
||||
// issued.
|
||||
Iat int64 `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
TokenInvalidIntrospectResponse struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
TokenRevocationResponse struct{}
|
||||
|
||||
RequestHandler struct {
|
||||
config *domain.Config
|
||||
tokens token.UseCase
|
||||
tickets ticket.UseCase
|
||||
}
|
||||
)
|
||||
|
||||
func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHandler {
|
||||
func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase, config *domain.Config) *RequestHandler {
|
||||
return &RequestHandler{
|
||||
config: config,
|
||||
tokens: tokens,
|
||||
tickets: tickets,
|
||||
}
|
||||
|
@ -75,37 +138,61 @@ func NewRequestHandler(tokens token.UseCase, tickets ticket.UseCase) *RequestHan
|
|||
|
||||
func (h *RequestHandler) Register(r *router.Router) {
|
||||
chain := middleware.Chain{
|
||||
middleware.JWTWithConfig(middleware.JWTConfig{
|
||||
AuthScheme: "Bearer",
|
||||
ContextKey: "token",
|
||||
SigningKey: []byte(h.config.JWT.Secret),
|
||||
SigningMethod: jwa.SignatureAlgorithm(h.config.JWT.Algorithm),
|
||||
Skipper: func(ctx *http.RequestCtx) bool {
|
||||
matched, _ := path.Match("/token*", string(ctx.Path()))
|
||||
|
||||
return matched
|
||||
},
|
||||
SuccessHandler: nil,
|
||||
TokenLookup: middleware.SourceHeader + ":" + http.HeaderAuthorization +
|
||||
"," + middleware.SourceParam + ":" + "token",
|
||||
}),
|
||||
middleware.LogFmt(),
|
||||
}
|
||||
|
||||
r.GET("/token", chain.RequestHandler(h.handleValidate))
|
||||
r.POST("/token", chain.RequestHandler(h.handleAction))
|
||||
r.POST("/introspect", chain.RequestHandler(h.handleIntrospect))
|
||||
r.POST("/revocation", chain.RequestHandler(h.handleRevokation))
|
||||
}
|
||||
|
||||
func (h *RequestHandler) handleValidate(ctx *http.RequestCtx) {
|
||||
func (h *RequestHandler) handleIntrospect(ctx *http.RequestCtx) {
|
||||
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
encoder := json.NewEncoder(ctx)
|
||||
|
||||
tkt, err := h.tokens.Verify(ctx, strings.TrimPrefix(string(ctx.Request.Header.Peek(http.HeaderAuthorization)),
|
||||
"Bearer "))
|
||||
if err != nil || tkt == nil {
|
||||
ctx.SetStatusCode(http.StatusUnauthorized)
|
||||
req := new(TokenIntrospectRequest)
|
||||
if err := req.bind(ctx); err != nil {
|
||||
ctx.SetStatusCode(http.StatusBadRequest)
|
||||
|
||||
_ = encoder.Encode(domain.NewError(
|
||||
domain.ErrorCodeUnauthorizedClient,
|
||||
err.Error(),
|
||||
"https://indieauth.net/source/#access-token-verification",
|
||||
))
|
||||
_ = encoder.Encode(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = encoder.Encode(&TokenVerificationResponse{
|
||||
ClientID: tkt.ClientID,
|
||||
Me: tkt.Me,
|
||||
Scope: tkt.Scope,
|
||||
tkn, err := h.tokens.Verify(ctx, req.Token)
|
||||
if err != nil || tkn == nil {
|
||||
// WARN(toby3d): If the token is not valid, the endpoint still
|
||||
// MUST return a 200 Response.
|
||||
_ = encoder.Encode(&TokenInvalidIntrospectResponse{
|
||||
Active: false,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = encoder.Encode(&TokenIntrospectResponse{
|
||||
Active: true,
|
||||
ClientID: tkn.ClientID.String(),
|
||||
Exp: tkn.Expiry.Unix(),
|
||||
Iat: tkn.CreatedAt.Unix(),
|
||||
Me: tkn.Me.String(),
|
||||
Scope: tkn.Scope.String(),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -133,14 +220,13 @@ func (h *RequestHandler) handleAction(ctx *http.RequestCtx) {
|
|||
|
||||
switch action {
|
||||
case domain.ActionRevoke:
|
||||
h.handleRevoke(ctx)
|
||||
h.handleRevokation(ctx)
|
||||
case domain.ActionTicket:
|
||||
h.handleTicket(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint: funlen
|
||||
func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
|
||||
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
|
||||
|
||||
|
@ -174,11 +260,11 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
|
|||
}
|
||||
|
||||
resp := &TokenExchangeResponse{
|
||||
AccessToken: token.AccessToken,
|
||||
TokenType: "Bearer",
|
||||
Scope: token.Scope,
|
||||
Me: token.Me,
|
||||
Profile: nil,
|
||||
AccessToken: token.AccessToken,
|
||||
ExpiresIn: token.Expiry.Unix(),
|
||||
Me: token.Me.String(),
|
||||
Profile: nil,
|
||||
RefreshToken: "", // TODO(toby3d)
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
|
@ -187,34 +273,35 @@ func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
resp.Profile = new(TokenProfileResponse)
|
||||
|
||||
if len(profile.Name) > 0 {
|
||||
resp.Profile.Name = profile.Name[0]
|
||||
resp.Profile = &TokenProfileResponse{
|
||||
Name: profile.GetName(),
|
||||
URL: "",
|
||||
Photo: "",
|
||||
Email: "",
|
||||
}
|
||||
|
||||
if len(profile.URL) > 0 {
|
||||
resp.Profile.URL = profile.URL[0]
|
||||
if url := profile.GetURL(); url != nil {
|
||||
resp.Profile.URL = url.String()
|
||||
}
|
||||
|
||||
if len(profile.Photo) > 0 {
|
||||
resp.Profile.Photo = profile.Photo[0]
|
||||
if photo := profile.GetPhoto(); photo != nil {
|
||||
resp.Profile.Photo = photo.String()
|
||||
}
|
||||
|
||||
if len(profile.Email) > 0 {
|
||||
resp.Profile.Email = profile.Email[0]
|
||||
if email := profile.GetEmail(); email != nil {
|
||||
resp.Profile.Email = email.String()
|
||||
}
|
||||
|
||||
_ = encoder.Encode(resp)
|
||||
}
|
||||
|
||||
func (h *RequestHandler) handleRevoke(ctx *http.RequestCtx) {
|
||||
func (h *RequestHandler) handleRevokation(ctx *http.RequestCtx) {
|
||||
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
|
||||
encoder := json.NewEncoder(ctx)
|
||||
|
||||
req := new(TokenRevokeRequest)
|
||||
req := new(TokenRevocationRequest)
|
||||
if err := req.bind(ctx); err != nil {
|
||||
ctx.SetStatusCode(http.StatusBadRequest)
|
||||
|
||||
|
@ -266,12 +353,12 @@ func (h *RequestHandler) handleTicket(ctx *http.RequestCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
_ = encoder.Encode(TokenExchangeResponse{
|
||||
AccessToken: tkn.AccessToken,
|
||||
TokenType: "Bearer",
|
||||
Scope: tkn.Scope,
|
||||
Me: tkn.Me,
|
||||
Profile: nil, // TODO(toby3d)
|
||||
_ = encoder.Encode(&TokenExchangeResponse{
|
||||
AccessToken: tkn.AccessToken,
|
||||
Me: tkn.Me.String(),
|
||||
Profile: nil,
|
||||
ExpiresIn: tkn.Expiry.Unix(),
|
||||
RefreshToken: "", // TODO(toby3d)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -292,7 +379,7 @@ func (r *TokenExchangeRequest) bind(ctx *http.RequestCtx) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *TokenRevokeRequest) bind(ctx *http.RequestCtx) error {
|
||||
func (r *TokenRevocationRequest) bind(ctx *http.RequestCtx) error {
|
||||
indieAuthError := new(domain.Error)
|
||||
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
|
||||
if errors.As(err, indieAuthError) {
|
||||
|
@ -325,3 +412,20 @@ func (r *TokenTicketRequest) bind(ctx *http.RequestCtx) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TokenIntrospectRequest) bind(ctx *http.RequestCtx) error {
|
||||
indieAuthError := new(domain.Error)
|
||||
if err := form.Unmarshal(ctx.PostArgs(), r); err != nil {
|
||||
if errors.As(err, indieAuthError) {
|
||||
return indieAuthError
|
||||
}
|
||||
|
||||
return domain.NewError(
|
||||
domain.ErrorCodeInvalidRequest,
|
||||
err.Error(),
|
||||
"https://indieauth.net/source/#access-token-verification-request",
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -42,23 +42,23 @@ func TestExchange(t *testing.T) {
|
|||
}
|
||||
*/
|
||||
|
||||
func TestVerification(t *testing.T) {
|
||||
func TestIntrospection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := NewDependencies(t)
|
||||
|
||||
r := router.New()
|
||||
delivery.NewRequestHandler(deps.tokenService, deps.ticketService).Register(r)
|
||||
delivery.NewRequestHandler(deps.tokenService, deps.ticketService, deps.config).Register(r)
|
||||
|
||||
client, _, cleanup := httptest.New(t, r.Handler)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
const requestURL = "https://app.example.com/token"
|
||||
const requestURL = "https://app.example.com/introspect"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, requestURL, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, requestURL, []byte("token="+deps.token.AccessToken))
|
||||
defer http.ReleaseRequest(req)
|
||||
req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON)
|
||||
deps.token.SetAuthHeader(req)
|
||||
req.Header.SetContentType(common.MIMEApplicationForm)
|
||||
|
||||
resp := http.AcquireResponse()
|
||||
defer http.ReleaseResponse(resp)
|
||||
|
@ -71,16 +71,19 @@ func TestVerification(t *testing.T) {
|
|||
t.Errorf("GET %s = %d, want %d", requestURL, result, http.StatusOK)
|
||||
}
|
||||
|
||||
result := new(delivery.TokenVerificationResponse)
|
||||
result := new(delivery.TokenIntrospectResponse)
|
||||
if err := json.Unmarshal(resp.Body(), result); err != nil {
|
||||
e := err.(*json.SyntaxError)
|
||||
|
||||
t.Logf("%s\noffset: %d", resp.Body(), e.Offset)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deps.token.AccessToken = ""
|
||||
|
||||
if result.ClientID.String() != deps.token.ClientID.String() ||
|
||||
result.Me.String() != deps.token.Me.String() ||
|
||||
result.Scope.String() != deps.token.Scope.String() {
|
||||
if result.ClientID != deps.token.ClientID.String() ||
|
||||
result.Me != deps.token.Me.String() ||
|
||||
result.Scope != deps.token.Scope.String() {
|
||||
t.Errorf("GET %s = %+v, want %+v", requestURL, result, deps.token)
|
||||
}
|
||||
}
|
||||
|
@ -91,19 +94,17 @@ func TestRevocation(t *testing.T) {
|
|||
deps := NewDependencies(t)
|
||||
|
||||
r := router.New()
|
||||
delivery.NewRequestHandler(deps.tokenService, deps.ticketService).Register(r)
|
||||
delivery.NewRequestHandler(deps.tokenService, deps.ticketService, deps.config).Register(r)
|
||||
|
||||
client, _, cleanup := httptest.New(t, r.Handler)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
const requestURL = "https://app.example.com/token"
|
||||
const requestURL = "https://app.example.com/revocation"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, requestURL, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, requestURL, []byte("token="+deps.token.AccessToken))
|
||||
defer http.ReleaseRequest(req)
|
||||
req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON)
|
||||
req.Header.SetContentType(common.MIMEApplicationForm)
|
||||
req.PostArgs().Set("action", domain.ActionRevoke.String())
|
||||
req.PostArgs().Set("token", deps.token.AccessToken)
|
||||
|
||||
resp := http.AcquireResponse()
|
||||
defer http.ReleaseResponse(resp)
|
||||
|
|
8
main.go
8
main.go
|
@ -40,6 +40,8 @@ import (
|
|||
"source.toby3d.me/website/indieauth/internal/domain"
|
||||
healthhttpdelivery "source.toby3d.me/website/indieauth/internal/health/delivery/http"
|
||||
metadatahttpdelivery "source.toby3d.me/website/indieauth/internal/metadata/delivery/http"
|
||||
"source.toby3d.me/website/indieauth/internal/profile"
|
||||
profilehttprepo "source.toby3d.me/website/indieauth/internal/profile/repository/http"
|
||||
"source.toby3d.me/website/indieauth/internal/session"
|
||||
sessionmemoryrepo "source.toby3d.me/website/indieauth/internal/session/repository/memory"
|
||||
sessionsqlite3repo "source.toby3d.me/website/indieauth/internal/session/repository/sqlite3"
|
||||
|
@ -72,6 +74,7 @@ type (
|
|||
Sessions session.Repository
|
||||
Tickets ticket.Repository
|
||||
Tokens token.Repository
|
||||
Profiles profile.Repository
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -187,6 +190,7 @@ func main() {
|
|||
WriteTimeout: DefaultWriteTimeout,
|
||||
}
|
||||
opts.Clients = clienthttprepo.NewHTTPClientRepository(opts.Client)
|
||||
opts.Profiles = profilehttprepo.NewHTPPClientRepository(opts.Client)
|
||||
|
||||
r := router.New() //nolint: varnamelen
|
||||
NewApp(opts).Register(r)
|
||||
|
@ -267,7 +271,7 @@ func main() {
|
|||
|
||||
func NewApp(opts NewAppOptions) *App {
|
||||
return &App{
|
||||
auth: authucase.NewAuthUseCase(opts.Sessions, config),
|
||||
auth: authucase.NewAuthUseCase(opts.Sessions, opts.Profiles, config),
|
||||
clients: clientucase.NewClientUseCase(opts.Clients),
|
||||
matcher: language.NewMatcher(message.DefaultCatalog.Languages()),
|
||||
sessions: sessionucase.NewSessionUseCase(opts.Sessions),
|
||||
|
@ -323,7 +327,7 @@ func (app *App) Register(r *router.Router) {
|
|||
},
|
||||
AuthorizationResponseIssParameterSupported: true,
|
||||
}).Register(r)
|
||||
tokenhttpdelivery.NewRequestHandler(app.tokens, app.tickets).Register(r)
|
||||
tokenhttpdelivery.NewRequestHandler(app.tokens, app.tickets, config).Register(r)
|
||||
clienthttpdelivery.NewRequestHandler(clienthttpdelivery.NewRequestHandlerOptions{
|
||||
Client: indieAuthClient,
|
||||
Config: config,
|
||||
|
|
Loading…
Reference in New Issue