Merge branch 'release/0.1.0'
This commit is contained in:
commit
0d8c5c334b
|
@ -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
|
|
@ -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}'
|
|
@ -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 |
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -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 |
|
@ -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"
|
||||||
|
}
|
|
@ -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: {}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
81
go.mod
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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=
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
"",
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
"",
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]
|
||||||
|
}
|
|
@ -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]
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
// The domain package contains models, objects and entities used in all layers
|
||||||
|
// of the project architecture.
|
||||||
|
package domain
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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"),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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"})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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", "")
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
*.pem
|
|
@ -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
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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", "")
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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
Loading…
Reference in New Issue