Merge branch 'release/0.1.0'
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details

This commit is contained in:
Maxim Lebedev 2022-06-10 14:50:17 +05:00
commit 0d8c5c334b
Signed by: toby3d
GPG Key ID: 1F14E25B7C119FC5
2578 changed files with 4710686 additions and 2 deletions

65
.golangci.yml Normal file
View File

@ -0,0 +1,65 @@
---
run:
tests: true
skip-dirs:
- locales
- testdata
- web
skip-dirs-use-default: true
skip-files:
- ".*_gen\\.go$"
output:
sort-results: true
linters-settings:
gci:
sections:
- standard
- default
- prefix(source.toby3d.me)
section-separators:
- newLine
goimports:
local-prefixes: source.toby3d.me
ireturn:
allow:
- error
- stdlib
- "(Repository|UseCase)$"
- "sqlmock.Sqlmock"
lll:
tab-width: 8
varnamelen:
ignore-type-assert-ok: true
ignore-map-index-ok: true
ignore-chan-recv-ok: true
ignore-names:
- ctx # context
- db # dataBase
- err # error
- i # index
- id
- ip
- j # alt index
- ln # listener
- me
- ok
- tc # testCase
- ts # timeStamp
- tx # transaction
ignore-decls:
- "cid *domain.ClientID"
- "ctx *fasthttp.RequestCtx"
- "ctx context.Context"
- "i int"
- "me *domain.Me"
- "r *router.Router"
linters:
enable-all: true
disable:
- godox
issues:
exclude-rules:
- source: "^//go:generate "
linters:
- lll
fix: true

29
Makefile Normal file
View File

@ -0,0 +1,29 @@
#!/usr/bin/make -f
SHELL = /bin/sh
#### Start of system configuration section. ####
srcdir = .
GO ?= go
GOFLAGS ?= -buildvcs=true
EXECUTABLE ?= indieauth
#### End of system configuration section. ####
.PHONY: all clean check help
all: main.go
$(GO) build -v $(GOFLAGS) -o $(EXECUTABLE)
clean: ## Delete all files in the current directory that are normally created by building the program
-rm $(srcdir)/internal/testing/httptest/{cert,key}.pem
$(GO) clean
check: ## Perform self-tests
$(GO) generate $(srcdir)/internal/testing/httptest/...
$(GO) test -v -cover -failfast -short -shuffle=on $(GOFLAGS) $(srcdir)/...
.PHONY: help
help: ## Display this help screen
@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

2
assets/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.fasthttp.br
*.fasthttp.gz

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
assets/icon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#AAB8C2" d="M13 3A10 10 0 0 0 3 13v10h4V13a6 6 0 0 1 12 0v10h4V13A10 10 0 0 0 13 3z"/><path fill="#FFAC33" d="M26 32a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V20a4 4 0 0 1 4-4h18a4 4 0 0 1 4 4v12z"/><path fill="#C1694F" d="M35 9a9 9 0 1 0-12 8.48V33.5a2.5 2.5 0 0 0 4.95.49L28 34a1 1 0 0 0 1-1v-1a1 1 0 0 0-1-1v-1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1v-2.28A2 2 0 0 0 29 22v-4.52A9 9 0 0 0 35 9zm-9-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

19
assets/site.webmanifest Normal file
View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

84
build/ci/drone.yml Normal file
View File

@ -0,0 +1,84 @@
---
kind: pipeline
type: docker
name: default
environment:
CGO_ENABLED: 0
steps:
- name: test
image: golang:1.18
volumes:
- name: modules
path: /go/pkg/mod
commands:
- make check
- name: build
image: golang:1.18
volumes:
- name: modules
path: /go/pkg/mod
commands:
- make
depends_on:
- test
- name: stop-service
image: appleboy/drone-ssh
settings:
host:
from_secret: SSH_HOST
username: root
key:
from_secret: SSH_PRIVATE_KEY
script:
- "systemctl stop indieauth"
depends_on:
- build
when:
branch:
- master
- name: delivery
image: appleboy/drone-scp
settings:
host:
from_secret: SSH_HOST
username: root
password: ""
key:
from_secret: SSH_PRIVATE_KEY
target: "/root/indieauth"
source:
- "indieauth"
- "assets/*"
overwrite: true
# NOTE(toby3d): Just run a previous version of the instance if it failed to deliver the current one.
failure: ignore
depends_on:
- stop-service
when:
branch:
- master
- name: start-service
image: appleboy/drone-ssh
settings:
host:
from_secret: SSH_HOST
username: root
key:
from_secret: SSH_PRIVATE_KEY
script:
- "systemctl start indieauth"
depends_on:
- delivery
when:
branch:
- master
volumes:
- name: modules
temp: {}

49
build/ci/gitlab-ci.yml Normal file
View File

@ -0,0 +1,49 @@
---
image: golang:latest
variables:
REPO_NAME: gitlab.com/$CI_PROJECT_PATH
CGO_ENABLED: 0
cache:
paths:
- .go/pkg/mod/
before_script:
- mkdir -p $GOPATH/src/$(dirname $REPO_NAME)
- ln -svf $CI_PROJECT_DIR $GOPATH/src/$REPO_NAME
- cd $GOPATH/src/$REPO_NAME
stages:
- test
- build
test:
stage: test
before_script:
- go install gotest.tools/gotestsum@latest
script:
- gotestsum --junitfile report.xml --format testname
artifacts:
when: always
reports:
junit: report.xml
cover:
stage: test
before_script:
- go install github.com/t-yuki/gocover-cobertura@latest
script:
- go test -coverprofile=coverage.txt -covermode count $REPO_NAME/...
- gocover-cobertura < coverage.txt > coverage.xml
artifacts:
reports:
cobertura: coverage.xml
build:
stage: build
script:
- make build
artifacts:
paths:
- indieauth

81
catalog_gen.go Normal file
View File

@ -0,0 +1,81 @@
// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT.
package main
import (
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/message/catalog"
)
type dictionary struct {
index []uint32
data string
}
func (d *dictionary) Lookup(key string) (data string, ok bool) {
p, ok := messageKeyToIndex[key]
if !ok {
return "", false
}
start, end := d.index[p], d.index[p+1]
if start == end {
return "", false
}
return d.data[start:end], true
}
func init() {
dict := map[string]catalog.Dictionary{
"en": &dictionary{index: enIndex, data: enData},
"ru": &dictionary{index: ruIndex, data: ruData},
}
fallback := language.MustParse("en")
cat, err := catalog.NewFromMap(dict, catalog.Fallback(fallback))
if err != nil {
panic(err)
}
message.DefaultCatalog = cat
}
var messageKeyToIndex = map[string]int{
"Allow": 4,
"Authorize %s": 0,
"Authorize application": 1,
"Choose your scopes": 2,
"Deny": 3,
"Error": 6,
"How do I fix it?": 7,
"Recipient": 10,
"Resource": 11,
"Send": 12,
"Sign In": 8,
"TicketAuth": 9,
"version": 5,
}
var enIndex = []uint32{ // 14 elements
0x00000000, 0x00000010, 0x00000026, 0x00000039,
0x0000003e, 0x00000044, 0x0000004c, 0x00000052,
0x00000063, 0x0000006b, 0x00000076, 0x00000080,
0x00000089, 0x0000008e,
} // Size: 80 bytes
const enData string = "" + // Size: 142 bytes
"\x02Authorize %[1]s\x02Authorize application\x02Choose your scopes\x02De" +
"ny\x02Allow\x02version\x02Error\x02How do I fix it?\x02Sign In\x02Ticket" +
"Auth\x02Recipient\x02Resource\x02Send"
var ruIndex = []uint32{ // 14 elements
0x00000000, 0x0000001f, 0x0000004d, 0x0000008e,
0x0000009f, 0x000000b2, 0x000000bf, 0x000000cc,
0x000000ee, 0x000000f9, 0x00000104, 0x00000119,
0x00000126, 0x00000139,
} // Size: 80 bytes
const ruData string = "" + // Size: 313 bytes
"\x02Авторизовать %[1]s\x02Авторизовать приложение\x02Выбери предоставляе" +
"мые разрешения\x02Отказать\x02Разрешить\x02версия\x02Ошибка\x02Как испр" +
"авить это?\x02Войти\x02TicketAuth\x02Получатель\x02Ресурс\x02Отправить"
// Total table size 615 bytes (0KiB); checksum: 66FB60EC

32
configs/example.yml Normal file
View File

@ -0,0 +1,32 @@
---
name: "IndieAuth"
runMode: "dev"
server:
certFile: "https/cert.pem"
domain: "localhost"
enablePprof: false
host: "0.0.0.0"
keyFile: "https/key.pem"
port: 3000
protocol: "http"
rootUrl: "{{protocol}}://{{domain}}:{{port}}/"
staticRootPath: "assets/"
staticUrlPrefix: "/static"
database:
type: "memory"
# path: "data/development.db"
code:
expiry: "10m"
length: 32
jwt:
algorithm: "RS256"
expiry: "1h"
nonceLength: 24
secret: "hackme"
indieAuth:
enabled: true
username: user
password: hackme
ticketAuth:
expiry: "1m"
length: 24

5
docs/README.md Normal file
View File

@ -0,0 +1,5 @@
# [auth.toby3d.me](https://auth.toby3d.me/) [![Build Status](https://drone.toby3d.me/api/badges/toby3d/auth/status.svg)](https://drone.toby3d.me/toby3d/auth)
> [IndieAuth](https://indieauth.net/source/) personal instance.
An attempt to implement my own server to authenticate and authorize third-party applications through [IndieWeb](https://indieweb.org/) sites on [Go](https://go.dev/). Based on the latest versions of the protocol standards and related batteries like [TicketAuth](https://indieweb.org/IndieAuth_Ticket_Auth) and [RelMeAuth](https://microformats.org/wiki/RelMeAuth).

81
go.mod
View File

@ -1,3 +1,80 @@
module gitlab.com/toby3d/indieauth
module source.toby3d.me/toby3d/auth
go 1.16
go 1.18
require (
github.com/DATA-DOG/go-sqlmock v1.5.0
github.com/brianvoe/gofakeit/v6 v6.16.0
github.com/fasthttp/router v1.4.10
github.com/goccy/go-json v0.9.7
github.com/jmoiron/sqlx v1.3.5
github.com/lestrrat-go/jwx/v2 v2.0.2
github.com/spf13/viper v1.12.0
github.com/stretchr/testify v1.7.2
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
github.com/valyala/fasthttp v1.37.0
github.com/valyala/fasttemplate v1.2.1
github.com/valyala/quicktemplate v1.7.0
go.etcd.io/bbolt v1.3.6
golang.org/x/text v0.3.7
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f
inet.af/netaddr v0.0.0-20211027220019-c74959edd3b6
modernc.org/sqlite v1.17.3
source.toby3d.me/toby3d/form v0.3.0
source.toby3d.me/toby3d/middleware v0.9.2
willnorris.com/go/microformats v1.1.1
)
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/fasthttp/session/v2 v2.4.11 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.15.6 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.1 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.2 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/savsgio/dictpool v0.0.0-20220406081701-03de5edb2e6d // indirect
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.0 // indirect
github.com/tinylib/msgp v1.1.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go4.org/intern v0.0.0-20220301175310-a089fc204883 // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect
golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68 // indirect
golang.org/x/tools v0.1.11 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.36.0 // indirect
modernc.org/ccgo/v3 v3.16.6 // indirect
modernc.org/libc v1.16.8 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.1.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.2 // indirect
modernc.org/token v1.0.0 // indirect
)

687
go.sum Normal file
View File

@ -0,0 +1,687 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/brianvoe/gofakeit/v6 v6.16.0 h1:EelCqtfArd8ppJ0z+TpOxXH8sVWNPBadPNdCDSMMw7k=
github.com/brianvoe/gofakeit/v6 v6.16.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp/router v1.4.10 h1:C8z6K1pTqhLjSv97/qCY9tZiiPT8JuFwDoO9E2HJFWQ=
github.com/fasthttp/router v1.4.10/go.mod h1:FGSUOg9SQ/tU864SfD23kG/HwfD0akXqOqhTQ27gTFQ=
github.com/fasthttp/session/v2 v2.4.11 h1:ZPiKk0pkBl7umMVq1nOdvRgjn9Kfc5RzusPjnYckP84=
github.com/fasthttp/session/v2 v2.4.11/go.mod h1:mHFWv73p5vYJaTZQDPokykT4GVOxfOoHGag/DlX8FzU=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.15.6 h1:6D9PcO8QWu0JyaQ2zUMmu16T1T+zjjEpP91guRsvDfY=
github.com/klauspost/compress v1.15.6/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.1 h1:Cnc4NxIySph38pQPzKbjg5OkKsGR/Cf5xcWt5OlSUDI=
github.com/lestrrat-go/httprc v1.0.1/go.mod h1:5Ml+nB++j6IC0e6LzefJnrpMQDKgDwDCaIQQzhbqhJM=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.0.2 h1:wkq9jwCkF3xrykISzn0Eksd7NEMOZ9yvCdnEpovIJX8=
github.com/lestrrat-go/jwx/v2 v2.0.2/go.mod h1:xV8+xRcrKbmnScV8adOzUuuTrL8aAZJoY4q2JAqIYU8=
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.0.2 h1:+jQXlF3scKIcSEKkdHzXhCTDLPFi5r1wnK6yPS+49Gw=
github.com/pelletier/go-toml/v2 v2.0.2/go.mod h1:MovirKjgVRESsAvNZlAjtFwV867yGuwRkXbG66OzopI=
github.com/philhofer/fwd v1.1.1 h1:GdGcTjf5RNAxwS4QLsiMzJYj5KEvPJD3Abr261yRQXQ=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/savsgio/dictpool v0.0.0-20220406081701-03de5edb2e6d h1:ICMDEgNgR5xFW6ZDeMKTtmh07YiLr7GkDw897I2DwKg=
github.com/savsgio/dictpool v0.0.0-20220406081701-03de5edb2e6d/go.mod h1:jrsy/bTK2n5uybo7bAvtLGzmuzAbxp+nKS8bzgrZURE=
github.com/savsgio/gotils v0.0.0-20220401102855-e56b59f40436/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/subosito/gotenv v1.4.0 h1:yAzM1+SmVcz5R4tXGsNMu1jUl2aOJXoiWUCEwwnGrvs=
github.com/subosito/gotenv v1.4.0/go.mod h1:mZd6rFysKEcUhUHXJk0C/08wAgyDBFuwEYL7vWWGaGo=
github.com/tinylib/msgp v1.1.6 h1:i+SbKraHhnrf9M5MYmvQhFnbLhAXSDWF8WWsuyRdocw=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus=
github.com/valyala/fasthttp v1.37.0 h1:7WHCyI7EAkQMVmrfBhWTCOaeROb1aCBiTopx63LkMbE=
github.com/valyala/fasthttp v1.37.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM=
github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go4.org/intern v0.0.0-20211027215823-ae77deb06f29/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA=
go4.org/intern v0.0.0-20220301175310-a089fc204883 h1:pq5gAii+wMY+DsJ5r9I6T7CHjHxHlb4d45gChzX2SsI=
go4.org/intern v0.0.0-20220301175310-a089fc204883/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37 h1:Tx9kY6yUkLge/pFG7IEMwDZy6CS2ajFc9TvQdPCW0uA=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68 h1:z8Hj/bl9cOV2grsOpEaQFUaly0JWN3i97mo3jXKJNp0=
golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY=
golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI=
gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
inet.af/netaddr v0.0.0-20211027220019-c74959edd3b6 h1:acCzuUSQ79tGsM/O50VRFySfMm19IoMKL+sZztZkCxw=
inet.af/netaddr v0.0.0-20211027220019-c74959edd3b6/go.mod h1:y3MGhcFMlh0KZPMuXXow8mpjxxAk3yoDNsp4cQz54i8=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo=
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.6 h1:3l18poV+iUemQ98O3X5OMr97LOqlzis+ytivU4NqGhA=
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
modernc.org/libc v1.16.7/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/libc v1.16.8 h1:Ux98PaOMvolgoFX/YwusFOHBnanXdGRmWgI8ciI2z4o=
modernc.org/libc v1.16.8/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU=
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.17.3 h1:iE+coC5g17LtByDYDWKpR6m2Z9022YrSh3bumwOnIrI=
modernc.org/sqlite v1.17.3/go.mod h1:10hPVYar9C0kfXuTWGz8s0XtB8uAGymUy51ZzStYe3k=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.2 h1:iFBDH6j1Z0bN/Q9udJnnFoFpENA4252qe/7/5woE5MI=
modernc.org/strutil v1.1.2/go.mod h1:OYajnUAcI/MX+XD/Wx7v1bbdvcQSvxgtb0gC+u3d3eg=
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
source.toby3d.me/toby3d/form v0.3.0 h1:kI8apdFeVr+koqTTGVoIRiR5NMqjrhCJlajYlu+1bVw=
source.toby3d.me/toby3d/form v0.3.0/go.mod h1:drlHMC+j/gb5zsttCSwx8qcYsbaRW+wFfE8bK6y+oeY=
source.toby3d.me/toby3d/middleware v0.9.2 h1:HInjjZaN7GTqlWq32XscJs4Wf0taFG6OhyTAJrED1vA=
source.toby3d.me/toby3d/middleware v0.9.2/go.mod h1:MWedNnEpLCOk2rgjlfjpkn38t+1j53htSrp3lf6pC34=
willnorris.com/go/microformats v1.1.1 h1:h5tk2luq6KBIRcwMGdksxdeea4GGuWrRFie5460OAbo=
willnorris.com/go/microformats v1.1.1/go.mod h1:kvVnWrkkEscVAIITCEoiTX66Hcyg59C7q0E49mb9TJ0=

18
init/indieauth.service Normal file
View File

@ -0,0 +1,18 @@
[Unit]
Description=Personal IndieAuth server instance
Documentation=https://indieauth.net/source/
After=syslog.target
After=network.target
[Service]
RestartSec=2s
Type=simple
User=indieweb
Group=indieweb
WorkingDirectory=/var/lib/indieauth/
ExecStart=/usr/local/bin/indieauth --config=/etc/indieauth/config.yml
Restart=always
[Install]
Alias=indieauth
WantedBy=multi-user.target

View File

@ -0,0 +1,438 @@
package http
import (
"crypto/subtle"
"errors"
"path"
"strings"
"github.com/fasthttp/router"
json "github.com/goccy/go-json"
http "github.com/valyala/fasthttp"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/auth"
"source.toby3d.me/toby3d/auth/internal/client"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/profile"
"source.toby3d.me/toby3d/auth/web"
"source.toby3d.me/toby3d/form"
"source.toby3d.me/toby3d/middleware"
)
type (
AuthAuthorizationRequest struct {
// Indicates to the authorization server that an authorization
// code should be returned as the response.
ResponseType domain.ResponseType `form:"response_type"` // code
// The client URL.
ClientID *domain.ClientID `form:"client_id"`
// The redirect URL indicating where the user should be
// redirected to after approving the request.
RedirectURI *domain.URL `form:"redirect_uri"`
// A parameter set by the client which will be included when the
// user is redirected back to the client. This is used to
// prevent CSRF attacks. The authorization server MUST return
// the unmodified state value back to the client.
State string `form:"state"`
// The code challenge as previously described.
CodeChallenge string `form:"code_challenge"`
// The hashing method used to calculate the code challenge.
CodeChallengeMethod domain.CodeChallengeMethod `form:"code_challenge_method"`
// A space-separated list of scopes the client is requesting,
// e.g. "profile", or "profile create". If the client omits this
// value, the authorization server MUST NOT issue an access
// token for this authorization code. Only the user's profile
// URL may be returned without any scope requested.
Scope domain.Scopes `form:"scope"`
// The URL that the user entered.
Me *domain.Me `form:"me"`
}
AuthVerifyRequest struct {
ClientID *domain.ClientID `form:"client_id"`
Me *domain.Me `form:"me"`
RedirectURI *domain.URL `form:"redirect_uri"`
CodeChallengeMethod domain.CodeChallengeMethod `form:"code_challenge_method"`
ResponseType domain.ResponseType `form:"response_type"`
Scope domain.Scopes `form:"scope[]"`
Authorize string `form:"authorize"`
CodeChallenge string `form:"code_challenge"`
State string `form:"state"`
Provider string `form:"provider"`
}
AuthExchangeRequest struct {
GrantType domain.GrantType `form:"grant_type"` // authorization_code
// The authorization code received from the authorization
// endpoint in the redirect.
Code string `form:"code"`
// The client's URL, which MUST match the client_id used in the
// authentication request.
ClientID *domain.ClientID `form:"client_id"`
// The client's redirect URL, which MUST match the initial
// authentication request.
RedirectURI *domain.URL `form:"redirect_uri"`
// The original plaintext random string generated before
// starting the authorization request.
CodeVerifier string `form:"code_verifier"`
}
AuthExchangeResponse struct {
Me *domain.Me `json:"me"`
Profile *AuthProfileResponse `json:"profile,omitempty"`
}
AuthProfileResponse struct {
Email *domain.Email `json:"email,omitempty"`
Photo *domain.URL `json:"photo,omitempty"`
URL *domain.URL `json:"url,omitempty"`
Name string `json:"name,omitempty"`
}
NewRequestHandlerOptions struct {
Auth auth.UseCase
Clients client.UseCase
Config *domain.Config
Matcher language.Matcher
Profiles profile.UseCase
}
RequestHandler struct {
clients client.UseCase
config *domain.Config
matcher language.Matcher
useCase auth.UseCase
}
)
func NewRequestHandler(opts NewRequestHandlerOptions) *RequestHandler {
return &RequestHandler{
clients: opts.Clients,
config: opts.Config,
matcher: opts.Matcher,
useCase: opts.Auth,
}
}
func (h *RequestHandler) Register(r *router.Router) {
chain := middleware.Chain{
middleware.CSRFWithConfig(middleware.CSRFConfig{
Skipper: func(ctx *http.RequestCtx) bool {
matched, _ := path.Match("/authorize*", string(ctx.Path()))
return ctx.IsPost() && matched
},
CookieMaxAge: 0,
CookieSameSite: http.CookieSameSiteStrictMode,
ContextKey: "csrf",
CookieDomain: h.config.Server.Domain,
CookieName: "__Secure-csrf",
CookiePath: "",
TokenLookup: "param:_csrf",
TokenLength: 0,
CookieSecure: true,
CookieHTTPOnly: true,
}),
middleware.BasicAuthWithConfig(middleware.BasicAuthConfig{
Skipper: func(ctx *http.RequestCtx) bool {
matched, _ := path.Match("/api/*", string(ctx.Path()))
provider := string(ctx.QueryArgs().Peek("provider"))
providerMatched := provider != "" && provider != domain.ProviderDirect.UID
return !ctx.IsPost() || !matched || providerMatched
},
Validator: func(ctx *http.RequestCtx, login, password string) (bool, error) {
userMatch := subtle.ConstantTimeCompare([]byte(login),
[]byte(h.config.IndieAuth.Username))
passMatch := subtle.ConstantTimeCompare([]byte(password),
[]byte(h.config.IndieAuth.Password))
return userMatch == 1 && passMatch == 1, nil
},
Realm: "",
}),
middleware.LogFmt(),
}
r.GET("/authorize", chain.RequestHandler(h.handleAuthorize))
r.POST("/api/authorize", chain.RequestHandler(h.handleVerify))
r.POST("/authorize", chain.RequestHandler(h.handleExchange))
}
func (h *RequestHandler) handleAuthorize(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
tags, _, _ := language.ParseAcceptLanguage(string(ctx.Request.Header.Peek(http.HeaderAcceptLanguage)))
tag, _, _ := h.matcher.Match(tags...)
baseOf := web.BaseOf{
Config: h.config,
Language: tag,
Printer: message.NewPrinter(tag),
}
req := NewAuthAuthorizationRequest()
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: err,
})
return
}
client, err := h.clients.Discovery(ctx, req.ClientID)
if err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: err,
})
return
}
if !client.ValidateRedirectURI(req.RedirectURI) {
ctx.SetStatusCode(http.StatusBadRequest)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: domain.NewError(
domain.ErrorCodeInvalidClient,
"requested redirect_uri is not registered on client_id side",
"",
),
})
return
}
csrf, _ := ctx.UserValue(middleware.DefaultCSRFConfig.ContextKey).([]byte)
web.WriteTemplate(ctx, &web.AuthorizePage{
BaseOf: baseOf,
CSRF: csrf,
Scope: req.Scope,
Client: client,
Me: req.Me,
RedirectURI: req.RedirectURI,
CodeChallengeMethod: req.CodeChallengeMethod,
ResponseType: req.ResponseType,
CodeChallenge: req.CodeChallenge,
State: req.State,
Providers: make([]*domain.Provider, 0), // TODO(toby3d)
})
}
func (h *RequestHandler) handleVerify(ctx *http.RequestCtx) {
ctx.Response.Header.Set(http.HeaderAccessControlAllowOrigin, h.config.Server.Domain)
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.Request.Header.DelCookie("__Secure-csrf")
encoder := json.NewEncoder(ctx)
req := NewAuthVerifyRequest()
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
redirectURL := http.AcquireURI()
defer http.ReleaseURI(redirectURL)
req.RedirectURI.CopyTo(redirectURL)
if strings.EqualFold(req.Authorize, "deny") {
domain.NewError(domain.ErrorCodeAccessDenied, "user deny authorization request", "", req.State).
SetReirectURI(redirectURL)
ctx.Redirect(redirectURL.String(), http.StatusFound)
return
}
code, err := h.useCase.Generate(ctx, auth.GenerateOptions{
ClientID: req.ClientID,
Me: req.Me,
RedirectURI: req.RedirectURI,
CodeChallengeMethod: req.CodeChallengeMethod,
Scope: req.Scope,
CodeChallenge: req.CodeChallenge,
})
if err != nil {
ctx.SetStatusCode(http.StatusInternalServerError)
_ = encoder.Encode(err)
return
}
for key, val := range map[string]string{
"code": code,
"iss": h.config.Server.GetRootURL(),
"state": req.State,
} {
redirectURL.QueryArgs().Set(key, val)
}
ctx.Redirect(redirectURL.String(), http.StatusFound)
}
func (h *RequestHandler) handleExchange(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
encoder := json.NewEncoder(ctx)
req := new(AuthExchangeRequest)
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
me, profile, err := h.useCase.Exchange(ctx, auth.ExchangeOptions{
Code: req.Code,
ClientID: req.ClientID,
RedirectURI: req.RedirectURI,
CodeVerifier: req.CodeVerifier,
})
if err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
var userInfo *AuthProfileResponse
if profile != nil {
userInfo = &AuthProfileResponse{
Email: profile.GetEmail(),
Photo: profile.GetPhoto(),
URL: profile.GetURL(),
Name: profile.GetName(),
}
}
_ = encoder.Encode(&AuthExchangeResponse{
Me: me,
Profile: userInfo,
})
}
func NewAuthAuthorizationRequest() *AuthAuthorizationRequest {
return &AuthAuthorizationRequest{
ClientID: new(domain.ClientID),
CodeChallenge: "",
CodeChallengeMethod: domain.CodeChallengeMethodUndefined,
Me: new(domain.Me),
RedirectURI: new(domain.URL),
ResponseType: domain.ResponseTypeUndefined,
Scope: make(domain.Scopes, 0),
State: "",
}
}
//nolint: cyclop
func (r *AuthAuthorizationRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.QueryArgs().QueryString(), r); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#authorization-request",
)
}
if r.ResponseType == domain.ResponseTypeID {
r.ResponseType = domain.ResponseTypeCode
}
return nil
}
func NewAuthVerifyRequest() *AuthVerifyRequest {
return &AuthVerifyRequest{
Authorize: "",
ClientID: new(domain.ClientID),
CodeChallenge: "",
CodeChallengeMethod: domain.CodeChallengeMethodUndefined,
Me: new(domain.Me),
Provider: "",
RedirectURI: new(domain.URL),
ResponseType: domain.ResponseTypeUndefined,
Scope: make(domain.Scopes, 0),
State: "",
}
}
//nolint: funlen,cyclop
func (r *AuthVerifyRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs().QueryString(), r); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#authorization-request",
)
}
// NOTE(toby3d): backwards-compatible support.
// See: https://aaronparecki.com/2020/12/03/1/indieauth-2020#response-type
if r.ResponseType == domain.ResponseTypeID {
r.ResponseType = domain.ResponseTypeCode
}
r.Provider = strings.ToLower(r.Provider)
if !strings.EqualFold(r.Authorize, "allow") && !strings.EqualFold(r.Authorize, "deny") {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"cannot validate verification request",
"https://indieauth.net/source/#authorization-request",
)
}
return nil
}
func (r *AuthExchangeRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.PostArgs().QueryString(), r); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"cannot validate verification request",
"https://indieauth.net/source/#redeeming-the-authorization-code",
)
}
return nil
}

View File

@ -0,0 +1,123 @@
package http_test
import (
"path"
"strings"
"sync"
"testing"
"github.com/fasthttp/router"
http "github.com/valyala/fasthttp"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/auth"
delivery "source.toby3d.me/toby3d/auth/internal/auth/delivery/http"
ucase "source.toby3d.me/toby3d/auth/internal/auth/usecase"
"source.toby3d.me/toby3d/auth/internal/client"
clientrepo "source.toby3d.me/toby3d/auth/internal/client/repository/memory"
clientucase "source.toby3d.me/toby3d/auth/internal/client/usecase"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/profile"
profilerepo "source.toby3d.me/toby3d/auth/internal/profile/repository/memory"
"source.toby3d.me/toby3d/auth/internal/session"
sessionrepo "source.toby3d.me/toby3d/auth/internal/session/repository/memory"
"source.toby3d.me/toby3d/auth/internal/testing/httptest"
userrepo "source.toby3d.me/toby3d/auth/internal/user/repository/memory"
)
type Dependencies struct {
authService auth.UseCase
clients client.Repository
clientService client.UseCase
config *domain.Config
matcher language.Matcher
profiles profile.Repository
sessions session.Repository
store *sync.Map
}
func TestAuthorize(t *testing.T) {
t.Parallel()
deps := NewDependencies(t)
me := domain.TestMe(t, "https://user.example.net")
user := domain.TestUser(t)
client := domain.TestClient(t)
deps.store.Store(path.Join(clientrepo.DefaultPathPrefix, client.ID.String()), client)
deps.store.Store(path.Join(profilerepo.DefaultPathPrefix, me.String()), user.Profile)
deps.store.Store(path.Join(userrepo.DefaultPathPrefix, me.String()), user)
r := router.New()
//nolint: exhaustivestruct
delivery.NewRequestHandler(delivery.NewRequestHandlerOptions{
Auth: deps.authService,
Clients: deps.clientService,
Config: deps.config,
Matcher: deps.matcher,
}).Register(r)
httpClient, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
uri := http.AcquireURI()
defer http.ReleaseURI(uri)
uri.Update("https://example.com/authorize")
for key, val := range map[string]string{
"client_id": client.ID.String(),
"code_challenge": "OfYAxt8zU2dAPDWQxTAUIteRzMsoj9QBdMIVEDOErUo",
"code_challenge_method": domain.CodeChallengeMethodS256.String(),
"me": me.String(),
"redirect_uri": client.RedirectURI[0].String(),
"response_type": domain.ResponseTypeCode.String(),
"scope": "profile email",
"state": "1234567890",
} {
uri.QueryArgs().Set(key, val)
}
req := httptest.NewRequest(http.MethodGet, uri.String(), nil)
defer http.ReleaseRequest(req)
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
if err := httpClient.Do(req, resp); err != nil {
t.Fatal(err)
}
if resp.StatusCode() != http.StatusOK {
t.Errorf("GET %s = %d, want %d", uri.String(), resp.StatusCode(), http.StatusOK)
}
const expResult = `Authorize application`
if result := string(resp.Body()); !strings.Contains(result, expResult) {
t.Errorf("GET %s = %s, want %s", uri.String(), result, expResult)
}
}
func NewDependencies(tb testing.TB) Dependencies {
tb.Helper()
config := domain.TestConfig(tb)
matcher := language.NewMatcher(message.DefaultCatalog.Languages())
store := new(sync.Map)
clients := clientrepo.NewMemoryClientRepository(store)
sessions := sessionrepo.NewMemorySessionRepository(store, config)
profiles := profilerepo.NewMemoryProfileRepository(store)
authService := ucase.NewAuthUseCase(sessions, profiles, config)
clientService := clientucase.NewClientUseCase(clients)
return Dependencies{
authService: authService,
clients: clients,
clientService: clientService,
config: config,
matcher: matcher,
sessions: sessions,
profiles: profiles,
store: store,
}
}

49
internal/auth/usecase.go Normal file
View File

@ -0,0 +1,49 @@
package auth
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type (
GenerateOptions struct {
ClientID *domain.ClientID
Me *domain.Me
RedirectURI *domain.URL
CodeChallengeMethod domain.CodeChallengeMethod
Scope domain.Scopes
CodeChallenge string
}
ExchangeOptions struct {
ClientID *domain.ClientID
RedirectURI *domain.URL
Code string
CodeVerifier string
}
UseCase interface {
Generate(ctx context.Context, opts GenerateOptions) (string, error)
Exchange(ctx context.Context, opts ExchangeOptions) (*domain.Me, *domain.Profile, error)
}
)
var (
ErrMismatchClientID error = domain.NewError(
domain.ErrorCodeInvalidRequest,
"client's URL MUST match the client_id used in the authentication request",
"https://indieauth.net/source/#request",
)
ErrMismatchRedirectURI error = domain.NewError(
domain.ErrorCodeInvalidRequest,
"client's redirect URL MUST match the initial authentication request",
"https://indieauth.net/source/#request",
)
ErrMismatchPKCE error = domain.NewError(
domain.ErrorCodeInvalidRequest,
"code_verifier is not hashes to the same value as given in the code_challenge in the original "+
" authorization request",
"https://indieauth.net/source/#request",
)
)

View File

@ -0,0 +1,85 @@
package usecase
import (
"context"
"fmt"
"source.toby3d.me/toby3d/auth/internal/auth"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/profile"
"source.toby3d.me/toby3d/auth/internal/random"
"source.toby3d.me/toby3d/auth/internal/session"
)
type authUseCase struct {
config *domain.Config
sessions session.Repository
profiles profile.Repository
}
// NewAuthUseCase creates a new authentication use case.
func NewAuthUseCase(sessions session.Repository, profiles profile.Repository, config *domain.Config) auth.UseCase {
return &authUseCase{
config: config,
sessions: sessions,
profiles: profiles,
}
}
func (uc *authUseCase) Generate(ctx context.Context, opts auth.GenerateOptions) (string, error) {
code, err := random.String(uc.config.Code.Length)
if err != nil {
return "", fmt.Errorf("cannot generate random code: %w", err)
}
var userInfo *domain.Profile
// NOTE(toby3d): We request information about the profile only if there
// is a corresponding Scope. However, the availability of this
// information in the token is not guaranteed and is completely optional:
// https://indieauth.net/source/#profile-information
if opts.Scope.Has(domain.ScopeProfile) {
if userInfo, err = uc.profiles.Get(ctx, opts.Me); err == nil &&
userInfo.Email != nil && !opts.Scope.Has(domain.ScopeEmail) {
userInfo.Email = nil
}
}
if err = uc.sessions.Create(ctx, &domain.Session{
ClientID: opts.ClientID,
Code: code,
CodeChallenge: opts.CodeChallenge,
CodeChallengeMethod: opts.CodeChallengeMethod,
Me: opts.Me,
Profile: userInfo,
RedirectURI: opts.RedirectURI,
Scope: opts.Scope,
}); err != nil {
return "", fmt.Errorf("cannot save session in store: %w", err)
}
return code, nil
}
func (uc *authUseCase) Exchange(ctx context.Context, opts auth.ExchangeOptions) (*domain.Me, *domain.Profile, error) {
session, err := uc.sessions.GetAndDelete(ctx, opts.Code)
if err != nil {
return nil, nil, fmt.Errorf("cannot find session in store: %w", err)
}
if opts.ClientID.String() != session.ClientID.String() {
return nil, nil, auth.ErrMismatchClientID
}
if opts.RedirectURI.String() != session.RedirectURI.String() {
return nil, nil, auth.ErrMismatchRedirectURI
}
if session.CodeChallenge != "" &&
session.CodeChallengeMethod != domain.CodeChallengeMethodUndefined &&
!session.CodeChallengeMethod.Validate(session.CodeChallenge, opts.CodeVerifier) {
return nil, nil, auth.ErrMismatchPKCE
}
return session.Me, session.Profile, nil
}

View File

@ -0,0 +1,180 @@
package http
import (
"errors"
"strings"
"github.com/fasthttp/router"
http "github.com/valyala/fasthttp"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/token"
"source.toby3d.me/toby3d/auth/web"
"source.toby3d.me/toby3d/form"
"source.toby3d.me/toby3d/middleware"
)
type (
ClientCallbackRequest struct {
Error domain.ErrorCode `form:"error,omitempty"`
Iss *domain.ClientID `form:"iss"`
Code string `form:"code"`
ErrorDescription string `form:"error_description,omitempty"`
State string `form:"state"`
}
NewRequestHandlerOptions struct {
Matcher language.Matcher
Tokens token.UseCase
Client *domain.Client
Config *domain.Config
}
RequestHandler struct {
matcher language.Matcher
tokens token.UseCase
client *domain.Client
config *domain.Config
}
)
func NewRequestHandler(opts NewRequestHandlerOptions) *RequestHandler {
return &RequestHandler{
client: opts.Client,
config: opts.Config,
matcher: opts.Matcher,
tokens: opts.Tokens,
}
}
func (h *RequestHandler) Register(r *router.Router) {
chain := middleware.Chain{
middleware.LogFmt(),
}
r.GET("/", chain.RequestHandler(h.handleRender))
r.GET("/callback", chain.RequestHandler(h.handleCallback))
}
func (h *RequestHandler) handleRender(ctx *http.RequestCtx) {
redirect := make([]string, len(h.client.RedirectURI))
for i := range h.client.RedirectURI {
redirect[i] = h.client.RedirectURI[i].String()
}
ctx.Response.Header.Set(
http.HeaderLink, `<`+strings.Join(redirect, `>; rel="redirect_uri", `)+`>; rel="redirect_uri"`,
)
tags, _, _ := language.ParseAcceptLanguage(string(ctx.Request.Header.Peek(http.HeaderAcceptLanguage)))
tag, _, _ := h.matcher.Match(tags...)
// TODO(toby3d): generate and store PKCE
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
web.WriteTemplate(ctx, &web.HomePage{
BaseOf: web.BaseOf{
Config: h.config,
Language: tag,
Printer: message.NewPrinter(tag),
},
Client: h.client,
State: "hackme", // TODO(toby3d): generate and store state
})
}
//nolint: funlen
func (h *RequestHandler) handleCallback(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
tags, _, _ := language.ParseAcceptLanguage(string(ctx.Request.Header.Peek(http.HeaderAcceptLanguage)))
tag, _, _ := h.matcher.Match(tags...)
baseOf := web.BaseOf{
Config: h.config,
Language: tag,
Printer: message.NewPrinter(tag),
}
req := new(ClientCallbackRequest)
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusInternalServerError)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: err,
})
return
}
if req.Error != domain.ErrorCodeUndefined {
ctx.SetStatusCode(http.StatusUnauthorized)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: domain.NewError(
domain.ErrorCodeAccessDenied,
req.ErrorDescription,
"",
req.State,
),
})
return
}
// TODO(toby3d): load and check state
if req.Iss == nil || req.Iss.String() != h.client.ID.String() {
ctx.SetStatusCode(http.StatusBadRequest)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: domain.NewError(
domain.ErrorCodeInvalidClient,
"iss does not match client_id",
"https://indieauth.net/source/#authorization-response",
req.State,
),
})
return
}
token, _, err := h.tokens.Exchange(ctx, token.ExchangeOptions{
ClientID: h.client.ID,
RedirectURI: h.client.RedirectURI[0],
Code: req.Code,
CodeVerifier: "", // TODO(toby3d): validate PKCE here
})
if err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
web.WriteTemplate(ctx, &web.ErrorPage{
BaseOf: baseOf,
Error: err,
})
return
}
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
web.WriteTemplate(ctx, &web.CallbackPage{
BaseOf: baseOf,
Token: token,
})
}
func (req *ClientCallbackRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.QueryArgs().QueryString(), req); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(domain.ErrorCodeInvalidRequest, err.Error(), "")
}
return nil
}

View File

@ -0,0 +1,95 @@
package http_test
import (
"sync"
"testing"
"github.com/fasthttp/router"
http "github.com/valyala/fasthttp"
"golang.org/x/text/language"
"golang.org/x/text/message"
delivery "source.toby3d.me/toby3d/auth/internal/client/delivery/http"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/profile"
profilerepo "source.toby3d.me/toby3d/auth/internal/profile/repository/memory"
"source.toby3d.me/toby3d/auth/internal/session"
sessionrepo "source.toby3d.me/toby3d/auth/internal/session/repository/memory"
"source.toby3d.me/toby3d/auth/internal/testing/httptest"
"source.toby3d.me/toby3d/auth/internal/token"
tokenrepo "source.toby3d.me/toby3d/auth/internal/token/repository/memory"
tokenucase "source.toby3d.me/toby3d/auth/internal/token/usecase"
)
type Dependencies struct {
profiles profile.Repository
client *domain.Client
config *domain.Config
matcher language.Matcher
sessions session.Repository
store *sync.Map
tokens token.Repository
tokenService token.UseCase
}
func TestRead(t *testing.T) {
t.Parallel()
deps := NewDependencies(t)
r := router.New()
delivery.NewRequestHandler(delivery.NewRequestHandlerOptions{
Client: deps.client,
Config: deps.config,
Matcher: deps.matcher,
Tokens: deps.tokenService,
}).Register(r)
client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
const requestURI string = "https://app.example.com/"
req, resp := httptest.NewRequest(http.MethodGet, requestURI, nil), http.AcquireResponse()
t.Cleanup(func() {
http.ReleaseRequest(req)
http.ReleaseResponse(resp)
})
if err := client.Do(req, resp); err != nil {
t.Error(err)
}
if resp.StatusCode() != http.StatusOK {
t.Errorf("GET %s = %d, want %d", requestURI, resp.StatusCode(), http.StatusOK)
}
}
func NewDependencies(tb testing.TB) Dependencies {
tb.Helper()
store := new(sync.Map)
client := domain.TestClient(tb)
config := domain.TestConfig(tb)
matcher := language.NewMatcher(message.DefaultCatalog.Languages())
sessions := sessionrepo.NewMemorySessionRepository(store, config)
tokens := tokenrepo.NewMemoryTokenRepository(store)
profiles := profilerepo.NewMemoryProfileRepository(store)
tokenService := tokenucase.NewTokenUseCase(tokenucase.Config{
Config: config,
Profiles: profiles,
Sessions: sessions,
Tokens: tokens,
})
return Dependencies{
client: client,
config: config,
matcher: matcher,
sessions: sessions,
store: store,
profiles: profiles,
tokens: tokens,
tokenService: tokenService,
}
}

View File

@ -0,0 +1,17 @@
package client
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type Repository interface {
Get(ctx context.Context, id *domain.ClientID) (*domain.Client, error)
}
var ErrNotExist error = domain.NewError(
domain.ErrorCodeInvalidClient,
"client with the specified ID does not exist",
"",
)

View File

@ -0,0 +1,137 @@
package http
import (
"context"
"fmt"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/client"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/httputil"
)
type httpClientRepository struct {
client *http.Client
}
const (
DefaultMaxRedirectsCount int = 10
hApp string = "h-app"
hXApp string = "h-x-app"
propertyLogo string = "logo"
propertyName string = "name"
propertyURL string = "url"
relRedirectURI string = "redirect_uri"
)
func NewHTTPClientRepository(c *http.Client) client.Repository {
return &httpClientRepository{
client: c,
}
}
func (repo *httpClientRepository) Get(ctx context.Context, cid *domain.ClientID) (*domain.Client, error) {
req := http.AcquireRequest()
defer http.ReleaseRequest(req)
req.SetRequestURI(cid.String())
req.Header.SetMethod(http.MethodGet)
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
if err := repo.client.DoRedirects(req, resp, DefaultMaxRedirectsCount); err != nil {
return nil, fmt.Errorf("failed to make a request to the client: %w", err)
}
if resp.StatusCode() == http.StatusNotFound {
return nil, fmt.Errorf("%w: status on client page is not 200", client.ErrNotExist)
}
client := &domain.Client{
ID: cid,
RedirectURI: make([]*domain.URL, 0),
Logo: make([]*domain.URL, 0),
URL: make([]*domain.URL, 0),
Name: make([]string, 0),
}
extract(client, resp)
return client, nil
}
//nolint: gocognit, cyclop
func extract(dst *domain.Client, src *http.Response) {
for _, endpoint := range httputil.ExtractEndpoints(src, relRedirectURI) {
if !containsURL(dst.RedirectURI, endpoint) {
dst.RedirectURI = append(dst.RedirectURI, endpoint)
}
}
for _, itemType := range []string{hXApp, hApp} {
for _, name := range httputil.ExtractProperty(src, itemType, propertyName) {
if n, ok := name.(string); ok && !containsString(dst.Name, n) {
dst.Name = append(dst.Name, n)
}
}
for _, logo := range httputil.ExtractProperty(src, itemType, propertyLogo) {
var (
uri *domain.URL
err error
)
switch l := logo.(type) {
case string:
uri, err = domain.ParseURL(l)
case map[string]string:
if value, ok := l["value"]; ok {
uri, err = domain.ParseURL(value)
}
}
if err != nil || containsURL(dst.Logo, uri) {
continue
}
dst.Logo = append(dst.Logo, uri)
}
for _, property := range httputil.ExtractProperty(src, itemType, propertyURL) {
prop, ok := property.(string)
if !ok {
continue
}
if u, err := domain.ParseURL(prop); err == nil || !containsURL(dst.URL, u) {
dst.URL = append(dst.URL, u)
}
}
}
}
func containsString(src []string, find string) bool {
for i := range src {
if src[i] != find {
continue
}
return true
}
return false
}
func containsURL(src []*domain.URL, find *domain.URL) bool {
for i := range src {
if src[i].String() != find.String() {
continue
}
return true
}
return false
}

View File

@ -0,0 +1,73 @@
package http_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
http "github.com/valyala/fasthttp"
repository "source.toby3d.me/toby3d/auth/internal/client/repository/http"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/testing/httptest"
)
const testBody string = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%[1]s</title>
<link rel="redirect_uri" href="%[4]s">
</head>
<body>
<div class="h-app h-x-app">
<img class="u-logo" src="%[3]s">
<a class="u-url p-name" href="%[2]s">%[1]s</a>
</div>
</body>
</html>
`
func TestGet(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
httpClient, _, cleanup := httptest.New(t, testHandler(t, client))
t.Cleanup(cleanup)
result, err := repository.NewHTTPClientRepository(httpClient).Get(context.Background(), client.ID)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, client.Name, result.Name)
assert.Equal(t, client.ID.String(), result.ID.String())
for i := range client.URL {
assert.Equal(t, client.URL[i].String(), result.URL[i].String())
}
for i := range client.Logo {
assert.Equal(t, client.Logo[i].String(), result.Logo[i].String())
}
for i := range client.RedirectURI {
assert.Equal(t, client.RedirectURI[i].String(), result.RedirectURI[i].String())
}
}
func testHandler(tb testing.TB, client *domain.Client) http.RequestHandler {
tb.Helper()
return func(ctx *http.RequestCtx) {
ctx.Response.Header.Set(http.HeaderLink, `<`+client.RedirectURI[0].String()+`>; rel="redirect_uri"`)
ctx.SuccessString(common.MIMETextHTMLCharsetUTF8, fmt.Sprintf(
testBody, client.Name[0], client.URL[0].String(), client.Logo[0].String(),
client.RedirectURI[1].String(),
))
}
}

View File

@ -0,0 +1,43 @@
package memory
import (
"context"
"fmt"
"path"
"sync"
"source.toby3d.me/toby3d/auth/internal/client"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type memoryClientRepository struct {
store *sync.Map
}
const DefaultPathPrefix string = "clients"
func NewMemoryClientRepository(store *sync.Map) client.Repository {
return &memoryClientRepository{
store: store,
}
}
func (repo *memoryClientRepository) Create(ctx context.Context, client *domain.Client) error {
repo.store.Store(path.Join(DefaultPathPrefix, client.ID.String()), client)
return nil
}
func (repo *memoryClientRepository) Get(ctx context.Context, id *domain.ClientID) (*domain.Client, error) {
src, ok := repo.store.Load(path.Join(DefaultPathPrefix, id.String()))
if !ok {
return nil, fmt.Errorf("cannot find client in store: %w", client.ErrNotExist)
}
c, ok := src.(*domain.Client)
if !ok {
return nil, fmt.Errorf("cannot decode client from store: %w", client.ErrNotExist)
}
return c, nil
}

View File

@ -0,0 +1,31 @@
package memory_test
import (
"context"
"path"
"reflect"
"sync"
"testing"
repository "source.toby3d.me/toby3d/auth/internal/client/repository/memory"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestGet(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
store := new(sync.Map)
store.Store(path.Join(repository.DefaultPathPrefix, client.ID.String()), client)
result, err := repository.NewMemoryClientRepository(store).
Get(context.Background(), client.ID)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result, client) {
t.Errorf("Get(%s) = %+v, want %+v", client.ID, result, client)
}
}

View File

@ -0,0 +1,18 @@
package client
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type UseCase interface {
// Discovery returns client public information bu ClientID URL.
Discovery(ctx context.Context, id *domain.ClientID) (*domain.Client, error)
}
var ErrInvalidMe error = domain.NewError(
domain.ErrorCodeInvalidRequest,
"cannot fetch client endpoints on provided me",
"",
)

View File

@ -0,0 +1,28 @@
package usecase
import (
"context"
"fmt"
"source.toby3d.me/toby3d/auth/internal/client"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type clientUseCase struct {
repo client.Repository
}
func NewClientUseCase(repo client.Repository) client.UseCase {
return &clientUseCase{
repo: repo,
}
}
func (useCase *clientUseCase) Discovery(ctx context.Context, id *domain.ClientID) (*domain.Client, error) {
c, err := useCase.repo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("cannot discovery client by id: %w", err)
}
return c, nil
}

View File

@ -0,0 +1,32 @@
package usecase_test
import (
"context"
"path"
"reflect"
"sync"
"testing"
repository "source.toby3d.me/toby3d/auth/internal/client/repository/memory"
"source.toby3d.me/toby3d/auth/internal/client/usecase"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestDiscovery(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
store := new(sync.Map)
store.Store(path.Join(repository.DefaultPathPrefix, client.ID.String()), client)
result, err := usecase.NewClientUseCase(repository.NewMemoryClientRepository(store)).
Discovery(context.Background(), client.ID)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result, client) {
t.Errorf("Discovery(%s) = %+v, want %+v", client.ID, result, client)
}
}

13
internal/common/common.go Normal file
View File

@ -0,0 +1,13 @@
package common
const charsetUTF8 = "charset=UTF-8"
const (
MIMEApplicationForm string = "application/x-www-form-urlencoded"
MIMEApplicationJSON string = "application/json"
MIMEApplicationJSONCharsetUTF8 string = MIMEApplicationJSON + "; " + charsetUTF8
MIMETextHTML string = "text/html"
MIMETextHTMLCharsetUTF8 string = MIMETextHTML + "; " + charsetUTF8
MIMETextPlain string = "text/plain"
MIMETextPlainCharsetUTF8 string = MIMETextPlain + "; " + charsetUTF8
)

74
internal/domain/action.go Normal file
View File

@ -0,0 +1,74 @@
package domain
import (
"fmt"
"strconv"
"strings"
)
// Action represent action for token endpoint supported by IndieAuth.
//
// NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety:
// https://threedots.tech/post/safer-enums-in-go/#struct-based-enums
type Action struct {
uid string
}
//nolint: gochecknoglobals // structs cannot be constants
var (
ActionUndefined = Action{uid: ""}
// ActionRevoke represent action for revoke token.
ActionRevoke = Action{uid: "revoke"}
// ActionTicket represent action for TicketAuth extension.
ActionTicket = Action{uid: "ticket"}
)
var ErrActionUnknown error = NewError(ErrorCodeInvalidRequest, "unknown action method", "")
// ParseAction parse string identifier of action into struct enum.
func ParseAction(uid string) (Action, error) {
switch strings.ToLower(uid) {
case ActionRevoke.uid:
return ActionRevoke, nil
case ActionTicket.uid:
return ActionTicket, nil
}
return ActionUndefined, fmt.Errorf("%w: %s", ErrActionUnknown, uid)
}
// UnmarshalForm implements custom unmarshler for form values.
func (a *Action) UnmarshalForm(v []byte) error {
action, err := ParseAction(string(v))
if err != nil {
return fmt.Errorf("Action: UnmarshalForm: %w", err)
}
*a = action
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (a *Action) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("Action: UnmarshalJSON: %w", err)
}
action, err := ParseAction(src)
if err != nil {
return fmt.Errorf("Action: UnmarshalJSON: %w", err)
}
*a = action
return nil
}
// String returns string representation of action.
func (a Action) String() string {
return a.uid
}

View File

@ -0,0 +1,89 @@
//nolint: dupl
package domain_test
import (
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseAction(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
in string
out domain.Action
}{
{in: "revoke", out: domain.ActionRevoke},
{in: "ticket", out: domain.ActionTicket},
} {
tc := tc
t.Run(tc.in, func(t *testing.T) {
t.Parallel()
result, err := domain.ParseAction(tc.in)
if err != nil {
t.Fatalf("%+v", err)
}
if result != tc.out {
t.Errorf("ParseAction(%s) = %v, want %v", tc.in, result, tc.out)
}
})
}
}
func TestAction_UnmarshalForm(t *testing.T) {
t.Parallel()
input := []byte("revoke")
result := domain.ActionUndefined
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.ActionRevoke {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, domain.ActionRevoke)
}
}
func TestAction_UnmarshalJSON(t *testing.T) {
t.Parallel()
input := []byte(`"revoke"`)
result := domain.ActionUndefined
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.ActionRevoke {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, domain.ActionRevoke)
}
}
func TestAction_String(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in domain.Action
out string
}{
{name: "revoke", in: domain.ActionRevoke, out: "revoke"},
{name: "ticket", in: domain.ActionTicket, out: "ticket"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := tc.in.String()
if result != tc.out {
t.Errorf("String() = %v, want %v", result, tc.out)
}
})
}
}

34
internal/domain/app.go Normal file
View File

@ -0,0 +1,34 @@
package domain
type App struct {
Logo []*URL
URL []*URL
Name []string
}
// GetName safe returns first name, if any.
func (a App) GetName() string {
if len(a.Name) == 0 {
return ""
}
return a.Name[0]
}
// GetURL safe returns first URL, if any.
func (a App) GetURL() *URL {
if len(a.URL) == 0 {
return nil
}
return a.URL[0]
}
// GetLogo safe returns first logo, if any.
func (a App) GetLogo() *URL {
if len(a.Logo) == 0 {
return nil
}
return a.Logo[0]
}

115
internal/domain/client.go Normal file
View File

@ -0,0 +1,115 @@
package domain
import (
"bytes"
"net"
"strings"
"testing"
)
// Client describes the client requesting data about the user.
type Client struct {
ID *ClientID
Logo []*URL
RedirectURI []*URL
URL []*URL
Name []string
}
// NewClient creates a new empty Client with provided ClientID, if any.
func NewClient(cid *ClientID) *Client {
return &Client{
ID: cid,
Logo: make([]*URL, 0),
RedirectURI: make([]*URL, 0),
URL: make([]*URL, 0),
Name: make([]string, 0),
}
}
// TestClient returns valid random generated client for tests.
func TestClient(tb testing.TB) *Client {
tb.Helper()
redirects := make([]*URL, 0)
for _, redirect := range []string{
"https://app.example.com/redirect",
"https://app.example.net/redirect",
} {
redirects = append(redirects, TestURL(tb, redirect))
}
return &Client{
ID: TestClientID(tb),
Name: []string{"Example App"},
URL: []*URL{TestURL(tb, "https://app.example.com/")},
Logo: []*URL{TestURL(tb, "https://app.example.com/logo.png")},
RedirectURI: redirects,
}
}
// ValidateRedirectURI validates RedirectURI from request to ClientID or
// registered set of client RedirectURI.
//
// If the URL scheme, host or port of the redirect_uri in the request do not
// match that of the client_id, then the authorization endpoint SHOULD verify
// that the requested redirect_uri matches one of the redirect URLs published by
// the client, and SHOULD block the request from proceeding if not.
func (c *Client) ValidateRedirectURI(redirectURI *URL) bool {
if redirectURI == nil {
return false
}
rHost, rPort, err := net.SplitHostPort(string(redirectURI.Host()))
if err != nil {
rHost = string(redirectURI.Host())
}
cHost, cPort, err := net.SplitHostPort(string(c.ID.clientID.Host()))
if err != nil {
cHost = string(c.ID.clientID.Host())
}
if bytes.EqualFold(redirectURI.Scheme(), c.ID.clientID.Scheme()) &&
strings.EqualFold(rHost, cHost) &&
strings.EqualFold(rPort, cPort) {
return true
}
for i := range c.RedirectURI {
if redirectURI.String() != c.RedirectURI[i].String() {
continue
}
return true
}
return false
}
// GetName safe returns first name, if any.
func (c Client) GetName() string {
if len(c.Name) == 0 {
return ""
}
return c.Name[0]
}
// GetURL safe returns first URL, if any.
func (c Client) GetURL() *URL {
if len(c.URL) == 0 {
return nil
}
return c.URL[0]
}
// GetLogo safe returns first logo, if any.
func (c Client) GetLogo() *URL {
if len(c.Logo) == 0 {
return nil
}
return c.Logo[0]
}

View File

@ -0,0 +1,181 @@
package domain
import (
"fmt"
"net/url"
"strconv"
"strings"
"testing"
http "github.com/valyala/fasthttp"
"inet.af/netaddr"
)
// ClientID is a URL client identifier.
type ClientID struct {
clientID *http.URI
}
//nolint: gochecknoglobals // slices cannot be constants
var (
localhostIPv4 = netaddr.MustParseIP("127.0.0.1")
localhostIPv6 = netaddr.MustParseIP("::1")
)
// ParseClientID parse string as client ID URL identifier.
//nolint: funlen, cyclop
func ParseClientID(src string) (*ClientID, error) {
cid := http.AcquireURI()
if err := cid.Parse(nil, []byte(src)); err != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#client-identifier",
)
}
scheme := string(cid.Scheme())
if scheme != "http" && scheme != "https" {
return nil, NewError(
ErrorCodeInvalidRequest,
"client identifier URL MUST have either an https or http scheme",
"https://indieauth.net/source/#client-identifier",
)
}
path := string(cid.PathOriginal())
if path == "" || strings.Contains(path, "/.") || strings.Contains(path, "/..") {
return nil, NewError(
ErrorCodeInvalidRequest,
"client identifier URL MUST contain a path component and MUST NOT contain "+
"single-dot or double-dot path segments",
"https://indieauth.net/source/#client-identifier",
)
}
if cid.Hash() != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
"client identifier URL MUST NOT contain a fragment component",
"https://indieauth.net/source/#client-identifier",
)
}
if cid.Username() != nil || cid.Password() != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
"client identifier URL MUST NOT contain a username or password component",
"https://indieauth.net/source/#client-identifier",
)
}
domain := string(cid.Host())
if domain == "" {
return nil, NewError(
ErrorCodeInvalidRequest,
"client host name MUST be domain name or a loopback interface",
"https://indieauth.net/source/#client-identifier",
)
}
ip, err := netaddr.ParseIP(domain)
if err != nil {
ipPort, err := netaddr.ParseIPPort(domain)
if err != nil {
//nolint: nilerr // ClientID does not contain an IP address, so it is valid
return &ClientID{clientID: cid}, nil
}
ip = ipPort.IP()
}
if !ip.IsLoopback() && ip.Compare(localhostIPv4) != 0 && ip.Compare(localhostIPv6) != 0 {
return nil, NewError(
ErrorCodeInvalidRequest,
"client identifier URL MUST NOT be IPv4 or IPv6 addresses except for IPv4 "+
"127.0.0.1 or IPv6 [::1]",
"https://indieauth.net/source/#client-identifier",
)
}
return &ClientID{
clientID: cid,
}, nil
}
// TestClientID returns valid random generated ClientID for tests.
func TestClientID(tb testing.TB) *ClientID {
tb.Helper()
clientID, err := ParseClientID("https://indieauth.example.com/")
if err != nil {
tb.Fatal(err)
}
return clientID
}
// UnmarshalForm implements custom unmarshler for form values.
func (cid *ClientID) UnmarshalForm(v []byte) error {
clientID, err := ParseClientID(string(v))
if err != nil {
return fmt.Errorf("ClientID: UnmarshalForm: %w", err)
}
*cid = *clientID
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (cid *ClientID) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("ClientID: UnmarshalJSON: %w", err)
}
clientID, err := ParseClientID(src)
if err != nil {
return fmt.Errorf("ClientID: UnmarshalJSON: %w", err)
}
*cid = *clientID
return nil
}
// MarshalForm implements custom marshler for JSON.
func (cid ClientID) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(cid.String())), nil
}
// URI returns copy of parsed *fasthttp.URI.
//
// WARN(toby3d): This copy MUST be released via fasthttp.ReleaseURI.
func (cid ClientID) URI() *http.URI {
uri := http.AcquireURI()
cid.clientID.CopyTo(uri)
return uri
}
// URL returns url.URL representation of client ID.
func (cid ClientID) URL() *url.URL {
return &url.URL{
ForceQuery: false,
Fragment: string(cid.clientID.Hash()),
Host: string(cid.clientID.Host()),
Opaque: "",
Path: string(cid.clientID.Path()),
RawFragment: "",
RawPath: string(cid.clientID.PathOriginal()),
RawQuery: string(cid.clientID.QueryString()),
Scheme: string(cid.clientID.Scheme()),
User: nil,
}
}
// String returns string representation of client ID.
func (cid ClientID) String() string {
return cid.clientID.String()
}

View File

@ -0,0 +1,104 @@
package domain_test
import (
"fmt"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseClientID(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in string
expError bool
}{
{name: "valid", in: "https://example.com/", expError: false},
{name: "valid path", in: "https://example.com/username", expError: false},
{name: "valid query", in: "https://example.com/users?id=100", expError: false},
{name: "valid port", in: "https://example.com:8443/", expError: false},
{name: "valid loopback", in: "https://127.0.0.1:8443/", expError: false},
{name: "missing scheme", in: "example.com", expError: true},
{name: "invalid scheme", in: "mailto:user@example.com", expError: true},
{name: "invalid double-dot path", in: "https://example.com/foo/../bar", expError: true},
{name: "invalid fragment", in: "https://example.com/#me", expError: true},
{name: "invalid user", in: "https://user:pass@example.com/", expError: true},
{name: "host is an IP address", in: "https://172.28.92.51/", expError: true},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := domain.ParseClientID(tc.in)
switch {
case err != nil && !tc.expError:
t.Errorf("ParseClientID(%s) = %+v, want nil", tc.in, err)
case err == nil && tc.expError:
t.Errorf("ParseClientID(%s) = %+v, want error", tc.in, err)
}
})
}
}
func TestClientID_UnmarshalForm(t *testing.T) {
t.Parallel()
cid := domain.TestClientID(t)
input := []byte(fmt.Sprint(cid))
result := new(domain.ClientID)
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != fmt.Sprint(cid) {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, cid)
}
}
func TestClientID_UnmarshalJSON(t *testing.T) {
t.Parallel()
cid := domain.TestClientID(t)
input := []byte(fmt.Sprintf(`"%s"`, cid))
result := new(domain.ClientID)
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != fmt.Sprint(cid) {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, cid)
}
}
func TestClientID_MarshalJSON(t *testing.T) {
t.Parallel()
cid := domain.TestClientID(t)
result, err := cid.MarshalJSON()
if err != nil {
t.Fatalf("%+v", err)
}
if string(result) != fmt.Sprintf(`"%s"`, cid) {
t.Errorf("MarshalJSON() = %s, want %s", result, fmt.Sprintf(`"%s"`, cid))
}
}
// TODO(toby3d): TestClientID_URI
// TODO(toby3d): TestClientID_URL
func TestClientID_String(t *testing.T) {
t.Parallel()
if cid := domain.TestClientID(t); cid.String() != fmt.Sprint(cid) {
t.Errorf("String() = %s, want %s", cid.String(), fmt.Sprint(cid))
}
}

View File

@ -0,0 +1,59 @@
package domain_test
import (
"fmt"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestClient_ValidateRedirectURI(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
for _, tc := range []struct {
name string
in *domain.URL
}{
{name: "client_id prefix", in: domain.TestURL(t, fmt.Sprint(client.ID, "/callback"))},
{name: "registered redirect_uri", in: client.RedirectURI[len(client.RedirectURI)-1]},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if result := client.ValidateRedirectURI(tc.in); !result {
t.Errorf("ValidateRedirectURI(%v) = %t, want %t", tc.in, result, true)
}
})
}
}
func TestClient_GetName(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
if result := client.GetName(); result != client.Name[0] {
t.Errorf("GetName() = %v, want %v", result, client.Name[0])
}
}
func TestClient_GetURL(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
if result := client.GetURL(); result != client.URL[0] {
t.Errorf("GetURL() = %v, want %v", result, client.URL[0])
}
}
func TestClient_GetLogo(t *testing.T) {
t.Parallel()
client := domain.TestClient(t)
if result := client.GetLogo(); result != client.Logo[0] {
t.Errorf("GetLogo() = %v, want %v", result, client.Logo[0])
}
}

View File

@ -0,0 +1,138 @@
package domain
//nolint: gosec // support old clients
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"hash"
"strconv"
"strings"
)
// CodeChallengeMethod represent a PKCE challenge method for validate verifier.
//
// NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety:
// https://threedots.tech/post/safer-enums-in-go/#struct-based-enums
type CodeChallengeMethod struct {
hash hash.Hash
uid string
}
//nolint: gochecknoglobals // structs cannot be constants
var (
CodeChallengeMethodUndefined = CodeChallengeMethod{
uid: "",
hash: nil,
}
CodeChallengeMethodPLAIN = CodeChallengeMethod{
uid: "PLAIN",
hash: nil,
}
CodeChallengeMethodMD5 = CodeChallengeMethod{
uid: "MD5",
hash: md5.New(), //nolint: gosec // support old clients
}
CodeChallengeMethodS1 = CodeChallengeMethod{
uid: "S1",
hash: sha1.New(), //nolint: gosec // support old clients
}
CodeChallengeMethodS256 = CodeChallengeMethod{
uid: "S256",
hash: sha256.New(),
}
CodeChallengeMethodS512 = CodeChallengeMethod{
uid: "S512",
hash: sha512.New(),
}
)
var ErrCodeChallengeMethodUnknown error = NewError(
ErrorCodeInvalidRequest,
"unknown code_challene_method",
"https://indieauth.net/source/#authorization-request",
)
//nolint: gochecknoglobals // maps cannot be constants
var uidsMethods = map[string]CodeChallengeMethod{
CodeChallengeMethodMD5.uid: CodeChallengeMethodMD5,
CodeChallengeMethodPLAIN.uid: CodeChallengeMethodPLAIN,
CodeChallengeMethodS1.uid: CodeChallengeMethodS1,
CodeChallengeMethodS256.uid: CodeChallengeMethodS256,
CodeChallengeMethodS512.uid: CodeChallengeMethodS512,
CodeChallengeMethodUndefined.uid: CodeChallengeMethodUndefined,
}
// ParseCodeChallengeMethod parse string identifier of code challenge method
// into struct enum.
func ParseCodeChallengeMethod(uid string) (CodeChallengeMethod, error) {
if method, ok := uidsMethods[strings.ToUpper(uid)]; ok {
return method, nil
}
return CodeChallengeMethodUndefined, fmt.Errorf("%w: %s", ErrCodeChallengeMethodUnknown, uid)
}
// UnmarshalForm implements custom unmarshler for form values.
func (ccm *CodeChallengeMethod) UnmarshalForm(v []byte) error {
method, err := ParseCodeChallengeMethod(string(v))
if err != nil {
return fmt.Errorf("CodeChallengeMethod: UnmarshalForm: %w", err)
}
*ccm = method
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (ccm *CodeChallengeMethod) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("CodeChallengeMethod: UnmarshalJSON: %w", err)
}
if *ccm, err = ParseCodeChallengeMethod(src); err != nil {
return fmt.Errorf("CodeChallengeMethod: UnmarshalJSON: %w", err)
}
return nil
}
func (ccm CodeChallengeMethod) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(ccm.uid)), nil
}
// String returns string representation of code challenge method.
func (ccm CodeChallengeMethod) String() string {
return ccm.uid
}
// Validate checks for a match to the verifier with the hashed version of the
// challenge via the chosen method.
func (ccm CodeChallengeMethod) Validate(codeChallenge, verifier string) bool {
if ccm.uid == CodeChallengeMethodUndefined.uid {
return false
}
if ccm.uid == CodeChallengeMethodPLAIN.uid {
return codeChallenge == verifier
}
hash := ccm.hash
hash.Reset() // WARN(toby3d): even hash.New contains something.
if _, err := hash.Write([]byte(verifier)); err != nil {
return false
}
return codeChallenge == base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}

View File

@ -0,0 +1,175 @@
package domain_test
//nolint: gosec // support old clients
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"hash"
"testing"
"github.com/brianvoe/gofakeit/v6"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/random"
)
func TestParseCodeChallengeMethod(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in string
out domain.CodeChallengeMethod
expError bool
}{
{name: "invalid", in: "und", out: domain.CodeChallengeMethodUndefined, expError: true},
{name: "PLAIN", in: "plain", out: domain.CodeChallengeMethodPLAIN, expError: false},
{name: "MD5", in: "Md5", out: domain.CodeChallengeMethodMD5, expError: false},
{name: "S1", in: "S1", out: domain.CodeChallengeMethodS1, expError: false},
{name: "S256", in: "S256", out: domain.CodeChallengeMethodS256, expError: false},
{name: "S512", in: "S512", out: domain.CodeChallengeMethodS512, expError: false},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result, err := domain.ParseCodeChallengeMethod(tc.in)
switch {
case err != nil && !tc.expError:
t.Errorf("ParseCodeChallengeMethod(%s) = %+v, want nil", tc.in, err)
case err == nil && tc.expError:
t.Errorf("ParseCodeChallengeMethod(%s) = %+v, want error", tc.in, err)
}
if result != tc.out {
t.Errorf("ParseCodeChallengeMethod(%s) = %v, want %v", tc.in, result, tc.out)
}
})
}
}
func TestCodeChallengeMethod_UnmarshalForm(t *testing.T) {
t.Parallel()
input := []byte("S256")
result := domain.CodeChallengeMethodUndefined
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.CodeChallengeMethodS256 {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, domain.CodeChallengeMethodS256)
}
}
func TestCodeChallengeMethod_UnmarshalJSON(t *testing.T) {
t.Parallel()
input := []byte(`"S256"`)
result := domain.CodeChallengeMethodUndefined
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.CodeChallengeMethodS256 {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, domain.CodeChallengeMethodS256)
}
}
func TestCodeChallengeMethod_String(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in domain.CodeChallengeMethod
out string
}{
{name: "plain", in: domain.CodeChallengeMethodPLAIN, out: "PLAIN"},
{name: "md5", in: domain.CodeChallengeMethodMD5, out: "MD5"},
{name: "s1", in: domain.CodeChallengeMethodS1, out: "S1"},
{name: "s256", in: domain.CodeChallengeMethodS256, out: "S256"},
{name: "s512", in: domain.CodeChallengeMethodS512, out: "S512"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := tc.in.String()
if result != tc.out {
t.Errorf("String() = %v, want %v", result, tc.out)
}
})
}
}
//nolint: gosec // support old clients
func TestCodeChallengeMethod_Validate(t *testing.T) {
t.Parallel()
verifier, err := random.String(gofakeit.Number(43, 128))
if err != nil {
t.Fatalf("%+v", err)
}
for _, tc := range []struct {
hash hash.Hash
in domain.CodeChallengeMethod
name string
expError bool
}{
{name: "invalid", in: domain.CodeChallengeMethodS256, hash: md5.New(), expError: true},
{name: "MD5", in: domain.CodeChallengeMethodMD5, hash: md5.New(), expError: false},
{name: "plain", in: domain.CodeChallengeMethodPLAIN, hash: nil, expError: false},
{name: "S1", in: domain.CodeChallengeMethodS1, hash: sha1.New(), expError: false},
{name: "S256", in: domain.CodeChallengeMethodS256, hash: sha256.New(), expError: false},
{name: "S512", in: domain.CodeChallengeMethodS512, hash: sha512.New(), expError: false},
{name: "undefined", in: domain.CodeChallengeMethodUndefined, hash: nil, expError: true},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var codeChallenge string
switch tc.in {
case domain.CodeChallengeMethodUndefined, domain.CodeChallengeMethodPLAIN:
codeChallenge = verifier
default:
hash := tc.hash
hash.Reset()
if _, err := hash.Write([]byte(verifier)); err != nil {
t.Error(err)
}
codeChallenge = base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
if result := tc.in.Validate(codeChallenge, verifier); result != !tc.expError {
t.Errorf("Validate(%s, %s) = %t, want %t", codeChallenge, verifier, result, tc.expError)
}
})
}
}
func TestCodeChallengeMethod_Validate_IndieAuth(t *testing.T) {
t.Parallel()
if ok := domain.CodeChallengeMethodS256.Validate(
"ALiMNf5FvF_LIWLhSkd9tjPKh3PEmai2OrdDBzrVZ3M",
"6f535c952339f0670311b4bbec5c41c00805e83291fc7eb15ca4963f82a4d57595787dcc6ee90571fb7789cbd521fe0178ed",
); !ok {
t.Errorf("Validate(%s, %s) = %t, want %t", "ALiMNf5FvF_LIWLhSkd9tjPKh3PEmai2OrdDBzrVZ3M",
"6f535c952339f0670311b4bbec5c41c00805e83291fc7eb15ca4963f82a4d57595787dcc6ee90571fb7789cbd521"+
"fe0178ed", ok, true)
}
}

141
internal/domain/config.go Normal file
View File

@ -0,0 +1,141 @@
package domain
import (
"net"
"path/filepath"
"testing"
"time"
"github.com/valyala/fasttemplate"
)
type (
Config struct {
Code ConfigCode `yaml:"code"`
Database ConfigDatabase `yaml:"database"`
IndieAuth ConfigIndieAuth `yaml:"indieAuth"`
JWT ConfigJWT `yaml:"jwt"`
Server ConfigServer `yaml:"server"`
TicketAuth ConfigTicketAuth `yaml:"ticketAuth"`
Name string `yaml:"name"`
RunMode string `yaml:"runMode"`
}
ConfigServer struct {
CertificateFile string `yaml:"certFile"`
Domain string `yaml:"domain"`
Host string `yaml:"host"`
KeyFile string `yaml:"keyFile"`
Port string `yaml:"port"`
Protocol string `yaml:"protocol"`
RootURL string `yaml:"rootUrl"`
StaticRootPath string `yaml:"staticRootPath"`
StaticURLPrefix string `yaml:"staticUrlPrefix"`
EnablePprof bool `yaml:"enablePprof"`
}
ConfigDatabase struct {
Path string `yaml:"path"`
Type string `yaml:"type"` // memory
}
// Configuration of a one-time code after giving permission to an
// application. The client needs to request the server with this code to
// exchange it for a token or user information.
ConfigCode struct {
Expiry time.Duration `yaml:"expiry"` // 10m
Length int `yaml:"length"` // 32
}
ConfigJWT struct {
Expiry time.Duration `yaml:"expiry"` // 1h
Algorithm string `yaml:"algorithm"` // HS256
Secret string `yaml:"secret"`
NonceLength int `yaml:"nonceLength"` // 22
}
ConfigIndieAuth struct {
Password string `yaml:"password"`
Username string `yaml:"username"`
Enabled bool `yaml:"enabled"` // true
}
ConfigTicketAuth struct {
Expiry time.Duration `yaml:"expiry"` // 1m
Length int `yaml:"length"` // 24
}
ConfigRelMeAuth struct {
Providers []ConfigRelMeAuthProvider `yaml:"providers"`
Enabled bool `yaml:"enabled"` // true
}
ConfigRelMeAuthProvider struct {
ID string `yaml:"id"`
Secret string `yaml:"secret"`
Type string `yaml:"type"`
}
)
// TestConfig returns a valid config for tests.
//nolint: gomnd // testing domain can contains non-standart values
func TestConfig(tb testing.TB) *Config {
tb.Helper()
return &Config{
Name: "IndieAuth",
RunMode: "dev",
Server: ConfigServer{
CertificateFile: filepath.Join("https", "cert.pem"),
Domain: "localhost",
EnablePprof: false,
Host: "0.0.0.0",
KeyFile: filepath.Join("https", "key.pem"),
Port: "3000",
Protocol: "http",
RootURL: "{{protocol}}://{{domain}}:{{port}}/",
StaticRootPath: "/",
StaticURLPrefix: "/static",
},
Database: ConfigDatabase{
Type: "memory",
Path: "",
},
Code: ConfigCode{
Expiry: 10 * time.Minute,
Length: 32,
},
JWT: ConfigJWT{
Expiry: time.Hour,
NonceLength: 22,
Secret: "hackme",
Algorithm: "HS256",
},
IndieAuth: ConfigIndieAuth{
Enabled: true,
Username: "user",
Password: "password",
},
TicketAuth: ConfigTicketAuth{
Expiry: time.Minute,
Length: 24,
},
}
}
// GetAddress return host:port address.
func (cs ConfigServer) GetAddress() string {
return net.JoinHostPort(cs.Host, cs.Port)
}
// GetRootURL returns generated root URL from template RootURL.
func (cs ConfigServer) GetRootURL() string {
return fasttemplate.ExecuteString(cs.RootURL, `{{`, `}}`, map[string]interface{}{
"domain": cs.Domain,
"host": cs.Host,
"port": cs.Port,
"protocol": cs.Protocol,
"staticRootPath": cs.StaticRootPath,
"staticUrlPrefix": cs.StaticURLPrefix,
})
}

View File

@ -0,0 +1,29 @@
package domain_test
import (
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestConfigServer_GetAddress(t *testing.T) {
t.Parallel()
config := domain.TestConfig(t)
expResult := config.Server.Host + ":" + config.Server.Port
if result := config.Server.GetAddress(); result != expResult {
t.Errorf("GetAddress() = %s, want %s", result, expResult)
}
}
func TestConfigServer_GetRootURL(t *testing.T) {
t.Parallel()
config := domain.TestConfig(t)
expResult := config.Server.Protocol + "://" + config.Server.Domain + ":" + config.Server.Port + "/"
if result := config.Server.GetRootURL(); result != expResult {
t.Errorf("GetRootURL() = %s, want %s", result, expResult)
}
}

3
internal/domain/doc.go Normal file
View File

@ -0,0 +1,3 @@
// The domain package contains models, objects and entities used in all layers
// of the project architecture.
package domain

81
internal/domain/email.go Normal file
View File

@ -0,0 +1,81 @@
package domain
import (
"fmt"
"strconv"
"strings"
"testing"
)
// Email represent email identifier.
type Email struct {
user string
host string
subAddress string
}
const DefaultEmailPartsLength int = 2
var ErrEmailInvalid error = NewError(ErrorCodeInvalidRequest, "cannot parse email", "")
// ParseEmail parse strings to email identifier.
func ParseEmail(src string) (*Email, error) {
parts := strings.Split(strings.TrimPrefix(src, "mailto:"), "@")
if len(parts) != DefaultEmailPartsLength {
return nil, ErrEmailInvalid
}
result := &Email{
user: parts[0],
host: parts[1],
subAddress: "",
}
if userParts := strings.SplitN(parts[0], `+`, DefaultEmailPartsLength); len(userParts) > 1 {
result.user = userParts[0]
result.subAddress = userParts[1]
}
return result, nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (e *Email) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("Email: UnmarshalJSON: %w", err)
}
email, err := ParseEmail(src)
if err != nil {
return fmt.Errorf("Email: UnmarshalJSON: %w", err)
}
*e = *email
return nil
}
func (e Email) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(e.String())), nil
}
// TestEmail returns valid random generated email identifier.
func TestEmail(tb testing.TB) *Email {
tb.Helper()
return &Email{
user: "user",
subAddress: "",
host: "example.com",
}
}
// String returns string representation of email identifier.
func (e Email) String() string {
if e.subAddress == "" {
return e.user + "@" + e.host
}
return e.user + "+" + e.subAddress + "@" + e.host
}

View File

@ -0,0 +1,46 @@
package domain_test
import (
"fmt"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseEmail(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in string
out string
}{
{name: "simple", in: "user@example.com", out: "user@example.com"},
{name: "subAddress", in: "user+suffix@example.com", out: "user+suffix@example.com"},
{name: "mailto prefix", in: "mailto:user@example.com", out: "user@example.com"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result, err := domain.ParseEmail(tc.in)
if err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != tc.out {
t.Errorf("ParseEmail(%s) = %s, want %s", tc.in, result, tc.out)
}
})
}
}
func TestEmail_String(t *testing.T) {
t.Parallel()
email := domain.TestEmail(t) //nolint: ifshort
if result := email.String(); result != fmt.Sprint(email) {
t.Errorf("String() = %v, want %v", result, email)
}
}

300
internal/domain/error.go Normal file
View File

@ -0,0 +1,300 @@
package domain
import (
"fmt"
"strconv"
"strings"
http "github.com/valyala/fasthttp"
"golang.org/x/xerrors"
)
type (
// Error describes the format of a typical IndieAuth error.
//nolint: tagliatelle // RFC 6749 section 5.2
Error struct {
// A single error code.
Code ErrorCode `json:"error"`
// Human-readable ASCII text providing additional information, used to
// assist the client developer in understanding the error that occurred.
Description string `json:"error_description,omitempty"`
// A URI identifying a human-readable web page with information about
// the error, used to provide the client developer with additional
// information about the error.
URI string `json:"error_uri,omitempty"`
// REQUIRED if a "state" parameter was present in the client
// authorization request. The exact value received from the
// client.
State string `json:"-"`
frame xerrors.Frame `json:"-"`
}
// ErrorCode represent error code described in RFC 6749.
ErrorCode struct {
uid string
status int
}
)
var (
// ErrorCodeUndefined describes an unrecognized error code.
ErrorCodeUndefined = ErrorCode{
uid: "",
status: 0,
}
// ErrorCodeAccessDenied describes the access_denied error code.
//
// RFC 6749 section 4.1.2.1: The resource owner or authorization server
// denied the request.
ErrorCodeAccessDenied = ErrorCode{
uid: "access_denied",
status: 0, // TODO(toby3d)
}
// ErrorCodeInvalidClient describes the invalid_client error code.
//
// RFC 6749 section 5.2: Client authentication failed (e.g., unknown
// client, no client authentication included, or unsupported
// authentication method).
//
// The authorization server MAY return an HTTP 401 (Unauthorized) status
// code to indicate which HTTP authentication schemes are supported.
//
// If the client attempted to authenticate via the "Authorization"
// request header field, the authorization server MUST respond with an
// HTTP 401 (Unauthorized) status code and include the
// "WWW-Authenticate" response header field matching the authentication
// scheme used by the client.
ErrorCodeInvalidClient = ErrorCode{
uid: "invalid_client",
status: 0, // TODO(toby3d)
}
// ErrorCodeInvalidGrant describes the invalid_grant error code.
//
// RFC 6749 section 5.2: The provided authorization grant (e.g.,
// authorization code, resource owner credentials) or refresh token is
// invalid, expired, revoked, does not match the redirection URI used in
// the authorization request, or was issued to another client.
ErrorCodeInvalidGrant = ErrorCode{
uid: "invalid_grant",
status: 0, // TODO(toby3d)
}
// ErrorCodeInvalidRequest describes the invalid_request error code.
//
// IndieAuth: The request is not valid.
//
// RFC 6749 section 4.1.2.1: The request is missing a required
// parameter, includes an invalid parameter value, includes a parameter
// more than once, or is otherwise malformed.
//
// RFC 6749 section 5.2: The request is missing a required parameter,
// includes an unsupported parameter value (other than grant type),
// repeats a parameter, includes multiple credentials, utilizes more
// than one mechanism for authenticating the client, or is otherwise
// malformed.
ErrorCodeInvalidRequest = ErrorCode{
uid: "invalid_request",
status: http.StatusBadRequest,
}
// ErrorCodeInvalidScope describes the invalid_scope error code.
//
// RFC 6749 section 4.1.2.1: The requested scope is invalid, unknown, or
// malformed.
//
// RFC 6749 section 5.2: The requested scope is invalid, unknown,
// malformed, or exceeds the scope granted by the resource owner.
ErrorCodeInvalidScope = ErrorCode{
uid: "invalid_scope",
status: 0, // TODO(toby3d)
}
// ErrorCodeServerError describes the server_error error code.
//
// RFC 6749 section 4.1.2.1: The authorization server encountered an
// unexpected condition that prevented it from fulfilling the request.
// (This error code is needed because a 500 Internal Server Error HTTP
// status code cannot be returned to the client via an HTTP redirect.)
ErrorCodeServerError = ErrorCode{
uid: "server_error",
status: 0, // TODO(toby3d)
}
// ErrorCodeTemporarilyUnavailable describes the temporarily_unavailable error code.
//
// RFC 6749 section 4.1.2.1: The authorization server is currently
// unable to handle the request due to a temporary overloading or
// maintenance of the server. (This error code is needed because a 503
// Service Unavailable HTTP status code cannot be returned to the client
// via an HTTP redirect.)
ErrorCodeTemporarilyUnavailable = ErrorCode{
uid: "temporarily_unavailable",
status: 0, // TODO(toby3d)
}
// ErrorCodeUnauthorizedClient describes the unauthorized_client error code.
//
// RFC 6749 section 4.1.2.1: The client is not authorized to request an
// authorization code using this method.
//
// RFC 6749 section 5.2: The authenticated client is not authorized to
// use this authorization grant type.
ErrorCodeUnauthorizedClient = ErrorCode{
uid: "unauthorized_client",
status: 0, // TODO(toby3d)
}
// ErrorCodeUnsupportedGrantType describes the unsupported_grant_type error code.
//
// RFC 6749 section 5.2: The authorization grant type is not supported
// by the authorization server.
ErrorCodeUnsupportedGrantType = ErrorCode{
uid: "unsupported_grant_type",
status: 0, // TODO(toby3d)
}
// ErrorCodeUnsupportedResponseType describes the unsupported_response_type error code.
//
// RFC 6749 section 4.1.2.1: The authorization server does not support
// obtaining an authorization code using this method.
ErrorCodeUnsupportedResponseType = ErrorCode{
uid: "unsupported_response_type",
status: 0, // TODO(toby3d)
}
// ErrorCodeInvalidToken describes the invalid_token error code.
//
// IndieAuth: The access token provided is expired, revoked, or invalid.
ErrorCodeInvalidToken = ErrorCode{
uid: "invalid_token",
status: http.StatusUnauthorized,
}
// ErrorCodeInsufficientScope describes the insufficient_scope error code.
//
// IndieAuth: The request requires higher privileges than provided.
ErrorCodeInsufficientScope = ErrorCode{
uid: "insufficient_scope",
status: http.StatusForbidden,
}
)
var ErrErrorCodeUnknown error = NewError(ErrorCodeInvalidRequest, "unknown error code", "")
//nolint: gochecknoglobals // maps cannot be constants
var uidsErrorCodes = map[string]ErrorCode{
ErrorCodeAccessDenied.uid: ErrorCodeAccessDenied,
ErrorCodeInsufficientScope.uid: ErrorCodeInsufficientScope,
ErrorCodeInvalidClient.uid: ErrorCodeInvalidClient,
ErrorCodeInvalidGrant.uid: ErrorCodeInvalidGrant,
ErrorCodeInvalidRequest.uid: ErrorCodeInvalidRequest,
ErrorCodeInvalidScope.uid: ErrorCodeInvalidScope,
ErrorCodeInvalidToken.uid: ErrorCodeInvalidToken,
ErrorCodeServerError.uid: ErrorCodeServerError,
ErrorCodeTemporarilyUnavailable.uid: ErrorCodeTemporarilyUnavailable,
ErrorCodeUnauthorizedClient.uid: ErrorCodeUnauthorizedClient,
ErrorCodeUnsupportedGrantType.uid: ErrorCodeUnsupportedGrantType,
ErrorCodeUnsupportedResponseType.uid: ErrorCodeUnsupportedResponseType,
}
// String returns a string representation of the error code.
func (ec ErrorCode) String() string {
return ec.uid
}
// UnmarshalForm implements custom unmarshler for form values.
func (ec *ErrorCode) UnmarshalForm(v []byte) error {
code, ok := uidsErrorCodes[strings.ToLower(string(v))]
if !ok {
return fmt.Errorf("UnmarshalForm: %w", ErrErrorCodeUnknown)
}
*ec = code
return nil
}
// MarshalJSON encodes the error code into its string representation in JSON.
func (ec ErrorCode) MarshalJSON() ([]byte, error) {
return []byte(strconv.QuoteToASCII(ec.uid)), nil
}
// Error returns a string representation of the error, satisfying the error
// interface.
func (e Error) Error() string {
return fmt.Sprint(e)
}
// Format prints the stack as error detail.
func (e Error) Format(state fmt.State, r rune) {
xerrors.FormatError(e, state, r)
}
// FormatError prints the receiver's error, if any.
func (e Error) FormatError(printer xerrors.Printer) error {
printer.Print(e.Code)
if e.Description != "" {
printer.Print(": ", e.Description)
}
if !printer.Detail() {
return nil
}
e.frame.Format(printer)
return nil
}
// SetReirectURI sets fasthttp.QueryArgs with the request state, code,
// description and error URI in the provided fasthttp.URI.
func (e Error) SetReirectURI(uri *http.URI) {
if uri == nil {
return
}
for key, val := range map[string]string{
"error": e.Code.String(),
"error_description": e.Description,
"error_uri": e.URI,
"state": e.State,
} {
if val == "" {
continue
}
uri.QueryArgs().Set(key, val)
}
}
// NewError creates a new Error with the stack pointing to the function call
// line number.
//
// If no code or ErrorCodeUndefined is provided, ErrorCodeAccessDenied will be
// used instead.
func NewError(code ErrorCode, description, uri string, requestState ...string) *Error {
if code == ErrorCodeUndefined {
code = ErrorCodeAccessDenied
}
var state string
if len(requestState) > 0 {
state = requestState[0]
}
return &Error{
Code: code,
Description: description,
URI: uri,
State: state,
frame: xerrors.Caller(1),
}
}

View File

@ -0,0 +1,28 @@
package domain_test
import (
"fmt"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func ExampleNewError() {
fmt.Printf("%v", domain.NewError(domain.ErrorCodeInvalidRequest, "client_id MUST be provided", ""))
// Output: invalid_request: client_id MUST be provided
}
func TestErrorCode_UnmarshalForm(t *testing.T) {
t.Parallel()
input := []byte("access_denied")
result := domain.ErrorCodeUndefined
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.ErrorCodeAccessDenied {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, domain.ErrorCodeAccessDenied)
}
}

View File

@ -0,0 +1,85 @@
package domain
import (
"fmt"
"strconv"
"strings"
)
// GrantType represent fixed grant_type parameter.
//
// NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety:
// https://threedots.tech/post/safer-enums-in-go/#struct-based-enums
type GrantType struct {
uid string
}
//nolint: gochecknoglobals // structs cannot be constants
var (
GrantTypeUndefined = GrantType{uid: ""}
GrantTypeAuthorizationCode = GrantType{uid: "authorization_code"}
GrantTypeRefreshToken = GrantType{uid: "refresh_token"}
// TicketAuth extension.
GrantTypeTicket = GrantType{uid: "ticket"}
)
var ErrGrantTypeUnknown error = NewError(
ErrorCodeInvalidGrant,
"unknown grant type",
"",
)
//nolint: gochecknoglobals // maps cannot be constants
var uidsGrantTypes = map[string]GrantType{
GrantTypeAuthorizationCode.uid: GrantTypeAuthorizationCode,
GrantTypeRefreshToken.uid: GrantTypeRefreshToken,
GrantTypeTicket.uid: GrantTypeTicket,
}
// ParseGrantType parse grant_type value as GrantType struct enum.
func ParseGrantType(uid string) (GrantType, error) {
if grantType, ok := uidsGrantTypes[strings.ToLower(uid)]; ok {
return grantType, nil
}
return GrantTypeUndefined, fmt.Errorf("%w: %s", ErrGrantTypeUnknown, uid)
}
// UnmarshalForm implements custom unmarshler for form values.
func (gt *GrantType) UnmarshalForm(src []byte) error {
responseType, err := ParseGrantType(string(src))
if err != nil {
return fmt.Errorf("GrantType: UnmarshalForm: %w", err)
}
*gt = responseType
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (gt *GrantType) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("GrantType: UnmarshalJSON: %w", err)
}
responseType, err := ParseGrantType(src)
if err != nil {
return fmt.Errorf("GrantType: UnmarshalJSON: %w", err)
}
*gt = responseType
return nil
}
func (gt GrantType) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(gt.uid)), nil
}
// String returns string representation of grant type.
func (gt GrantType) String() string {
return gt.uid
}

View File

@ -0,0 +1,89 @@
//nolint: dupl
package domain_test
import (
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseGrantType(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
in string
out domain.GrantType
}{
{in: "authorization_code", out: domain.GrantTypeAuthorizationCode},
{in: "ticket", out: domain.GrantTypeTicket},
} {
tc := tc
t.Run(tc.in, func(t *testing.T) {
t.Parallel()
result, err := domain.ParseGrantType(tc.in)
if err != nil {
t.Fatalf("%+v", err)
}
if result != tc.out {
t.Errorf("ParseGrantType(%s) = %v, want %v", tc.in, result, tc.out)
}
})
}
}
func TestGrantType_UnmarshalForm(t *testing.T) {
t.Parallel()
input := []byte("authorization_code")
result := domain.GrantTypeUndefined
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.GrantTypeAuthorizationCode {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, domain.GrantTypeAuthorizationCode)
}
}
func TestGrantType_UnmarshalJSON(t *testing.T) {
t.Parallel()
input := []byte(`"authorization_code"`)
result := domain.GrantTypeUndefined
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.GrantTypeAuthorizationCode {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, domain.GrantTypeAuthorizationCode)
}
}
func TestGrantType_String(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in domain.GrantType
out string
}{
{name: "authorization_code", in: domain.GrantTypeAuthorizationCode, out: "authorization_code"},
{name: "ticket", in: domain.GrantTypeTicket, out: "ticket"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := tc.in.String()
if result != tc.out {
t.Errorf("String() = %v, want %v", result, tc.out)
}
})
}
}

188
internal/domain/me.go Normal file
View File

@ -0,0 +1,188 @@
package domain
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"testing"
http "github.com/valyala/fasthttp"
)
// Me is a URL user identifier.
type Me struct {
id *http.URI
}
// ParseMe parse string as me URL identifier.
//nolint: funlen, cyclop
func ParseMe(raw string) (*Me, error) {
id := http.AcquireURI()
if err := id.Parse(nil, []byte(raw)); err != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
err.Error(),
"https://indieauth.net/source/#user-profile-url",
"",
)
}
scheme := string(id.Scheme())
if scheme != "http" && scheme != "https" {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile URL MUST have either an https or http scheme",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
path := string(id.PathOriginal())
if path == "" || strings.Contains(path, "/.") || strings.Contains(path, "/..") {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile URL MUST contain a path component (/ is a valid path), MUST NOT contain single-dot "+
"or double-dot path segments",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
if id.Hash() != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile URL MUST NOT contain a fragment component",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
if id.Username() != nil || id.Password() != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile URL MUST NOT contain a username or password component",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
domain := string(id.Host())
if domain == "" {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile host name MUST be a domain name",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
if _, port, _ := net.SplitHostPort(domain); port != "" {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile MUST NOT contain a port",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
if net.ParseIP(domain) != nil {
return nil, NewError(
ErrorCodeInvalidRequest,
"profile MUST NOT be ipv4 or ipv6 addresses",
"https://indieauth.net/source/#user-profile-url",
"",
)
}
return &Me{id: id}, nil
}
// TestMe returns valid random generated me for tests.
func TestMe(tb testing.TB, src string) *Me {
tb.Helper()
me, err := ParseMe(src)
if err != nil {
tb.Fatal(err)
}
return me
}
// UnmarshalForm implements custom unmarshler for form values.
func (m *Me) UnmarshalForm(v []byte) error {
me, err := ParseMe(string(v))
if err != nil {
return fmt.Errorf("Me: UnmarshalForm: %w", err)
}
*m = *me
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (m *Me) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("Me: UnmarshalJSON: %w", err)
}
me, err := ParseMe(src)
if err != nil {
return fmt.Errorf("Me: UnmarshalJSON: %w", err)
}
*m = *me
return nil
}
// MarshalJSON implements custom marshler for JSON.
func (m Me) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(m.String())), nil
}
// URI returns copy of parsed me in *fasthttp.URI representation.
// This copy MUST be released via fasthttp.ReleaseURI.
func (m Me) URI() *http.URI {
if m.id == nil {
return nil
}
u := http.AcquireURI()
m.id.CopyTo(u)
return u
}
// URL returns copy of parsed me in *url.URL representation.
func (m Me) URL() *url.URL {
if m.id == nil {
return nil
}
return &url.URL{
ForceQuery: false,
Fragment: string(m.id.Hash()),
Host: string(m.id.Host()),
Opaque: "",
Path: string(m.id.Path()),
RawFragment: "",
RawPath: string(m.id.PathOriginal()),
RawQuery: string(m.id.QueryString()),
Scheme: string(m.id.Scheme()),
User: nil,
}
}
// String returns string representation of me.
func (m Me) String() string {
if m.id == nil {
return ""
}
return m.id.String()
}

104
internal/domain/me_test.go Normal file
View File

@ -0,0 +1,104 @@
package domain_test
import (
"fmt"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseMe(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in string
expError bool
}{
{name: "valid", in: "https://example.com/", expError: false},
{name: "valid path", in: "https://example.com/username", expError: false},
{name: "valid query", in: "https://example.com/users?id=100", expError: false},
{name: "missing scheme", in: "example.com", expError: true},
{name: "invalid scheme", in: "mailto:user@example.com", expError: true},
{name: "contains double-dot path", in: "https://example.com/foo/../bar", expError: true},
{name: "contains fragment", in: "https://example.com/#me", expError: true},
{name: "contains user", in: "https://user:pass@example.com/", expError: true},
{name: "contains port", in: "https://example.com:8443/", expError: true},
{name: "host is an IP address", in: "https://172.28.92.51/", expError: true},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := domain.ParseMe(tc.in)
switch {
case err != nil && !tc.expError:
t.Errorf("ParseMe(%s) = %+v, want nil", tc.in, err)
case err == nil && tc.expError:
t.Errorf("ParseMe(%s) = %+v, want error", tc.in, err)
}
})
}
}
func TestMe_UnmarshalForm(t *testing.T) {
t.Parallel()
me := domain.TestMe(t, "https://user.example.com/")
input := []byte(fmt.Sprint(me))
result := new(domain.Me)
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != fmt.Sprint(me) {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, me)
}
}
func TestMe_UnmarshalJSON(t *testing.T) {
t.Parallel()
me := domain.TestMe(t, "https://user.example.com/")
input := []byte(fmt.Sprintf(`"%s"`, me))
result := new(domain.Me)
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != fmt.Sprint(me) {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, me)
}
}
func TestMe_MarshalJSON(t *testing.T) {
t.Parallel()
me := domain.TestMe(t, "https://user.example.com/")
result, err := me.MarshalJSON()
if err != nil {
t.Fatalf("%+v", err)
}
if string(result) != fmt.Sprintf(`"%s"`, me) {
t.Errorf("MarshalJSON() = %s, want %s", result, fmt.Sprintf(`"%s"`, me))
}
}
// TODO(toby3d): TestMe_URI
// TODO(toby3d): TestMe_URL
func TestMe_String(t *testing.T) {
t.Parallel()
me := domain.TestMe(t, "https://user.example.com/")
if result := me.String(); result != fmt.Sprint(me) {
t.Errorf("Strig() = %s, want %s", result, fmt.Sprint(me))
}
}

124
internal/domain/metadata.go Normal file
View File

@ -0,0 +1,124 @@
package domain
import "testing"
type Metadata struct {
// The server's issuer identifier. The issuer identifier is a URL that
// uses the "https" scheme and has no query or fragment components. The
// identifier MUST be a prefix of the indieauth-metadata URL. e.g. for
// an indieauth-metadata endpoint
// https://example.com/.well-known/oauth-authorization-server, the
// issuer URL could be https://example.com/, or for a metadata URL of
// https://example.com/wp-json/indieauth/1.0/metadata, the issuer URL
// could be https://example.com/wp-json/indieauth/1.0
Issuer *ClientID
// The Authorization Endpoint.
AuthorizationEndpoint *URL
// The Token Endpoint.
TokenEndpoint *URL
// The Ticket Endpoint.
TicketEndpoint *URL
// The Micropub Endpoint.
MicropubEndpoint *URL
// The Microsub Endpoint.
MicrosubEndpoint *URL
// The Introspection Endpoint.
IntrospectionEndpoint *URL
// The Revocation Endpoint.
RevocationEndpoint *URL
// The User Info Endpoint.
UserinfoEndpoint *URL
// URL of a page containing human-readable information that developers
// might need to know when using the server. This might be a link to the
// IndieAuth spec or something more personal to your implementation.
ServiceDocumentation *URL
// JSON array containing scope values supported by the IndieAuth server.
// Servers MAY choose not to advertise some supported scope values even
// when this parameter is used.
ScopesSupported Scopes
// JSON array containing the response_type values supported. This
// differs from RFC8414 in that this parameter is OPTIONAL and that, if
// omitted, the default is code.
ResponseTypesSupported []ResponseType
// JSON array containing grant type values supported. If omitted, the
// default value differs from RFC8414 and is authorization_code.
GrantTypesSupported []GrantType
// JSON array containing the methods supported for PKCE. This parameter
// parameter differs from RFC8414 in that it is not optional as PKCE is
// REQUIRED.
CodeChallengeMethodsSupported []CodeChallengeMethod
// List of client authentication methods supported by this introspection endpoint.
IntrospectionEndpointAuthMethodsSupported []string // ["Bearer"]
RevocationEndpointAuthMethodsSupported []string // ["none"]
// Boolean parameter indicating whether the authorization server
// provides the iss parameter. If omitted, the default value is false.
// As the iss parameter is REQUIRED, this is provided for compatibility
// with OAuth 2.0 servers implementing the parameter.
AuthorizationResponseIssParameterSupported bool
}
// TestMetadata returns valid random generated Metadata for tests.
func TestMetadata(tb testing.TB) *Metadata {
tb.Helper()
return &Metadata{
Issuer: TestClientID(tb),
AuthorizationEndpoint: TestURL(tb, "https://indieauth.example.com/auth"),
TokenEndpoint: TestURL(tb, "https://indieauth.example.com/token"),
TicketEndpoint: TestURL(tb, "https://auth.example.org/ticket"),
MicropubEndpoint: TestURL(tb, "https://micropub.example.com/"),
MicrosubEndpoint: TestURL(tb, "https://microsub.example.com/"),
IntrospectionEndpoint: TestURL(tb, "https://indieauth.example.com/introspect"),
RevocationEndpoint: TestURL(tb, "https://indieauth.example.com/revocation"),
UserinfoEndpoint: TestURL(tb, "https://indieauth.example.com/userinfo"),
ServiceDocumentation: TestURL(tb, "https://indieauth.net/draft/"),
ScopesSupported: Scopes{
ScopeBlock,
ScopeChannels,
ScopeCreate,
ScopeDelete,
ScopeDraft,
ScopeEmail,
ScopeFollow,
ScopeMedia,
ScopeMute,
ScopeProfile,
ScopeRead,
ScopeUpdate,
},
ResponseTypesSupported: []ResponseType{
ResponseTypeCode,
ResponseTypeID,
},
GrantTypesSupported: []GrantType{
GrantTypeAuthorizationCode,
GrantTypeTicket,
},
CodeChallengeMethodsSupported: []CodeChallengeMethod{
CodeChallengeMethodMD5,
CodeChallengeMethodPLAIN,
CodeChallengeMethodS1,
CodeChallengeMethodS256,
CodeChallengeMethodS512,
},
IntrospectionEndpointAuthMethodsSupported: []string{"Bearer"},
RevocationEndpointAuthMethodsSupported: []string{"none"},
AuthorizationResponseIssParameterSupported: true,
}
}

View File

@ -0,0 +1,86 @@
package domain
import (
"testing"
)
// Profile describes the data about the user.
type Profile struct {
Photo []*URL `json:"photo,omitempty"`
URL []*URL `json:"url,omitempty"`
Email []*Email `json:"email,omitempty"`
Name []string `json:"name,omitempty"`
}
func NewProfile() *Profile {
return &Profile{
Photo: make([]*URL, 0),
URL: make([]*URL, 0),
Email: make([]*Email, 0),
Name: make([]string, 0),
}
}
// TestProfile returns a valid Profile with the generated test data filled in.
func TestProfile(tb testing.TB) *Profile {
tb.Helper()
return &Profile{
Email: []*Email{TestEmail(tb)},
Name: []string{"Example User"},
Photo: []*URL{TestURL(tb, "https://user.example.net/photo.jpg")},
URL: []*URL{TestURL(tb, "https://user.example.net/")},
}
}
func (p Profile) HasName() bool {
return len(p.Name) > 0
}
// GetName safe returns first name, if any.
func (p Profile) GetName() string {
if len(p.Name) == 0 {
return ""
}
return p.Name[0]
}
func (p Profile) HasURL() bool {
return len(p.URL) > 0
}
// GetURL safe returns first URL, if any.
func (p Profile) GetURL() *URL {
if len(p.URL) == 0 {
return nil
}
return p.URL[0]
}
func (p Profile) HasPhoto() bool {
return len(p.Photo) > 0
}
// GetPhoto safe returns first photo, if any.
func (p Profile) GetPhoto() *URL {
if len(p.Photo) == 0 {
return nil
}
return p.Photo[0]
}
func (p Profile) HasEmail() bool {
return len(p.Email) > 0
}
// GetEmail safe returns first email, if any.
func (p Profile) GetEmail() *Email {
if len(p.Email) == 0 {
return nil
}
return p.Email[0]
}

109
internal/domain/provider.go Normal file
View File

@ -0,0 +1,109 @@
package domain
import (
"path"
"strings"
http "github.com/valyala/fasthttp"
)
// Provider represent 3rd party RelMeAuth provider.
type Provider struct {
Scopes []string
AuthURL string
ClientID string
ClientSecret string
Name string
Photo string
RedirectURL string
TokenURL string
UID string
URL string
}
//nolint: gochecknoglobals // structs cannot be contants
var (
ProviderDirect = Provider{
AuthURL: "/authorize",
ClientID: "",
ClientSecret: "",
Name: "IndieAuth",
Photo: path.Join("static", "icon.svg"),
RedirectURL: path.Join("callback"),
Scopes: []string{},
TokenURL: "/token",
UID: "direct",
URL: "/",
}
ProviderTwitter = Provider{
AuthURL: "https://twitter.com/i/oauth2/authorize",
ClientID: "",
ClientSecret: "",
Name: "Twitter",
Photo: path.Join("static", "providers", "twitter.svg"),
RedirectURL: path.Join("callback", "twitter"),
Scopes: []string{"tweet.read", "users.read"},
TokenURL: "https://api.twitter.com/2/oauth2/token",
UID: "twitter",
URL: "https://twitter.com/",
}
ProviderGitHub = Provider{
AuthURL: "https://github.com/login/oauth/authorize",
ClientID: "",
ClientSecret: "",
Name: "GitHub",
Photo: path.Join("static", "providers", "github.svg"),
RedirectURL: path.Join("callback", "github"),
Scopes: []string{"read:user", "user:email"},
TokenURL: "https://github.com/login/oauth/access_token",
UID: "github",
URL: "https://github.com/",
}
ProviderGitLab = Provider{
AuthURL: "https://gitlab.com/oauth/authorize",
ClientID: "",
ClientSecret: "",
Name: "GitLab",
Photo: path.Join("static", "providers", "gitlab.svg"),
RedirectURL: path.Join("callback", "gitlab"),
Scopes: []string{"read_user"},
TokenURL: "https://gitlab.com/oauth/token",
UID: "gitlab",
URL: "https://gitlab.com/",
}
ProviderMastodon = Provider{
AuthURL: "https://mstdn.io/oauth/authorize",
ClientID: "",
ClientSecret: "",
Name: "Mastodon",
Photo: path.Join("static", "providers", "mastodon.svg"),
RedirectURL: path.Join("callback", "mastodon"),
Scopes: []string{"read:accounts"},
TokenURL: "https://mstdn.io/oauth/token",
UID: "mastodon",
URL: "https://mstdn.io/",
}
)
// AuthCodeURL returns URL for authorize user in RelMeAuth client.
func (p Provider) AuthCodeURL(state string) string {
uri := http.AcquireURI()
defer http.ReleaseURI(uri)
uri.Update(p.AuthURL)
for key, val := range map[string]string{
"client_id": p.ClientID,
"redirect_uri": p.RedirectURL,
"response_type": "code",
"scope": strings.Join(p.Scopes, " "),
"state": state,
} {
uri.QueryArgs().Set(key, val)
}
return uri.String()
}

View File

@ -0,0 +1,85 @@
package domain
import (
"fmt"
"strconv"
"strings"
)
// NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety:
// https://threedots.tech/post/safer-enums-in-go/#struct-based-enums
type ResponseType struct {
uid string
}
//nolint: gochecknoglobals // structs cannot be constants
var (
ResponseTypeUndefined = ResponseType{uid: ""}
// Deprecated(toby3d): Only accept response_type=code requests, and for
// backwards-compatible support, treat response_type=id requests as
// response_type=code requests:
// https://aaronparecki.com/2020/12/03/1/indieauth-2020#response-type
ResponseTypeID = ResponseType{uid: "id"}
// Indicates to the authorization server that an authorization code
// should be returned as the response:
// https://indieauth.net/source/#authorization-request-li-1
ResponseTypeCode = ResponseType{uid: "code"}
)
var ErrResponseTypeUnknown error = NewError(
ErrorCodeUnsupportedResponseType,
"unknown grant type",
"https://indieauth.net/source/#authorization-request",
)
// ParseResponseType parse string as response type struct enum.
func ParseResponseType(uid string) (ResponseType, error) {
switch strings.ToLower(uid) {
case ResponseTypeCode.uid:
return ResponseTypeCode, nil
case ResponseTypeID.uid:
return ResponseTypeID, nil
}
return ResponseTypeUndefined, fmt.Errorf("%w: %s", ErrResponseTypeUnknown, uid)
}
// UnmarshalForm implements custom unmarshler for form values.
func (rt *ResponseType) UnmarshalForm(src []byte) error {
responseType, err := ParseResponseType(string(src))
if err != nil {
return fmt.Errorf("ResponseType: UnmarshalForm: %w", err)
}
*rt = responseType
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (rt *ResponseType) UnmarshalJSON(v []byte) error {
uid, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("ResponseType: UnmarshalJSON: %w", err)
}
responseType, err := ParseResponseType(uid)
if err != nil {
return fmt.Errorf("ResponseType: UnmarshalJSON: %w", err)
}
*rt = responseType
return nil
}
func (rt ResponseType) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(rt.uid)), nil
}
// String returns string representation of response type.
func (rt ResponseType) String() string {
return rt.uid
}

View File

@ -0,0 +1,89 @@
//nolint: dupl
package domain_test
import (
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestResponseTypeType(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
in string
out domain.ResponseType
}{
{in: "id", out: domain.ResponseTypeID},
{in: "code", out: domain.ResponseTypeCode},
} {
tc := tc
t.Run(tc.in, func(t *testing.T) {
t.Parallel()
result, err := domain.ParseResponseType(tc.in)
if err != nil {
t.Fatalf("%+v", err)
}
if result != tc.out {
t.Errorf("ParseResponseType(%s) = %v, want %v", tc.in, result, tc.out)
}
})
}
}
func TestResponseType_UnmarshalForm(t *testing.T) {
t.Parallel()
input := []byte("code")
result := domain.ResponseTypeUndefined
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.ResponseTypeCode {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, domain.ResponseTypeCode)
}
}
func TestResponseType_UnmarshalJSON(t *testing.T) {
t.Parallel()
input := []byte(`"code"`)
result := domain.ResponseTypeUndefined
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if result != domain.ResponseTypeCode {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, domain.ResponseTypeCode)
}
}
func TestResponseType_String(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
in domain.ResponseType
out string
}{
{name: "id", in: domain.ResponseTypeID, out: "id"},
{name: "code", in: domain.ResponseTypeCode, out: "code"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := tc.in.String()
if result != tc.out {
t.Errorf("String() = %v, want %v", result, tc.out)
}
})
}
}

190
internal/domain/scope.go Normal file
View File

@ -0,0 +1,190 @@
package domain
import (
"fmt"
"strconv"
"strings"
)
type (
// Scope represent single token scope supported by IndieAuth.
//
// NOTE(toby3d): Encapsulate enums in structs for extra compile-time safety:
// https://threedots.tech/post/safer-enums-in-go/#struct-based-enums
Scope struct {
uid string
}
// Scopes represent set of Scope domains.
Scopes []Scope
)
var ErrScopeUnknown error = NewError(ErrorCodeInvalidRequest, "unknown scope", "https://indieweb.org/scope")
//nolint: gochecknoglobals // structs cannot be constants
var (
ScopeUndefined = Scope{uid: ""}
// https://indieweb.org/scope#Micropub_Scopes
ScopeCreate = Scope{uid: "create"}
ScopeDelete = Scope{uid: "delete"}
ScopeDraft = Scope{uid: "draft"}
ScopeMedia = Scope{uid: "media"}
ScopeUndelete = Scope{uid: "undelete"}
ScopeUpdate = Scope{uid: "update"}
// https://indieweb.org/scope#Microsub_Scopes
ScopeBlock = Scope{uid: "block"}
ScopeChannels = Scope{uid: "channels"}
ScopeFollow = Scope{uid: "follow"}
ScopeMute = Scope{uid: "mute"}
ScopeRead = Scope{uid: "read"}
// This scope requests access to the user's default profile information
// which include the following properties: name, photo, url.
//
// NOTE(toby3d): https://indieauth.net/source/#profile-information
ScopeProfile = Scope{uid: "profile"}
// This scope requests access to the user's email address in the
// following property: email.
//
// Note that because the profile scope is required when requesting
// profile information, the email scope cannot be requested on its own
// and must be requested along with the profile scope if desired.
//
// NOTE(toby3d): https://indieauth.net/source/#profile-information
ScopeEmail = Scope{uid: "email"}
)
//nolint: gochecknoglobals // maps cannot be constants
var uidsScopes = map[string]Scope{
ScopeBlock.uid: ScopeBlock,
ScopeChannels.uid: ScopeChannels,
ScopeCreate.uid: ScopeCreate,
ScopeDelete.uid: ScopeDelete,
ScopeDraft.uid: ScopeDraft,
ScopeEmail.uid: ScopeEmail,
ScopeFollow.uid: ScopeFollow,
ScopeMedia.uid: ScopeMedia,
ScopeMute.uid: ScopeMute,
ScopeProfile.uid: ScopeProfile,
ScopeRead.uid: ScopeRead,
ScopeUndelete.uid: ScopeUndelete,
ScopeUpdate.uid: ScopeUpdate,
}
// ParseScope parses scope slug into Scope domain.
func ParseScope(uid string) (Scope, error) {
if scope, ok := uidsScopes[strings.ToLower(uid)]; ok {
return scope, nil
}
return ScopeUndefined, fmt.Errorf("%w: %s", ErrScopeUnknown, uid)
}
func (s Scope) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(s.uid)), nil
}
// String returns string representation of scope.
func (s Scope) String() string {
return s.uid
}
// UnmarshalForm implements custom unmarshler for form values.
func (s *Scopes) UnmarshalForm(v []byte) error {
scopes := make(Scopes, 0)
for _, rawScope := range strings.Fields(string(v)) {
scope, err := ParseScope(rawScope)
if err != nil {
return fmt.Errorf("Scopes: UnmarshalForm: %w", err)
}
if scopes.Has(scope) {
continue
}
scopes = append(scopes, scope)
}
*s = scopes
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (s *Scopes) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("Scopes: UnmarshalJSON: %w", err)
}
result := make(Scopes, 0)
for _, rawScope := range strings.Fields(src) {
scope, err := ParseScope(rawScope)
if err != nil {
return fmt.Errorf("Scopes: UnmarshalJSON: %w", err)
}
if result.Has(scope) {
continue
}
result = append(result, scope)
}
*s = result
return nil
}
// UnmarshalJSON implements custom marshler for JSON.
func (s Scopes) MarshalJSON() ([]byte, error) {
scopes := make([]string, len(s))
for i := range s {
scopes[i] = s[i].String()
}
return []byte(strconv.Quote(strings.Join(scopes, " "))), nil
}
// String returns string representation of scopes.
func (s Scopes) String() string {
scopes := make([]string, len(s))
for i := range s {
scopes[i] = s[i].String()
}
return strings.Join(scopes, " ")
}
// IsEmpty returns true if the set does not contain valid scope.
func (s Scopes) IsEmpty() bool {
for i := range s {
if s[i] == ScopeUndefined {
continue
}
return false
}
return true
}
// Has check what input scope contains in current scopes collection.
func (s Scopes) Has(scope Scope) bool {
for i := range s {
if s[i] != scope {
continue
}
return true
}
return false
}

View File

@ -0,0 +1,153 @@
package domain_test
import (
"fmt"
"reflect"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseScope(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
in string
out domain.Scope
}{
{in: "create", out: domain.ScopeCreate},
{in: "delete", out: domain.ScopeDelete},
{in: "draft", out: domain.ScopeDraft},
{in: "media", out: domain.ScopeMedia},
{in: "update", out: domain.ScopeUpdate},
{in: "block", out: domain.ScopeBlock},
{in: "channels", out: domain.ScopeChannels},
{in: "follow", out: domain.ScopeFollow},
{in: "mute", out: domain.ScopeMute},
{in: "read", out: domain.ScopeRead},
{in: "profile", out: domain.ScopeProfile},
{in: "email", out: domain.ScopeEmail},
} {
tc := tc
t.Run(tc.in, func(t *testing.T) {
t.Parallel()
result, err := domain.ParseScope(tc.in)
if err != nil {
t.Fatalf("%+v", err)
}
if result != tc.out {
t.Errorf("ParseScope(%s) = %v, want %v", tc.in, result, tc.out)
}
})
}
}
func TestScopes_UnmarshalForm(t *testing.T) {
t.Parallel()
input := []byte("profile email")
results := make(domain.Scopes, 0)
if err := results.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
expResults := domain.Scopes{domain.ScopeProfile, domain.ScopeEmail}
if !reflect.DeepEqual(results, expResults) {
t.Errorf("UnmarshalForm(%s) = %s, want %s", input, results, expResults)
}
}
func TestScopes_UnmarshalJSON(t *testing.T) {
t.Parallel()
input := []byte(`"profile email"`)
results := make(domain.Scopes, 0)
if err := results.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
expResults := domain.Scopes{domain.ScopeProfile, domain.ScopeEmail}
if !reflect.DeepEqual(results, expResults) {
t.Errorf("UnmarshalJSON(%s) = %s, want %s", input, results, expResults)
}
}
func TestScopes_MarshalJSON(t *testing.T) {
t.Parallel()
scopes := domain.Scopes{domain.ScopeEmail, domain.ScopeProfile}
result, err := scopes.MarshalJSON()
if err != nil {
t.Fatalf("%+v", err)
}
if string(result) != fmt.Sprintf(`"%s"`, scopes) {
t.Errorf("MarshalJSON() = %s, want %s", result, fmt.Sprintf(`"%s"`, scopes))
}
}
func TestScope_String(t *testing.T) {
t.Parallel()
//nolint: paralleltest // false positive, in is used
for _, tc := range []struct {
in domain.Scope
out string
}{
{in: domain.ScopeCreate, out: "create"},
{in: domain.ScopeDelete, out: "delete"},
{in: domain.ScopeDraft, out: "draft"},
{in: domain.ScopeMedia, out: "media"},
{in: domain.ScopeUpdate, out: "update"},
{in: domain.ScopeBlock, out: "block"},
{in: domain.ScopeChannels, out: "channels"},
{in: domain.ScopeFollow, out: "follow"},
{in: domain.ScopeMute, out: "mute"},
{in: domain.ScopeRead, out: "read"},
{in: domain.ScopeProfile, out: "profile"},
{in: domain.ScopeEmail, out: "email"},
} {
tc := tc
t.Run(fmt.Sprint(tc.in), func(t *testing.T) {
t.Parallel()
if result := tc.in.String(); result != tc.out {
t.Errorf("String() = %s, want %s", result, tc.out)
}
})
}
}
func TestScopes_String(t *testing.T) {
t.Parallel()
scopes := domain.Scopes{domain.ScopeProfile, domain.ScopeEmail}
if result := scopes.String(); result != fmt.Sprint(scopes) {
t.Errorf("String() = %s, want %s", result, scopes)
}
}
func TestScopes_IsEmpty(t *testing.T) {
t.Parallel()
scopes := domain.Scopes{domain.ScopeUndefined}
if result := scopes.IsEmpty(); !result {
t.Errorf("IsEmpty() = %t, want %t", result, true)
}
}
func TestScopes_Has(t *testing.T) {
t.Parallel()
scopes := domain.Scopes{domain.ScopeProfile, domain.ScopeEmail}
if result := scopes.Has(domain.ScopeEmail); !result {
t.Errorf("Has(%s) = %t, want %t", domain.ScopeEmail, result, true)
}
}

View File

@ -0,0 +1,44 @@
package domain
import (
"testing"
"source.toby3d.me/toby3d/auth/internal/random"
)
//nolint: tagliatelle
type Session struct {
ClientID *ClientID `json:"client_id"`
RedirectURI *URL `json:"redirect_uri"`
Me *Me `json:"me"`
Profile *Profile `json:"profile,omitempty"`
Scope Scopes `json:"scope"`
CodeChallengeMethod CodeChallengeMethod `json:"code_challenge_method,omitempty"`
CodeChallenge string `json:"code_challenge,omitempty"`
Code string `json:"-"`
}
// TestSession returns valid random generated session for tests.
//nolint: gomnd // testing domain can contains non-standart values
func TestSession(tb testing.TB) *Session {
tb.Helper()
code, err := random.String(24)
if err != nil {
tb.Fatal(err)
}
return &Session{
ClientID: TestClientID(tb),
Code: code,
CodeChallenge: "hackme",
CodeChallengeMethod: CodeChallengeMethodPLAIN,
Profile: TestProfile(tb),
Me: TestMe(tb, "https://user.example.net/"),
RedirectURI: TestURL(tb, "https://example.com/callback"),
Scope: Scopes{
ScopeEmail,
ScopeProfile,
},
}
}

27
internal/domain/ticket.go Normal file
View File

@ -0,0 +1,27 @@
package domain
import (
"testing"
)
type Ticket struct {
// The access token will work at this URL.
Resource *URL
// The access token should be used when acting on behalf of this URL.
Subject *Me
// A random string that can be redeemed for an access token.
Ticket string
}
// TestTicket returns valid random generated ticket for tests.
func TestTicket(tb testing.TB) *Ticket {
tb.Helper()
return &Ticket{
Resource: TestURL(tb, "https://alice.example.com/private/"),
Subject: TestMe(tb, "https://bob.example.com/"),
Ticket: "32985723984723985792834",
}
}

181
internal/domain/token.go Normal file
View File

@ -0,0 +1,181 @@
package domain
import (
"fmt"
"testing"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/random"
)
type (
// Token describes the data of the token used by the clients.
Token struct {
CreatedAt time.Time
Expiry time.Time
ClientID *ClientID
Me *Me
Scope Scopes
AccessToken string
RefreshToken string
}
// NewTokenOptions contains options for NewToken function.
NewTokenOptions struct {
Expiration time.Duration
Issuer *ClientID
Subject *Me
Scope Scopes
Secret []byte
Algorithm string
NonceLength int
}
)
// DefaultNewTokenOptions describes the default settings for NewToken.
//nolint: gochecknoglobals, gomnd
var DefaultNewTokenOptions = NewTokenOptions{
Expiration: 0,
Scope: nil,
Issuer: nil,
Subject: nil,
Secret: nil,
Algorithm: "HS256",
NonceLength: 32,
}
// NewToken create a new token by provided options.
//nolint: cyclop
func NewToken(opts NewTokenOptions) (*Token, error) {
if opts.NonceLength == 0 {
opts.NonceLength = DefaultNewTokenOptions.NonceLength
}
if opts.Algorithm == "" {
opts.Algorithm = DefaultNewTokenOptions.Algorithm
}
now := time.Now().UTC().Round(time.Second)
nonce, err := random.String(opts.NonceLength)
if err != nil {
return nil, fmt.Errorf("cannot generate nonce: %w", err)
}
tkn := jwt.New()
for key, val := range map[string]interface{}{
"nonce": nonce,
"scope": opts.Scope,
jwt.IssuedAtKey: now,
jwt.NotBeforeKey: now,
jwt.SubjectKey: opts.Subject.String(),
} {
if err = tkn.Set(key, val); err != nil {
return nil, fmt.Errorf("failed to set JWT token field: %w", err)
}
}
if opts.Issuer != nil {
if err = tkn.Set(jwt.IssuerKey, opts.Issuer.String()); err != nil {
return nil, fmt.Errorf("failed to set JWT token field: %w", err)
}
}
if opts.Expiration != 0 {
if err = tkn.Set(jwt.ExpirationKey, now.Add(opts.Expiration)); err != nil {
return nil, fmt.Errorf("failed to set JWT token field: %w", err)
}
}
accessToken, err := jwt.Sign(tkn, jwt.WithKey(jwa.SignatureAlgorithm(opts.Algorithm), opts.Secret))
if err != nil {
return nil, fmt.Errorf("cannot sign a new access token: %w", err)
}
return &Token{
AccessToken: string(accessToken),
ClientID: opts.Issuer,
CreatedAt: now,
Expiry: now.Add(opts.Expiration),
Me: opts.Subject,
RefreshToken: "", // TODO(toby3d)
Scope: opts.Scope,
}, nil
}
// TestToken returns valid random generated token for tests.
//nolint: gomnd // testing domain can contains non-standart values
func TestToken(tb testing.TB) *Token {
tb.Helper()
nonce, err := random.String(22)
if err != nil {
tb.Fatal(err)
}
tkn := jwt.New()
cid := TestClientID(tb)
me := TestMe(tb, "https://user.example.net/")
now := time.Now().UTC().Round(time.Second)
scope := Scopes{
ScopeCreate,
ScopeDelete,
ScopeUpdate,
ScopeProfile,
ScopeEmail,
}
for key, val := range map[string]interface{}{
// NOTE(toby3d): required
jwt.IssuerKey: cid.String(),
jwt.SubjectKey: me.String(),
jwt.ExpirationKey: now.Add(1 * time.Hour),
jwt.NotBeforeKey: now.Add(-1 * time.Hour),
jwt.IssuedAtKey: now.Add(-1 * time.Hour),
// TODO(toby3d): jwt.AudienceKey
// TODO(toby3d): jwt.JwtIDKey
// NOTE(toby3d): optional
"scope": scope,
"nonce": nonce,
} {
_ = tkn.Set(key, val)
}
accessToken, err := jwt.Sign(tkn, jwt.WithKey(jwa.HS256, []byte("hackme")))
if err != nil {
tb.Fatal(err)
}
return &Token{
CreatedAt: now.Add(-1 * time.Hour),
Expiry: now.Add(1 * time.Hour),
ClientID: cid,
Me: me,
Scope: scope,
AccessToken: string(accessToken),
RefreshToken: "", // TODO(toby3d)
}
}
// SetAuthHeader writes an Access Token to the request header.
func (t Token) SetAuthHeader(r *http.Request) {
if t.AccessToken == "" {
return
}
r.Header.Set(http.HeaderAuthorization, t.String())
}
// String returns string representation of token.
func (t Token) String() string {
if t.AccessToken == "" {
return ""
}
return "Bearer " + t.AccessToken
}

View File

@ -0,0 +1,65 @@
package domain_test
import (
"bytes"
"fmt"
"testing"
"time"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestNewToken(t *testing.T) {
t.Parallel()
expResult := domain.TestToken(t)
opts := domain.NewTokenOptions{
Algorithm: "",
NonceLength: 0,
Issuer: expResult.ClientID,
Expiration: 1 * time.Hour,
Scope: expResult.Scope,
Subject: expResult.Me,
Secret: []byte("hackme"),
}
result, err := domain.NewToken(opts)
if err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result.ClientID) != fmt.Sprint(expResult.ClientID) ||
fmt.Sprint(result.Me) != fmt.Sprint(expResult.Me) ||
fmt.Sprint(result.Scope) != fmt.Sprint(expResult.Scope) {
t.Errorf("NewToken(%+v) = %+v, want %+v", opts, result, expResult)
}
}
func TestToken_SetAuthHeader(t *testing.T) {
t.Parallel()
token := domain.TestToken(t)
expResult := []byte("Bearer " + token.AccessToken)
req := http.AcquireRequest()
defer http.ReleaseRequest(req)
token.SetAuthHeader(req)
result := req.Header.Peek(http.HeaderAuthorization)
if result == nil || !bytes.Equal(result, expResult) {
t.Errorf("SetAuthHeader(%+v) = %s, want %s", req, result, expResult)
}
}
func TestToken_String(t *testing.T) {
t.Parallel()
token := domain.TestToken(t)
expResult := "Bearer " + token.AccessToken
if result := token.String(); result != expResult {
t.Errorf("String() = %s, want %s", result, expResult)
}
}

94
internal/domain/url.go Normal file
View File

@ -0,0 +1,94 @@
package domain
import (
"fmt"
"net/url"
"strconv"
"testing"
http "github.com/valyala/fasthttp"
)
// URL describe any valid HTTP URL.
type URL struct {
*http.URI
}
// ParseURL parse string as URL.
func ParseURL(src string) (*URL, error) {
u := http.AcquireURI()
if err := u.Parse(nil, []byte(src)); err != nil {
return nil, fmt.Errorf("cannot parse URL: %w", err)
}
return &URL{URI: u}, nil
}
// MustParseURL parse string as URL or panic.
func MustParseURL(src string) *URL {
uri, err := ParseURL(src)
if err != nil {
panic("MustParseURL: " + err.Error())
}
return uri
}
// TestURL returns URL of provided input for tests.
func TestURL(tb testing.TB, src string) *URL {
tb.Helper()
u := http.AcquireURI()
u.Update(src)
return &URL{
URI: u,
}
}
// UnmarshalForm implements custom unmarshler for form values.
func (u *URL) UnmarshalForm(v []byte) error {
url, err := ParseURL(string(v))
if err != nil {
return fmt.Errorf("URL: UnmarshalForm: %w", err)
}
*u = *url
return nil
}
// UnmarshalJSON implements custom unmarshler for JSON.
func (u *URL) UnmarshalJSON(v []byte) error {
src, err := strconv.Unquote(string(v))
if err != nil {
return fmt.Errorf("URL: UnmarshalJSON: %w", err)
}
url, err := ParseURL(src)
if err != nil {
return fmt.Errorf("URL: UnmarshalJSON: %w", err)
}
*u = *url
return nil
}
func (u URL) MarshalJSON() ([]byte, error) {
return []byte(strconv.Quote(u.String())), nil
}
// URL returns url.URL representation of URL.
func (u URL) URL() *url.URL {
if u.URI == nil {
return nil
}
result, err := url.ParseRequestURI(u.String())
if err != nil {
return nil
}
return result
}

View File

@ -0,0 +1,51 @@
package domain_test
import (
"fmt"
"testing"
"source.toby3d.me/toby3d/auth/internal/domain"
)
func TestParseURL(t *testing.T) {
t.Parallel()
input := "https://user:pass@example.com:8443/users?id=100#me"
if _, err := domain.ParseURL(input); err != nil {
t.Errorf("ParseURL(%s) = %+v, want nil", input, err)
}
}
func TestURL_UnmarshalForm(t *testing.T) {
t.Parallel()
url := domain.TestURL(t, "https://user:pass@example.com:8443/users?id=100#me")
input := []byte(fmt.Sprint(url))
result := new(domain.URL)
if err := result.UnmarshalForm(input); err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != fmt.Sprint(url) {
t.Errorf("UnmarshalForm(%s) = %v, want %v", input, result, url)
}
}
func TestURL_UnmarshalJSON(t *testing.T) {
t.Parallel()
url := domain.TestURL(t, "https://user:pass@example.com:8443/users?id=100#me")
input := []byte(fmt.Sprintf(`"%s"`, url))
result := new(domain.URL)
if err := result.UnmarshalJSON(input); err != nil {
t.Fatalf("%+v", err)
}
if fmt.Sprint(result) != fmt.Sprint(url) {
t.Errorf("UnmarshalJSON(%s) = %v, want %v", input, result, url)
}
}
// TODO(toby3d): TestURL_URL

32
internal/domain/user.go Normal file
View File

@ -0,0 +1,32 @@
package domain
import (
"testing"
)
type User struct {
Me *Me
AuthorizationEndpoint *URL
IndieAuthMetadata *URL
Micropub *URL
Microsub *URL
TicketEndpoint *URL
TokenEndpoint *URL
*Profile
}
// TestUser returns valid random generated user for tests.
func TestUser(tb testing.TB) *User {
tb.Helper()
return &User{
Profile: TestProfile(tb),
Me: TestMe(tb, "https://user.example.net/"),
AuthorizationEndpoint: TestURL(tb, "https://example.org/auth"),
IndieAuthMetadata: TestURL(tb, "https://example.org/.well-known/oauth-authorization-server"),
Micropub: TestURL(tb, "https://microsub.example.org/"),
Microsub: TestURL(tb, "https://micropub.example.org/"),
TicketEndpoint: TestURL(tb, "https://example.org/ticket"),
TokenEndpoint: TestURL(tb, "https://example.org/token"),
}
}

View File

@ -0,0 +1,27 @@
package http
import (
"github.com/fasthttp/router"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/middleware"
)
type RequestHandler struct{}
func NewRequestHandler() *RequestHandler {
return &RequestHandler{}
}
func (h *RequestHandler) Register(r *router.Router) {
chain := middleware.Chain{
middleware.LogFmt(),
}
r.GET("/health", chain.RequestHandler(h.read))
}
func (h *RequestHandler) read(ctx *http.RequestCtx) {
ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, `{"ok": true}`)
}

View File

@ -0,0 +1,42 @@
package http_test
import (
"testing"
"github.com/fasthttp/router"
http "github.com/valyala/fasthttp"
delivery "source.toby3d.me/toby3d/auth/internal/health/delivery/http"
"source.toby3d.me/toby3d/auth/internal/testing/httptest"
)
func TestRequestHandler(t *testing.T) {
t.Parallel()
r := router.New()
delivery.NewRequestHandler().Register(r)
client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
const requestURL = "https://app.example.com/health"
req, resp := httptest.NewRequest(http.MethodGet, requestURL, nil), http.AcquireResponse()
t.Cleanup(func() {
http.ReleaseRequest(req)
http.ReleaseResponse(resp)
})
if err := client.Do(req, resp); err != nil {
t.Fatal(err)
}
if result := resp.StatusCode(); result != http.StatusOK {
t.Errorf("GET %s = %d, want %d", requestURL, result, http.StatusOK)
}
const expBody = `{"ok": true}`
if result := string(resp.Body()); result != expBody {
t.Errorf("GET %s = %s, want %s", requestURL, result, expBody)
}
}

View File

@ -0,0 +1,133 @@
package httputil
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/tomnomnom/linkheader"
http "github.com/valyala/fasthttp"
"willnorris.com/go/microformats"
"source.toby3d.me/toby3d/auth/internal/domain"
)
var ErrEndpointNotExist = domain.NewError(
domain.ErrorCodeServerError,
"cannot found any endpoints",
"https://indieauth.net/source/#discovery-0",
)
func ExtractEndpoints(resp *http.Response, rel string) []*domain.URL {
results := make([]*domain.URL, 0)
urls, err := ExtractEndpointsFromHeader(resp, rel)
if err == nil {
results = append(results, urls...)
}
urls, err = ExtractEndpointsFromBody(resp, rel)
if err == nil {
results = append(results, urls...)
}
return results
}
func ExtractEndpointsFromHeader(resp *http.Response, rel string) ([]*domain.URL, error) {
results := make([]*domain.URL, 0)
for _, link := range linkheader.Parse(string(resp.Header.Peek(http.HeaderLink))) {
if !strings.EqualFold(link.Rel, rel) {
continue
}
u := http.AcquireURI()
if err := u.Parse(resp.Header.Peek(http.HeaderHost), []byte(link.URL)); err != nil {
return nil, fmt.Errorf("cannot parse header endpoint: %w", err)
}
results = append(results, &domain.URL{URI: u})
}
return results, nil
}
func ExtractEndpointsFromBody(resp *http.Response, rel string) ([]*domain.URL, error) {
endpoints, ok := microformats.Parse(bytes.NewReader(resp.Body()), nil).Rels[rel]
if !ok || len(endpoints) == 0 {
return nil, ErrEndpointNotExist
}
results := make([]*domain.URL, 0)
for i := range endpoints {
u := http.AcquireURI()
if err := u.Parse(resp.Header.Peek(http.HeaderHost), []byte(endpoints[i])); err != nil {
return nil, fmt.Errorf("cannot parse body endpoint: %w", err)
}
results = append(results, &domain.URL{URI: u})
}
return results, nil
}
func ExtractMetadata(resp *http.Response, client *http.Client) (*domain.Metadata, error) {
endpoints := ExtractEndpoints(resp, "indieauth-metadata")
if len(endpoints) == 0 {
return nil, ErrEndpointNotExist
}
_, body, err := client.Get(nil, endpoints[len(endpoints)-1].String())
if err != nil {
return nil, fmt.Errorf("failed to fetch metadata endpoint configuration: %w", err)
}
result := new(domain.Metadata)
if err = json.Unmarshal(body, result); err != nil {
return nil, fmt.Errorf("cannot unmarshal emtadata configuration: %w", err)
}
return result, nil
}
func ExtractProperty(resp *http.Response, itemType, key string) []interface{} {
//nolint: exhaustivestruct // only Host part in url.URL is needed
data := microformats.Parse(bytes.NewReader(resp.Body()), &url.URL{
Host: string(resp.Header.Peek(http.HeaderHost)),
})
return findProperty(data.Items, itemType, key)
}
func contains(src []string, find string) bool {
for i := range src {
if !strings.EqualFold(src[i], find) {
continue
}
return true
}
return false
}
func findProperty(src []*microformats.Microformat, itemType, key string) []interface{} {
for _, item := range src {
if contains(item.Type, itemType) {
return item.Properties[key]
}
result := findProperty(item.Children, itemType, key)
if result == nil {
continue
}
return result
}
return nil
}

View File

@ -0,0 +1,30 @@
package httputil_test
import (
"testing"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/httputil"
)
const testBody = `<html>
<body class="h-page">
<main class="h-card">
<h1 class="p-name">Sample Name</h1>
</main>
</body>
</html>`
func TestExtractProperty(t *testing.T) {
t.Parallel()
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
resp.SetBodyString(testBody)
results := httputil.ExtractProperty(resp, "h-card", "name")
if results == nil || results[0] != "Sample Name" {
t.Errorf(`ExtractProperty(resp, "h-card", "name") = %+s, want %+s`, results, []string{"Sample Name"})
}
}

View File

@ -0,0 +1,126 @@
package http
import (
"github.com/fasthttp/router"
"github.com/goccy/go-json"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/middleware"
)
type (
//nolint: tagliatelle // https://indieauth.net/source/#indieauth-server-metadata
MetadataResponse struct {
// The server's issuer identifier.
Issuer string `json:"issuer"`
// The Authorization Endpoint.
AuthorizationEndpoint string `json:"authorization_endpoint"`
// The Token Endpoint.
TokenEndpoint string `json:"token_endpoint"`
// The Introspection Endpoint.
IntrospectionEndpoint string `json:"introspection_endpoint"`
// JSON array containing a list of client authentication methods
// supported by this introspection endpoint.
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` //nolint: lll
// The Revocation Endpoint.
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
// JSON array containing the value "none".
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` //nolint: lll
// JSON array containing scope values supported by the
// IndieAuth server.
ScopesSupported []string `json:"scopes_supported,omitempty"`
// JSON array containing the response_type values supported.
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
// JSON array containing grant type values supported.
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
// URL of a page containing human-readable information that
// developers might need to know when using the server.
ServiceDocumentation string `json:"service_documentation,omitempty"`
// JSON array containing the methods supported for PKCE.
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
// Boolean parameter indicating whether the authorization server
// provides the iss parameter.
AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` //nolint: lll
// The User Info Endpoint.
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
}
RequestHandler struct {
metadata *domain.Metadata
}
)
func NewRequestHandler(metadata *domain.Metadata) *RequestHandler {
return &RequestHandler{
metadata: metadata,
}
}
func (h *RequestHandler) Register(r *router.Router) {
chain := middleware.Chain{
middleware.LogFmt(),
}
r.GET("/.well-known/oauth-authorization-server", chain.RequestHandler(h.read))
}
func (h *RequestHandler) read(ctx *http.RequestCtx) {
ctx.SetStatusCode(http.StatusOK)
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
scopes, responseTypes, grantTypes, codeChallengeMethods := make([]string, 0), make([]string, 0),
make([]string, 0), make([]string, 0)
for i := range h.metadata.ScopesSupported {
scopes = append(scopes, h.metadata.ScopesSupported[i].String())
}
for i := range h.metadata.ResponseTypesSupported {
responseTypes = append(responseTypes, h.metadata.ResponseTypesSupported[i].String())
}
for i := range h.metadata.GrantTypesSupported {
grantTypes = append(grantTypes, h.metadata.GrantTypesSupported[i].String())
}
for i := range h.metadata.CodeChallengeMethodsSupported {
codeChallengeMethods = append(codeChallengeMethods,
h.metadata.CodeChallengeMethodsSupported[i].String())
}
_ = json.NewEncoder(ctx).Encode(&MetadataResponse{
AuthorizationEndpoint: h.metadata.AuthorizationEndpoint.String(),
IntrospectionEndpoint: h.metadata.IntrospectionEndpoint.String(),
Issuer: h.metadata.Issuer.String(),
RevocationEndpoint: h.metadata.RevocationEndpoint.String(),
ServiceDocumentation: h.metadata.ServiceDocumentation.String(),
TokenEndpoint: h.metadata.TokenEndpoint.String(),
UserinfoEndpoint: h.metadata.UserinfoEndpoint.String(),
AuthorizationResponseIssParameterSupported: h.metadata.AuthorizationResponseIssParameterSupported,
CodeChallengeMethodsSupported: codeChallengeMethods,
GrantTypesSupported: grantTypes,
IntrospectionEndpointAuthMethodsSupported: h.metadata.IntrospectionEndpointAuthMethodsSupported,
ResponseTypesSupported: responseTypes,
ScopesSupported: scopes,
// NOTE(toby3d): If a revocation endpoint is provided, this
// property should also be provided with the value ["none"],
// since the omission of this value defaults to
// client_secret_basic according to RFC8414.
RevocationEndpointAuthMethodsSupported: h.metadata.RevocationEndpointAuthMethodsSupported,
})
}

View File

@ -0,0 +1,40 @@
package http_test
import (
"testing"
"github.com/fasthttp/router"
"github.com/goccy/go-json"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/domain"
delivery "source.toby3d.me/toby3d/auth/internal/metadata/delivery/http"
"source.toby3d.me/toby3d/auth/internal/testing/httptest"
)
func TestMetadata(t *testing.T) {
t.Parallel()
r := router.New()
metadata := domain.TestMetadata(t)
delivery.NewRequestHandler(metadata).Register(r)
client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
const requestURL string = "https://example.com/.well-known/oauth-authorization-server"
status, body, err := client.Get(nil, requestURL)
if err != nil {
t.Fatal(err)
}
if status != http.StatusOK {
t.Errorf("GET %s = %d, want %d", requestURL, status, http.StatusOK)
}
result := new(delivery.MetadataResponse)
if err = json.Unmarshal(body, result); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,17 @@
package metadata
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type Repository interface {
Get(ctx context.Context, me *domain.Me) (*domain.Metadata, error)
}
var ErrNotExist error = domain.NewError(
domain.ErrorCodeInvalidClient,
"not found 'indieauth-metadata' endpoint on provided me URL",
"https://indieauth.net/source/#discovery-0",
)

View File

@ -0,0 +1,91 @@
package http
import (
"context"
"encoding/json"
"fmt"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/httputil"
"source.toby3d.me/toby3d/auth/internal/metadata"
)
type (
//nolint: tagliatelle,lll
Metadata struct {
Issuer *domain.ClientID `json:"issuer"`
AuthorizationEndpoint *domain.URL `json:"authorization_endpoint"`
IntrospectionEndpoint *domain.URL `json:"introspection_endpoint"`
RevocationEndpoint *domain.URL `json:"revocation_endpoint,omitempty"`
ServiceDocumentation *domain.URL `json:"service_documentation,omitempty"`
TokenEndpoint *domain.URL `json:"token_endpoint"`
UserinfoEndpoint *domain.URL `json:"userinfo_endpoint,omitempty"`
CodeChallengeMethodsSupported []domain.CodeChallengeMethod `json:"code_challenge_methods_supported"`
GrantTypesSupported []domain.GrantType `json:"grant_types_supported,omitempty"`
ResponseTypesSupported []domain.ResponseType `json:"response_types_supported,omitempty"`
ScopesSupported []domain.Scope `json:"scopes_supported,omitempty"`
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"`
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"`
AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"`
}
httpMetadataRepository struct {
client *http.Client
}
)
const DefaultMaxRedirectsCount int = 10
func NewHTTPMetadataRepository(client *http.Client) metadata.Repository {
return &httpMetadataRepository{
client: client,
}
}
func (repo *httpMetadataRepository) Get(ctx context.Context, me *domain.Me) (*domain.Metadata, error) {
req := http.AcquireRequest()
defer http.ReleaseRequest(req)
req.SetRequestURI(me.String())
req.Header.SetMethod(http.MethodGet)
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
if err := repo.client.DoRedirects(req, resp, DefaultMaxRedirectsCount); err != nil {
return nil, fmt.Errorf("failed to make a request to the client: %w", err)
}
endpoints := httputil.ExtractEndpoints(resp, "indieauth-metadata")
if len(endpoints) == 0 {
return nil, metadata.ErrNotExist
}
_, body, err := repo.client.Get(nil, endpoints[len(endpoints)-1].String())
if err != nil {
return nil, fmt.Errorf("failed to fetch metadata endpoint configuration: %w", err)
}
data := new(Metadata)
if err = json.Unmarshal(body, data); err != nil {
return nil, fmt.Errorf("cannot unmarshal metadata configuration: %w", err)
}
//nolint: exhaustivestruct // TODO(toby3d)
return &domain.Metadata{
AuthorizationEndpoint: data.AuthorizationEndpoint,
AuthorizationResponseIssParameterSupported: data.AuthorizationResponseIssParameterSupported,
CodeChallengeMethodsSupported: data.CodeChallengeMethodsSupported,
GrantTypesSupported: data.GrantTypesSupported,
Issuer: data.Issuer,
ResponseTypesSupported: data.ResponseTypesSupported,
ScopesSupported: data.ScopesSupported,
ServiceDocumentation: data.ServiceDocumentation,
TokenEndpoint: data.TokenEndpoint,
// TODO(toby3d): support extensions?
// Micropub: data.Micropub,
// Microsub: data.Microsub,
// TicketEndpoint: data.TicketEndpoint,
}, nil
}

View File

@ -0,0 +1,36 @@
package memory
import (
"context"
"path"
"sync"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/metadata"
)
type memoryMetadataRepository struct {
store *sync.Map
}
const DefaultPathPrefix = "metadata"
func NewMemoryMetadataRepository(store *sync.Map) metadata.Repository {
return &memoryMetadataRepository{
store: store,
}
}
func (repo *memoryMetadataRepository) Get(ctx context.Context, me *domain.Me) (*domain.Metadata, error) {
src, ok := repo.store.Load(path.Join(DefaultPathPrefix, me.String()))
if !ok {
return nil, metadata.ErrNotExist
}
result, ok := src.(*domain.Metadata)
if !ok {
return nil, metadata.ErrNotExist
}
return result, nil
}

View File

@ -0,0 +1,17 @@
package profile
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type Repository interface {
Get(ctx context.Context, me *domain.Me) (*domain.Profile, error)
}
var ErrNotExist error = domain.NewError(
domain.ErrorCodeServerError,
"no profile data for the provided Me",
"https://indieweb.org/h-card",
)

View File

@ -0,0 +1,96 @@
package http
import (
"context"
"fmt"
http "github.com/valyala/fasthttp"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/httputil"
"source.toby3d.me/toby3d/auth/internal/profile"
)
type httpProfileRepository struct {
client *http.Client
}
const (
ErrPrefix string = "http"
DefaultMaxRedirectsCount int = 10
hCard string = "h-card"
propertyEmail string = "email"
propertyName string = "name"
propertyPhoto string = "photo"
propertyURL string = "url"
)
func NewHTPPClientRepository(client *http.Client) profile.Repository {
return &httpProfileRepository{
client: client,
}
}
//nolint: cyclop
func (repo *httpProfileRepository) Get(ctx context.Context, me *domain.Me) (*domain.Profile, error) {
req := http.AcquireRequest()
defer http.ReleaseRequest(req)
req.Header.SetMethod(http.MethodGet)
req.SetRequestURI(me.String())
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
if err := repo.client.DoRedirects(req, resp, DefaultMaxRedirectsCount); err != nil {
return nil, fmt.Errorf("%s: cannot fetch user by me: %w", ErrPrefix, err)
}
result := domain.NewProfile()
for _, name := range httputil.ExtractProperty(resp, hCard, propertyName) {
if n, ok := name.(string); ok {
result.Name = append(result.Name, n)
}
}
for _, rawEmail := range httputil.ExtractProperty(resp, hCard, propertyEmail) {
email, ok := rawEmail.(string)
if !ok {
continue
}
if e, err := domain.ParseEmail(email); err == nil {
result.Email = append(result.Email, e)
}
}
for _, rawURL := range httputil.ExtractProperty(resp, hCard, propertyURL) {
url, ok := rawURL.(string)
if !ok {
continue
}
if u, err := domain.ParseURL(url); err == nil {
result.URL = append(result.URL, u)
}
}
for _, rawPhoto := range httputil.ExtractProperty(resp, hCard, propertyPhoto) {
photo, ok := rawPhoto.(string)
if !ok {
continue
}
if p, err := domain.ParseURL(photo); err == nil {
result.Photo = append(result.Photo, p)
}
}
if result.GetName() == "" && result.GetURL() == nil &&
result.GetPhoto() == nil && result.GetEmail() == nil {
return nil, profile.ErrNotExist
}
return result, nil
}

View File

@ -0,0 +1,40 @@
package memory
import (
"context"
"fmt"
"path"
"sync"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/profile"
)
type memoryProfileRepository struct {
store *sync.Map
}
const (
ErrPrefix string = "memory"
DefaultPathPrefix string = "profiles"
)
func NewMemoryProfileRepository(store *sync.Map) profile.Repository {
return &memoryProfileRepository{
store: store,
}
}
func (repo *memoryProfileRepository) Get(_ context.Context, me *domain.Me) (*domain.Profile, error) {
src, ok := repo.store.Load(path.Join(DefaultPathPrefix, me.String()))
if !ok {
return nil, fmt.Errorf("%s: cannot find profile in store: %w", ErrPrefix, profile.ErrNotExist)
}
result, ok := src.(*domain.Profile)
if !ok {
return nil, fmt.Errorf("%s: cannot decode profile from store: %w", ErrPrefix, profile.ErrNotExist)
}
return result, nil
}

View File

@ -0,0 +1,17 @@
package profile
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type UseCase interface {
Fetch(ctx context.Context, me *domain.Me) (*domain.Profile, error)
}
var ErrScopeRequired error = domain.NewError(
domain.ErrorCodeInsufficientScope,
"token with 'profile' scopes is required to view profile data",
"https://indieauth.net/source/#user-information",
)

View File

@ -0,0 +1,28 @@
package usecase
import (
"context"
"fmt"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/profile"
)
type profileUseCase struct {
profiles profile.Repository
}
func NewProfileUseCase(profiles profile.Repository) profile.UseCase {
return &profileUseCase{
profiles: profiles,
}
}
func (uc *profileUseCase) Fetch(ctx context.Context, me *domain.Me) (*domain.Profile, error) {
result, err := uc.profiles.Get(ctx, me)
if err != nil {
return nil, fmt.Errorf("cannot fetch profile info: %w", err)
}
return result, nil
}

48
internal/random/random.go Normal file
View File

@ -0,0 +1,48 @@
package random
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
)
const (
Uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
Lowercase = "abcdefghijklmnopqrstuvwxyz"
Alphabetic = Uppercase + Lowercase
Numeric = "0123456789"
Alphanumeric = Alphabetic + Numeric
Symbols = "`" + `~!@#$%^&*()-_+={}[]|\;:"<>,./?`
Hex = Numeric + "abcdef"
)
func Bytes(length int) ([]byte, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return nil, fmt.Errorf("cannot read bytes: %w", err)
}
return bytes, nil
}
func String(length int, charsets ...string) (string, error) {
charset := strings.Join(charsets, "")
if charset == "" {
charset = Alphabetic
}
bytes := make([]byte, length)
for i := range bytes {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", fmt.Errorf("failed to randomize bytes: %w", err)
}
bytes[i] = charset[n.Int64()]
}
return string(bytes), nil
}

View File

@ -0,0 +1,16 @@
package session
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type Repository interface {
Get(ctx context.Context, code string) (*domain.Session, error)
Create(ctx context.Context, session *domain.Session) error
GetAndDelete(ctx context.Context, code string) (*domain.Session, error)
GC()
}
var ErrNotExist error = domain.NewError(domain.ErrorCodeServerError, "session with this code not exist", "")

View File

@ -0,0 +1,104 @@
package memory
import (
"context"
"fmt"
"path"
"sync"
"time"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/session"
)
type (
Session struct {
CreatedAt time.Time
*domain.Session
}
memorySessionRepository struct {
store *sync.Map
config *domain.Config
}
)
const DefaultPathPrefix string = "sessions"
func NewMemorySessionRepository(store *sync.Map, config *domain.Config) session.Repository {
return &memorySessionRepository{
config: config,
store: store,
}
}
func (repo *memorySessionRepository) Create(_ context.Context, state *domain.Session) error {
repo.store.Store(path.Join(DefaultPathPrefix, state.Code), &Session{
CreatedAt: time.Now().UTC(),
Session: state,
})
return nil
}
func (repo *memorySessionRepository) Get(_ context.Context, code string) (*domain.Session, error) {
src, ok := repo.store.Load(path.Join(DefaultPathPrefix, code))
if !ok {
return nil, fmt.Errorf("cannot find session in store: %w", session.ErrNotExist)
}
result, ok := src.(*Session)
if !ok {
return nil, fmt.Errorf("cannot decode session in store: %w", session.ErrNotExist)
}
return result.Session, nil
}
func (repo *memorySessionRepository) GetAndDelete(_ context.Context, code string) (*domain.Session, error) {
src, ok := repo.store.LoadAndDelete(path.Join(DefaultPathPrefix, code))
if !ok {
return nil, fmt.Errorf("cannot find session in store: %w", session.ErrNotExist)
}
result, ok := src.(*Session)
if !ok {
return nil, fmt.Errorf("cannot decode session in store: %w", session.ErrNotExist)
}
return result.Session, nil
}
func (repo *memorySessionRepository) GC() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for ts := range ticker.C {
ts := ts
repo.store.Range(func(key, value interface{}) bool {
k, ok := key.(string)
if !ok {
return false
}
matched, err := path.Match(DefaultPathPrefix+"/*", k)
if err != nil || !matched {
return false
}
val, ok := value.(*Session)
if !ok {
return false
}
if val.CreatedAt.Add(repo.config.Code.Expiry).After(ts) {
return false
}
repo.store.Delete(key)
return false
})
}
}

View File

@ -0,0 +1,157 @@
package sqlite3
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/session"
)
type (
Session struct {
CreatedAt sql.NullTime `db:"created_at"`
Code string `db:"code"`
Data string `db:"data"`
}
sqlite3SessionRepository struct {
db *sqlx.DB
}
)
const (
QueryTable string = `CREATE TABLE IF NOT EXISTS sessions (
created_at DATETIME NOT NULL,
code TEXT UNIQUE PRIMARY KEY NOT NULL,
data TEXT NOT NULL
);`
QueryGet string = `SELECT *
FROM sessions
WHERE code=$1;`
QueryCreate string = `INSERT INTO sessions (created_at, code, data)
VALUES (:created_at, :code, :data);`
QueryDelete string = `DELETE FROM sessions
WHERE code=$1;`
)
func NewSQLite3SessionRepository(db *sqlx.DB) session.Repository {
db.MustExec(QueryTable)
return &sqlite3SessionRepository{
db: db,
}
}
func (repo *sqlite3SessionRepository) Create(ctx context.Context, session *domain.Session) error {
src, err := NewSession(session)
if err != nil {
return fmt.Errorf("cannot encode session data for store: %w", err)
}
if _, err := repo.db.NamedExecContext(ctx, QueryCreate, src); err != nil {
return fmt.Errorf("cannot create session record in db: %w", err)
}
return nil
}
func (repo *sqlite3SessionRepository) Get(ctx context.Context, code string) (*domain.Session, error) {
s := new(Session) //nolint: varnamelen // cannot redaclare import
if err := repo.db.GetContext(ctx, s, QueryGet, code); err != nil {
return nil, fmt.Errorf("cannot find session in db: %w", err)
}
result := new(domain.Session)
if err := s.Populate([]byte(s.Data), result); err != nil {
return nil, fmt.Errorf("cannot decode session data from store: %w", err)
}
result.Code = code
return result, nil
}
func (repo *sqlite3SessionRepository) GetAndDelete(ctx context.Context, code string) (*domain.Session, error) {
s := new(Session) //nolint: varnamelen // cannot redaclare import
tx, err := repo.db.Beginx()
if err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
if err = tx.GetContext(ctx, s, QueryGet, code); err != nil {
//nolint: errcheck // deffered method
defer tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
return nil, session.ErrNotExist
}
return nil, fmt.Errorf("cannot find session in db: %w", err)
}
if _, err = tx.ExecContext(ctx, QueryDelete, code); err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("cannot remove session from db: %w", err)
}
if err = tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
result := new(domain.Session)
if err = s.Populate([]byte(s.Data), result); err != nil {
return nil, fmt.Errorf("cannot decode session data from store: %w", err)
}
result.Code = code
return result, nil
}
func (repo *sqlite3SessionRepository) GC() {}
func NewSession(src *domain.Session) (*Session, error) {
data, err := json.Marshal(src)
if err != nil {
return nil, fmt.Errorf("cannot encode data to JSON: %w", err)
}
return &Session{
CreatedAt: sql.NullTime{
Time: time.Now().UTC(),
Valid: true,
},
Code: src.Code,
Data: base64.StdEncoding.EncodeToString(data),
}, nil
}
func (t *Session) Populate(src []byte, dst *domain.Session) error {
tmp := make([]byte, base64.StdEncoding.DecodedLen(len(src)))
n, err := base64.StdEncoding.Decode(tmp, src)
if err != nil {
return fmt.Errorf("cannot decode base64 data: %w", err)
}
if err = json.Unmarshal(tmp[:n], dst); err != nil {
return fmt.Errorf("cannot decode JSON data: %w", err)
}
return nil
}

View File

@ -0,0 +1,127 @@
package sqlite3_test
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"source.toby3d.me/toby3d/auth/internal/domain"
repository "source.toby3d.me/toby3d/auth/internal/session/repository/sqlite3"
"source.toby3d.me/toby3d/auth/internal/testing/sqltest"
)
//nolint: gochecknoglobals // slices cannot be contants
var tableColumns = []string{"created_at", "code", "data"}
func TestCreate(t *testing.T) {
t.Parallel()
session := domain.TestSession(t)
session.Profile = nil
model, err := repository.NewSession(session)
if err != nil {
t.Fatal(err)
}
db, mock, cleanup := sqltest.Open(t)
t.Cleanup(cleanup)
createTable(t, mock)
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO sessions`)).
WithArgs(
sqltest.Time{},
model.Code,
model.Data,
).
WillReturnResult(sqlmock.NewResult(1, 1))
if err := repository.NewSQLite3SessionRepository(db).
Create(context.Background(), session); err != nil {
t.Error(err)
}
}
func TestGet(t *testing.T) {
t.Parallel()
session := domain.TestSession(t)
session.Profile = nil
model, err := repository.NewSession(session)
if err != nil {
t.Fatal(err)
}
db, mock, cleanup := sqltest.Open(t)
t.Cleanup(cleanup)
createTable(t, mock)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM sessions`)).
WithArgs(session.Code).
WillReturnRows(sqlmock.NewRows(tableColumns).
AddRow(
model.CreatedAt.Time,
model.Code,
model.Data,
))
result, err := repository.NewSQLite3SessionRepository(db).
Get(context.Background(), session.Code)
if err != nil {
t.Fatal(err)
}
if result.Code != session.Code {
t.Errorf("Get(%s) = %+v, want %+v", session.Code, result, session)
}
}
func TestGetAndDelete(t *testing.T) {
t.Parallel()
session := domain.TestSession(t)
session.Profile = nil
model, err := repository.NewSession(session)
if err != nil {
t.Fatal(err)
}
db, mock, cleanup := sqltest.Open(t)
t.Cleanup(cleanup)
createTable(t, mock)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM sessions`)).
WithArgs(session.Code).
WillReturnRows(sqlmock.NewRows(tableColumns).
AddRow(
model.CreatedAt.Time,
model.Code,
model.Data,
))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM sessions`)).
WithArgs(model.Code).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := repository.NewSQLite3SessionRepository(db).
GetAndDelete(context.Background(), session.Code)
if err != nil {
t.Fatal(err)
}
if result.Code != session.Code {
t.Errorf("GetAndDelete(%s) = %+v, want %+v", session.Code, result, session)
}
}
func createTable(tb testing.TB, mock sqlmock.Sqlmock) {
tb.Helper()
mock.ExpectExec(regexp.QuoteMeta(repository.QueryTable)).
WillReturnResult(sqlmock.NewResult(1, 1))
}

View File

@ -0,0 +1,11 @@
package session
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type UseCase interface {
Exchange(ctx context.Context, code string) (*domain.Session, error)
}

View File

@ -0,0 +1,28 @@
package usecase
import (
"context"
"fmt"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/session"
)
type sessionUseCase struct {
sessions session.Repository
}
func NewSessionUseCase(sessions session.Repository) session.UseCase {
return &sessionUseCase{
sessions: sessions,
}
}
func (useCase *sessionUseCase) Exchange(ctx context.Context, code string) (*domain.Session, error) {
session, err := useCase.sessions.GetAndDelete(ctx, code)
if err != nil {
return nil, fmt.Errorf("cannot find session in store: %w", err)
}
return session, nil
}

View File

@ -0,0 +1,35 @@
package bolttest
import (
"os"
"testing"
bolt "go.etcd.io/bbolt"
)
// New returns a temporary empty database bbolt in the temporary directory
// with the cleanup function.
func New(tb testing.TB) (*bolt.DB, func()) {
tb.Helper()
tempFile, err := os.CreateTemp(os.TempDir(), "bbolt_*.db")
if err != nil {
tb.Fatal(err)
}
filePath := tempFile.Name()
if err := tempFile.Close(); err != nil {
tb.Fatal(err)
}
db, err := bolt.Open(filePath, os.ModePerm, nil)
if err != nil {
tb.Fatal(err)
}
return db, func() {
_ = db.Close()
_ = os.Remove(filePath)
}
}

1
internal/testing/httptest/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.pem

View File

@ -0,0 +1,63 @@
//go:generate go run "$GOROOT/src/crypto/tls/generate_cert.go" --host 127.0.0.1,::1,localhost --start-date "Jan 1 00:00:00 1970" --duration=1000000h --ca --rsa-bits 1024 --ecdsa-curve P256
package httptest
import (
"crypto/tls"
_ "embed" // used for running tests without same import in "god object"
"net"
"testing"
"time"
http "github.com/valyala/fasthttp"
httputil "github.com/valyala/fasthttp/fasthttputil"
)
var (
//go:embed cert.pem
certData []byte
//go:embed key.pem
keyData []byte
)
// New returns the InMemory Server and the Client connected to it with the
// specified handler.
func New(tb testing.TB, handler http.RequestHandler) (*http.Client, *http.Server, func()) {
tb.Helper()
//nolint: exhaustivestruct
server := &http.Server{
CloseOnShutdown: true,
DisableKeepalive: true,
ReduceMemoryUsage: true,
Handler: http.TimeoutHandler(handler, 1*time.Second, "handler performance is too slow"),
}
ln := httputil.NewInmemoryListener()
//nolint: errcheck
go server.ServeTLSEmbed(ln, certData, keyData)
//nolint: exhaustivestruct
client := &http.Client{
TLSConfig: &tls.Config{
InsecureSkipVerify: true, //nolint: gosec
},
Dial: func(addr string) (net.Conn, error) {
return ln.Dial() //nolint: wrapcheck
},
}
return client, server, func() {
_ = server.Shutdown()
}
}
// NewRequest returns a new incoming server Request and cleanup function.
func NewRequest(method, target string, body []byte) *http.Request {
req := http.AcquireRequest()
req.Header.SetMethod(method)
req.SetRequestURI(target)
req.SetBody(body)
return req
}

View File

@ -0,0 +1,40 @@
package sqltest
import (
"database/sql/driver"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite" // used for running tests without same import in "god object"
)
type Time struct{}
func (Time) Match(v driver.Value) bool {
_, ok := v.(time.Time)
return ok
}
// Open creates a new InMemory sqlite3 database for testing.
func Open(tb testing.TB) (*sqlx.DB, sqlmock.Sqlmock, func()) {
tb.Helper()
db, mock, err := sqlmock.New()
if err != nil {
tb.Fatal(err)
}
xdb := sqlx.NewDb(db, "sqlite")
if err = xdb.Ping(); err != nil {
_ = db.Close()
tb.Fatal(err)
}
return xdb, mock, func() {
_ = db.Close()
}
}

View File

@ -0,0 +1,273 @@
package http
import (
"errors"
"fmt"
"path"
"github.com/fasthttp/router"
"github.com/goccy/go-json"
"github.com/lestrrat-go/jwx/v2/jwa"
http "github.com/valyala/fasthttp"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/random"
"source.toby3d.me/toby3d/auth/internal/ticket"
"source.toby3d.me/toby3d/auth/web"
"source.toby3d.me/toby3d/form"
"source.toby3d.me/toby3d/middleware"
)
type (
TicketGenerateRequest struct {
// The access token should be used when acting on behalf of this URL.
Subject *domain.Me `form:"subject"`
// The access token will work at this URL.
Resource *domain.URL `form:"resource"`
}
TicketExchangeRequest struct {
// A random string that can be redeemed for an access token.
Ticket string `form:"ticket"`
// The access token should be used when acting on behalf of this URL.
Subject *domain.Me `form:"subject"`
// The access token will work at this URL.
Resource *domain.URL `form:"resource"`
}
RequestHandler struct {
config *domain.Config
matcher language.Matcher
tickets ticket.UseCase
}
)
func NewRequestHandler(tickets ticket.UseCase, matcher language.Matcher, config *domain.Config) *RequestHandler {
return &RequestHandler{
config: config,
matcher: matcher,
tickets: tickets,
}
}
func (h *RequestHandler) Register(r *router.Router) {
//nolint: exhaustivestruct
chain := middleware.Chain{
middleware.CSRFWithConfig(middleware.CSRFConfig{
Skipper: func(ctx *http.RequestCtx) bool {
matched, _ := path.Match("/ticket*", string(ctx.Path()))
return ctx.IsPost() && matched
},
CookieMaxAge: 0,
CookieSameSite: http.CookieSameSiteStrictMode,
ContextKey: "csrf",
CookieDomain: h.config.Server.Domain,
CookieName: "__Secure-csrf",
CookiePath: "",
TokenLookup: "form:_csrf",
TokenLength: 0,
CookieSecure: true,
CookieHTTPOnly: true,
}),
middleware.JWTWithConfig(middleware.JWTConfig{
AuthScheme: "Bearer",
BeforeFunc: nil,
Claims: nil,
ContextKey: "token",
ErrorHandler: nil,
ErrorHandlerWithContext: nil,
ParseTokenFunc: nil,
SigningKey: []byte(h.config.JWT.Secret),
SigningKeys: nil,
SigningMethod: jwa.SignatureAlgorithm(h.config.JWT.Algorithm),
Skipper: middleware.DefaultSkipper,
SuccessHandler: nil,
TokenLookup: "header:" + http.HeaderAuthorization +
"," + "cookie:" + "__Secure-auth-token",
}),
middleware.LogFmt(),
}
r.GET("/ticket", chain.RequestHandler(h.handleRender))
r.POST("/api/ticket", chain.RequestHandler(h.handleSend))
r.POST("/ticket", chain.RequestHandler(h.handleRedeem))
}
func (h *RequestHandler) handleRender(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMETextHTMLCharsetUTF8)
tags, _, _ := language.ParseAcceptLanguage(string(ctx.Request.Header.Peek(http.HeaderAcceptLanguage)))
tag, _, _ := h.matcher.Match(tags...)
baseOf := web.BaseOf{
Config: h.config,
Language: tag,
Printer: message.NewPrinter(tag),
}
csrf, _ := ctx.UserValue("csrf").([]byte)
web.WriteTemplate(ctx, &web.TicketPage{
BaseOf: baseOf,
CSRF: csrf,
})
}
func (h *RequestHandler) handleSend(ctx *http.RequestCtx) {
ctx.Response.Header.Set(http.HeaderAccessControlAllowOrigin, h.config.Server.Domain)
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.SetStatusCode(http.StatusOK)
encoder := json.NewEncoder(ctx)
req := new(TicketGenerateRequest)
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
ticket := &domain.Ticket{
Ticket: "",
Resource: req.Resource,
Subject: req.Subject,
}
var err error
if ticket.Ticket, err = random.String(h.config.TicketAuth.Length); err != nil {
ctx.SetStatusCode(http.StatusInternalServerError)
_ = encoder.Encode(domain.NewError(domain.ErrorCodeServerError, err.Error(), ""))
return
}
if err = h.tickets.Generate(ctx, ticket); err != nil {
ctx.SetStatusCode(http.StatusInternalServerError)
_ = encoder.Encode(domain.NewError(domain.ErrorCodeServerError, err.Error(), ""))
return
}
ctx.SetStatusCode(http.StatusOK)
}
func (h *RequestHandler) handleRedeem(ctx *http.RequestCtx) {
ctx.SetContentType(common.MIMEApplicationJSONCharsetUTF8)
ctx.SetStatusCode(http.StatusOK)
encoder := json.NewEncoder(ctx)
req := new(TicketExchangeRequest)
if err := req.bind(ctx); err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(err)
return
}
token, err := h.tickets.Redeem(ctx, &domain.Ticket{
Ticket: req.Ticket,
Resource: req.Resource,
Subject: req.Subject,
})
if err != nil {
ctx.SetStatusCode(http.StatusBadRequest)
_ = encoder.Encode(domain.NewError(domain.ErrorCodeServerError, err.Error(), ""))
return
}
// TODO(toby3d): print the result as part of the debugging. Instead, we
// need to send or save the token to the recipient for later use.
ctx.SetBodyString(fmt.Sprintf(`{
"access_token": "%s",
"token_type": "Bearer",
"scope": "%s",
"me": "%s"
}`, token.AccessToken, token.Scope.String(), token.Me.String()))
}
func (req *TicketGenerateRequest) bind(ctx *http.RequestCtx) (err error) {
indieAuthError := new(domain.Error)
if err = form.Unmarshal(ctx.Request.PostArgs().QueryString(), req); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
if req.Resource == nil {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"resource value MUST be set",
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
if req.Subject == nil {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"subject value MUST be set",
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
return nil
}
func (req *TicketExchangeRequest) bind(ctx *http.RequestCtx) error {
indieAuthError := new(domain.Error)
if err := form.Unmarshal(ctx.Request.PostArgs().QueryString(), req); err != nil {
if errors.As(err, indieAuthError) {
return indieAuthError
}
return domain.NewError(
domain.ErrorCodeInvalidRequest,
err.Error(),
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
if req.Ticket == "" {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"ticket parameter is required",
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
if req.Resource == nil {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"resource parameter is required",
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
if req.Subject == nil {
return domain.NewError(
domain.ErrorCodeInvalidRequest,
"subject parameter is required",
"https://indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket",
)
}
return nil
}

View File

@ -0,0 +1,114 @@
package http_test
import (
"sync"
"testing"
"github.com/fasthttp/router"
http "github.com/valyala/fasthttp"
"golang.org/x/text/language"
"golang.org/x/text/message"
"source.toby3d.me/toby3d/auth/internal/common"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/testing/httptest"
"source.toby3d.me/toby3d/auth/internal/ticket"
delivery "source.toby3d.me/toby3d/auth/internal/ticket/delivery/http"
ticketrepo "source.toby3d.me/toby3d/auth/internal/ticket/repository/memory"
ucase "source.toby3d.me/toby3d/auth/internal/ticket/usecase"
)
type Dependencies struct {
client *http.Client
config *domain.Config
matcher language.Matcher
store *sync.Map
ticket *domain.Ticket
tickets ticket.Repository
ticketService ticket.UseCase
token *domain.Token
}
func TestUpdate(t *testing.T) {
t.Parallel()
deps := NewDependencies(t)
r := router.New()
delivery.NewRequestHandler(deps.ticketService, deps.matcher, deps.config).Register(r)
client, _, cleanup := httptest.New(t, r.Handler)
t.Cleanup(cleanup)
const requestURI string = "https://example.com/ticket"
req := httptest.NewRequest(http.MethodPost, requestURI, []byte(
`ticket=`+deps.ticket.Ticket+
`&resource=`+deps.ticket.Resource.String()+
`&subject=`+deps.ticket.Subject.String(),
))
defer http.ReleaseRequest(req)
req.Header.SetContentType(common.MIMEApplicationForm)
domain.TestToken(t).SetAuthHeader(req)
resp := http.AcquireResponse()
defer http.ReleaseResponse(resp)
if err := client.Do(req, resp); err != nil {
t.Fatal(err)
}
if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusAccepted {
t.Errorf("POST %s = %d, want %d or %d", requestURI, resp.StatusCode(), http.StatusOK,
http.StatusAccepted)
}
// TODO(toby3d): print the result as part of the debugging. Instead, you
// need to send or save the token to the recipient for later use.
if resp.Body() == nil {
t.Errorf("POST %s = nil, want something", requestURI)
}
}
func NewDependencies(tb testing.TB) Dependencies {
tb.Helper()
config := domain.TestConfig(tb)
matcher := language.NewMatcher(message.DefaultCatalog.Languages())
store := new(sync.Map)
ticket := domain.TestTicket(tb)
token := domain.TestToken(tb)
r := router.New()
// NOTE(toby3d): private resource
r.GET(ticket.Resource.URL().EscapedPath(), func(ctx *http.RequestCtx) {
ctx.SuccessString(common.MIMETextHTMLCharsetUTF8,
`<link rel="token_endpoint" href="https://auth.example.org/token">`)
})
// NOTE(toby3d): token endpoint
r.POST("/token", func(ctx *http.RequestCtx) {
ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, `{
"access_token": "`+token.AccessToken+`",
"me": "`+token.Me.String()+`",
"scope": "`+token.Scope.String()+`",
"token_type": "Bearer"
}`)
})
client, _, cleanup := httptest.New(tb, r.Handler)
tb.Cleanup(cleanup)
tickets := ticketrepo.NewMemoryTicketRepository(store, config)
ticketService := ucase.NewTicketUseCase(tickets, client, config)
return Dependencies{
client: client,
config: config,
matcher: matcher,
store: store,
ticket: ticket,
tickets: tickets,
ticketService: ticketService,
token: token,
}
}

View File

@ -0,0 +1,15 @@
package ticket
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type Repository interface {
Create(ctx context.Context, ticket *domain.Ticket) error
GetAndDelete(ctx context.Context, ticket string) (*domain.Ticket, error)
GC()
}
var ErrNotExist error = domain.NewError(domain.ErrorCodeInvalidRequest, "ticket not exist or expired", "")

View File

@ -0,0 +1,90 @@
package memory
import (
"context"
"fmt"
"path"
"sync"
"time"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/ticket"
)
type (
Ticket struct {
CreatedAt time.Time
*domain.Ticket
}
memoryTicketRepository struct {
config *domain.Config
store *sync.Map
}
)
const DefaultPathPrefix string = "tickets"
func NewMemoryTicketRepository(store *sync.Map, config *domain.Config) ticket.Repository {
return &memoryTicketRepository{
config: config,
store: store,
}
}
func (repo *memoryTicketRepository) Create(_ context.Context, t *domain.Ticket) error {
repo.store.Store(path.Join(DefaultPathPrefix, t.Ticket), &Ticket{
CreatedAt: time.Now().UTC(),
Ticket: t,
})
return nil
}
func (repo *memoryTicketRepository) GetAndDelete(_ context.Context, t string) (*domain.Ticket, error) {
src, ok := repo.store.LoadAndDelete(path.Join(DefaultPathPrefix, t))
if !ok {
return nil, fmt.Errorf("cannot find ticket in store: %w", ticket.ErrNotExist)
}
result, ok := src.(*Ticket)
if !ok {
return nil, fmt.Errorf("cannot decode ticket in store: %w", ticket.ErrNotExist)
}
return result.Ticket, nil
}
func (repo *memoryTicketRepository) GC() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for timeStamp := range ticker.C {
timeStamp := timeStamp.UTC()
repo.store.Range(func(key, value interface{}) bool {
k, ok := key.(string)
if !ok {
return false
}
matched, err := path.Match(DefaultPathPrefix+"/*", k)
if err != nil || !matched {
return false
}
val, ok := value.(*Ticket)
if !ok {
return false
}
if val.CreatedAt.Add(repo.config.Code.Expiry).After(timeStamp) {
return false
}
repo.store.Delete(key)
return false
})
}
}

View File

@ -0,0 +1,63 @@
package memory_test
import (
"context"
"path"
"reflect"
"sync"
"testing"
"time"
"source.toby3d.me/toby3d/auth/internal/domain"
repository "source.toby3d.me/toby3d/auth/internal/ticket/repository/memory"
)
func TestCreate(t *testing.T) {
t.Parallel()
store := new(sync.Map)
ticket := domain.TestTicket(t)
if err := repository.NewMemoryTicketRepository(store, domain.TestConfig(t)).
Create(context.Background(), ticket); err != nil {
t.Fatal(err)
}
storePath := path.Join(repository.DefaultPathPrefix, ticket.Ticket)
src, ok := store.Load(storePath)
if !ok {
t.Fatalf("Load(%s) = %t, want %t", storePath, ok, true)
}
if result, _ := src.(*repository.Ticket); !reflect.DeepEqual(result.Ticket, ticket) {
t.Errorf("Create(%+v) = %+v, want %+v", ticket, result.Ticket, ticket)
}
}
func TestGetAndDelete(t *testing.T) {
t.Parallel()
ticket := domain.TestTicket(t)
store := new(sync.Map)
store.Store(path.Join(repository.DefaultPathPrefix, ticket.Ticket), &repository.Ticket{
CreatedAt: time.Now().UTC(),
Ticket: ticket,
})
result, err := repository.NewMemoryTicketRepository(store, domain.TestConfig(t)).
GetAndDelete(context.Background(), ticket.Ticket)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result, ticket) {
t.Errorf("GetAndDelete(%s) = %+v, want %+v", ticket.Ticket, result, ticket)
}
storePath := path.Join(repository.DefaultPathPrefix, ticket.Ticket)
if src, _ := store.Load(storePath); src != nil {
t.Errorf("Load(%s) = %+v, want %+v", storePath, src, nil)
}
}

View File

@ -0,0 +1,121 @@
package sqlite3
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/ticket"
)
type (
Ticket struct {
CreatedAt sql.NullTime `db:"created_at"`
Resource string `db:"resource"`
Subject string `db:"subject"`
Ticket string `db:"ticket"`
}
sqlite3TicketRepository struct {
config *domain.Config
db *sqlx.DB
}
)
const (
QueryTable string = `CREATE TABLE IF NOT EXISTS tickets (
created_at DATETIME NOT NULL,
resource TEXT NOT NULL,
subject TEXT NOT NULL,
ticket TEXT UNIQUE PRIMARY KEY NOT NULL
);`
QueryGet string = `SELECT *
FROM tickets
WHERE ticket=$1;`
QueryCreate string = `INSERT INTO tickets (created_at, resource, subject, ticket)
VALUES (:created_at, :resource, :subject, :ticket);`
QueryDelete string = `DELETE FROM tickets
WHERE ticket=$1;`
)
func NewSQLite3TicketRepository(db *sqlx.DB, config *domain.Config) ticket.Repository {
db.MustExec(QueryTable)
return &sqlite3TicketRepository{
config: config,
db: db,
}
}
func (repo *sqlite3TicketRepository) Create(ctx context.Context, t *domain.Ticket) error {
if _, err := repo.db.NamedExecContext(ctx, QueryCreate, NewTicket(t)); err != nil {
return fmt.Errorf("cannot create token record in db: %w", err)
}
return nil
}
func (repo *sqlite3TicketRepository) GetAndDelete(ctx context.Context, rawTicket string) (*domain.Ticket, error) {
tx, err := repo.db.Beginx()
if err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
tkt := new(Ticket)
if err = tx.GetContext(ctx, tkt, QueryGet, rawTicket); err != nil {
//nolint: errcheck // deffered method
defer tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
return nil, ticket.ErrNotExist
}
return nil, fmt.Errorf("cannot find ticket in db: %w", err)
}
if _, err = tx.ExecContext(ctx, QueryDelete, rawTicket); err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("cannot remove ticket from db: %w", err)
}
if err = tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
result := new(domain.Ticket)
tkt.Populate(result)
return result, nil
}
func (repo *sqlite3TicketRepository) GC() {}
func NewTicket(src *domain.Ticket) *Ticket {
return &Ticket{
CreatedAt: sql.NullTime{
Time: time.Now().UTC(),
Valid: true,
},
Resource: src.Resource.String(),
Subject: src.Subject.String(),
Ticket: src.Ticket,
}
}
func (t *Ticket) Populate(dst *domain.Ticket) {
dst.Ticket = t.Ticket
dst.Subject, _ = domain.ParseMe(t.Subject)
dst.Resource, _ = domain.ParseURL(t.Resource)
}

View File

@ -0,0 +1,82 @@
package sqlite3_test
import (
"context"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"source.toby3d.me/toby3d/auth/internal/domain"
"source.toby3d.me/toby3d/auth/internal/testing/sqltest"
repository "source.toby3d.me/toby3d/auth/internal/ticket/repository/sqlite3"
)
//nolint: gochecknoglobals // slices cannot be contants
var tableColumns = []string{"created_at", "resource", "subject", "ticket"}
func TestCreate(t *testing.T) {
t.Parallel()
ticket := domain.TestTicket(t)
model := repository.NewTicket(ticket)
db, mock, cleanup := sqltest.Open(t)
t.Cleanup(cleanup)
createTable(t, mock)
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO tickets`)).
WithArgs(
sqltest.Time{},
model.Resource,
model.Subject,
model.Ticket,
).
WillReturnResult(sqlmock.NewResult(1, 1))
if err := repository.NewSQLite3TicketRepository(db, domain.TestConfig(t)).
Create(context.Background(), ticket); err != nil {
t.Error(err)
}
}
func TestGetAndDelete(t *testing.T) {
t.Parallel()
ticket := domain.TestTicket(t)
model := repository.NewTicket(ticket)
db, mock, cleanup := sqltest.Open(t)
t.Cleanup(cleanup)
createTable(t, mock)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM tickets`)).
WithArgs(model.Ticket).
WillReturnRows(sqlmock.NewRows(tableColumns).
AddRow(
model.CreatedAt.Time,
model.Resource,
model.Subject,
model.Ticket,
))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM tickets`)).
WithArgs(model.Ticket).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := repository.NewSQLite3TicketRepository(db, domain.TestConfig(t)).
GetAndDelete(context.Background(), ticket.Ticket)
if err != nil {
t.Fatal(err)
}
if result.Ticket != ticket.Ticket {
t.Errorf("GetAndDelete(%s) = %+v, want %+v", ticket.Ticket, result, ticket)
}
}
func createTable(tb testing.TB, mock sqlmock.Sqlmock) {
tb.Helper()
mock.ExpectExec(regexp.QuoteMeta(repository.QueryTable)).
WillReturnResult(sqlmock.NewResult(1, 1))
}

View File

@ -0,0 +1,24 @@
package ticket
import (
"context"
"source.toby3d.me/toby3d/auth/internal/domain"
)
type UseCase interface {
Generate(ctx context.Context, ticket *domain.Ticket) error
// Redeem transform received ticket into access token.
Redeem(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error)
Exchange(ctx context.Context, ticket string) (*domain.Token, error)
}
var (
ErrTicketEndpointNotExist error = domain.NewError(
domain.ErrorCodeServerError, "ticket_endpoint not found on ticket resource", "",
)
ErrTokenEndpointNotExist error = domain.NewError(
domain.ErrorCodeServerError, "token_endpoint not found on ticket resource", "",
)
)

Some files were not shown because too many files have changed in this diff Show More