Add oauth support

This commit is contained in:
Melon 2024-02-07 01:18:17 +00:00
parent d230acccaa
commit a05b2d983e
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
42 changed files with 1935 additions and 1078 deletions

View File

@ -0,0 +1,26 @@
package client_store
import (
"context"
"github.com/1f349/lavender/database"
"github.com/go-oauth2/oauth2/v4"
)
type ClientStore struct {
db *database.DB
}
var _ oauth2.ClientStore = &ClientStore{}
func New(db *database.DB) *ClientStore {
return &ClientStore{db: db}
}
func (c *ClientStore) GetByID(ctx context.Context, id string) (oauth2.ClientInfo, error) {
tx, err := c.db.BeginCtx(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
return tx.GetClientInfo(id)
}

View File

@ -9,12 +9,14 @@ import (
"encoding/pem"
"errors"
"flag"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/server"
"github.com/1f349/lavender/server/pages"
"github.com/1f349/mjwt"
"github.com/1f349/violet/utils"
exitReload "github.com/MrMelon54/exit-reload"
"github.com/google/subcommands"
_ "github.com/mattn/go-sqlite3"
"log"
"os"
"path/filepath"
@ -44,64 +46,56 @@ func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{})
return subcommands.ExitUsageError
}
var conf server.Conf
err := loadConfig(s.configPath, &conf)
openConf, err := os.Open(s.configPath)
if err != nil {
if os.IsNotExist(err) {
log.Println("[Lavender] Error: missing config file")
log.Println("[Tulip] Error: missing config file")
} else {
log.Println("[Lavender] Error: loading config file: ", err)
log.Println("[Tulip] Error: open config file: ", err)
}
return subcommands.ExitFailure
}
var config server.Conf
err = json.NewDecoder(openConf).Decode(&config)
if err != nil {
log.Println("[Tulip] Error: invalid config file: ", err)
return subcommands.ExitFailure
}
configPathAbs, err := filepath.Abs(s.configPath)
if err != nil {
log.Fatal("[Lavender] Failed to get absolute config path")
}
wd := filepath.Dir(configPathAbs)
mSign, err := mjwt.NewMJwtSignerFromFileOrCreate(conf.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096)
signingKey, err := mjwt.NewMJwtSignerFromFileOrCreate(config.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096)
if err != nil {
log.Fatal("[Lavender] Failed to load or create MJWT signer:", err)
}
saveMjwtPubKey(mSign, wd)
saveMjwtPubKey(signingKey, wd)
db, err := database.Open(filepath.Join(wd, "lavender.db.sqlite"))
if err != nil {
log.Fatal("[Lavender] Failed to open database:", err)
}
if err := pages.LoadPages(wd); err != nil {
log.Fatal("[Lavender] Failed to load page templates:", err)
}
srv := server.NewHttpServer(conf, mSign)
log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Server.Addr)
go utils.RunBackgroundHttp("HTTP", srv.Server)
srv := server.NewHttpServer(config, db, signingKey)
log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Addr)
go utils.RunBackgroundHttp("HTTP", srv)
exitReload.ExitReload("Lavender", func() {
var conf server.Conf
err := loadConfig(s.configPath, &conf)
if err != nil {
log.Println("[Lavender] Failed to read config:", err)
}
err = srv.UpdateConfig(conf)
if err != nil {
log.Println("[Lavender] Failed to reload config:", err)
}
}, func() {
exitReload.ExitReload("Lavender", func() {}, func() {
// stop http server
_ = srv.Server.Close()
_ = srv.Close()
})
return subcommands.ExitSuccess
}
func loadConfig(configPath string, conf *server.Conf) error {
openConf, err := os.Open(configPath)
if err != nil {
return err
}
return json.NewDecoder(openConf).Decode(conf)
}
func saveMjwtPubKey(mSign mjwt.Signer, wd string) {
pubKey := x509.MarshalPKCS1PublicKey(mSign.PublicKey())
b := new(bytes.Buffer)

39
database/db-types.go Normal file
View File

@ -0,0 +1,39 @@
package database
import (
"github.com/go-oauth2/oauth2/v4"
"time"
)
type User struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Roles string `json:"roles"`
UpdatedAt time.Time `json:"updated_at"`
Active bool `json:"active"`
}
type ClientInfoDbOutput struct {
Sub, Name, Secret, Domain, Owner string
SSO, Active bool
}
var _ oauth2.ClientInfo = &ClientInfoDbOutput{}
func (c *ClientInfoDbOutput) GetID() string { return c.Sub }
func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret }
func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain }
func (c *ClientInfoDbOutput) IsPublic() bool { return false }
func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner }
// GetName is an extra field for the oauth handler to display the application
// name
func (c *ClientInfoDbOutput) GetName() string { return c.Name }
// IsSSO is an extra field for the oauth handler to skip the user input stage
// this is for trusted applications to get permissions without asking the user
func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO }
// IsActive is an extra field for the app manager to get the active state
func (c *ClientInfoDbOutput) IsActive() bool { return c.Active }

41
database/db.go Normal file
View File

@ -0,0 +1,41 @@
package database
import (
"context"
"database/sql"
_ "embed"
)
//go:embed init.sql
var initSql string
type DB struct{ db *sql.DB }
func Open(p string) (*DB, error) {
db, err := sql.Open("sqlite3", p)
if err != nil {
return nil, err
}
_, err = db.Exec(initSql)
return &DB{db: db}, err
}
func (d *DB) Begin() (*Tx, error) {
begin, err := d.db.Begin()
if err != nil {
return nil, err
}
return &Tx{begin}, err
}
func (d *DB) BeginCtx(ctx context.Context) (*Tx, error) {
begin, err := d.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &Tx{begin}, err
}
func (d *DB) Close() error {
return d.db.Close()
}

5
database/db_test.go Normal file
View File

@ -0,0 +1,5 @@
package database
import (
_ "github.com/mattn/go-sqlite3"
)

21
database/init.sql Normal file
View File

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS users
(
subject TEXT PRIMARY KEY UNIQUE NOT NULL,
email TEXT NOT NULL,
email_verified INTEGER DEFAULT 0 NOT NULL,
roles TEXT NOT NULL,
updated_at DATETIME,
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS client_store
(
subject TEXT PRIMARY KEY UNIQUE NOT NULL,
name TEXT NOT NULL,
secret TEXT UNIQUE NOT NULL,
domain TEXT NOT NULL,
owner TEXT NOT NULL,
sso INTEGER,
active INTEGER DEFAULT 1,
FOREIGN KEY (owner) REFERENCES users (subject)
);

145
database/tx.go Normal file
View File

@ -0,0 +1,145 @@
package database
import (
"database/sql"
"fmt"
"github.com/1f349/lavender/password"
"github.com/go-oauth2/oauth2/v4"
"github.com/google/uuid"
"time"
)
func updatedAt() string {
return time.Now().UTC().Format(time.DateTime)
}
type Tx struct{ tx *sql.Tx }
func (t *Tx) Commit() error {
return t.tx.Commit()
}
func (t *Tx) Rollback() {
_ = t.tx.Rollback()
}
func (t *Tx) HasUser() error {
var exists bool
row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users)`)
err := row.Scan(&exists)
if err != nil {
return err
}
if !exists {
return sql.ErrNoRows
}
return nil
}
func (t *Tx) InsertUser(subject, email string, verifyEmail bool, roles string, active bool) error {
_, err := t.tx.Exec(`INSERT INTO users (subject, email, email_verified, roles, updated_at, active) VALUES (?, ?, ?, ?, ?, ?)`, subject, email, verifyEmail, roles, updatedAt(), active)
return err
}
func (t *Tx) GetUserRoles(sub string) (string, error) {
var r string
row := t.tx.QueryRow(`SELECT roles FROM users WHERE subject = ? LIMIT 1`, sub)
err := row.Scan(&r)
return r, err
}
func (t *Tx) GetUser(sub string) (*User, error) {
var u User
row := t.tx.QueryRow(`SELECT email, email_verified, roles, updated_at, active FROM users WHERE subject = ?`, sub)
err := row.Scan(&u.Email, &u.EmailVerified, &u.Roles, &u.UpdatedAt, &u.Active)
u.Sub = sub
return &u, err
}
func (t *Tx) GetUserEmail(sub string) (string, error) {
var email string
row := t.tx.QueryRow(`SELECT email FROM users WHERE subject = ?`, sub)
err := row.Scan(&email)
return email, err
}
func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) {
var u ClientInfoDbOutput
row := t.tx.QueryRow(`SELECT secret, name, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub)
err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.SSO, &u.Active)
u.Owner = sub
if !u.Active {
return nil, fmt.Errorf("client is not active")
}
return &u, err
}
func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) {
var u []ClientInfoDbOutput
row, err := t.tx.Query(`SELECT subject, name, domain, owner, sso, active FROM client_store LIMIT 25 OFFSET ?`, offset)
if err != nil {
return nil, err
}
defer row.Close()
for row.Next() {
var a ClientInfoDbOutput
err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.SSO, &a.Active)
if err != nil {
return nil, err
}
u = append(u, a)
}
return u, row.Err()
}
func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner string) error {
u := uuid.New()
secret, err := password.GenerateApiSecret(70)
if err != nil {
return err
}
_, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner, sso, active)
return err
}
func (t *Tx) UpdateClientApp(subject uuid.UUID, owner string, name, domain string, sso, active bool) error {
_, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ? AND owner = ?`, name, domain, sso, active, subject.String(), owner)
return err
}
func (t *Tx) ResetClientAppSecret(subject uuid.UUID, owner string) (string, error) {
secret, err := password.GenerateApiSecret(70)
if err != nil {
return "", err
}
_, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ? AND owner = ?`, secret, subject.String(), owner)
return secret, err
}
func (t *Tx) GetUserList(offset int) ([]User, error) {
var u []User
row, err := t.tx.Query(`SELECT subject, email, email_verified, roles, updated_at, active FROM users LIMIT 25 OFFSET ?`, offset)
if err != nil {
return nil, err
}
for row.Next() {
var a User
err := row.Scan(&a.Sub, &a.Email, &a.EmailVerified, &a.Roles, &a.UpdatedAt, &a.Active)
if err != nil {
return nil, err
}
u = append(u, a)
}
return u, row.Err()
}
func (t *Tx) UpdateUser(subject, roles string, active bool) error {
_, err := t.tx.Exec(`UPDATE users SET active = ?, roles = ? WHERE subject = ?`, active, roles, subject)
return err
}
func (t *Tx) UserEmailExists(email string) (exists bool, err error) {
row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = ? and email_verified = 1)`, email)
err = row.Scan(&exists)
return
}

16
go.mod
View File

@ -8,11 +8,14 @@ require (
github.com/1f349/overlapfs v0.0.1
github.com/1f349/violet v0.0.13
github.com/MrMelon54/exit-reload v0.0.1
github.com/emersion/go-message v0.18.0
github.com/go-oauth2/oauth2/v4 v4.5.2
github.com/go-session/session v3.1.2+incompatible
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/subcommands v1.2.0
github.com/google/uuid v1.6.0
github.com/julienschmidt/httprouter v1.3.0
github.com/rs/cors v1.10.1
github.com/mattn/go-sqlite3 v1.14.18
github.com/stretchr/testify v1.8.4
golang.org/x/oauth2 v0.16.0
)
@ -21,9 +24,20 @@ require (
github.com/MrMelon54/rescheduler v0.0.2 // indirect
github.com/becheran/wildmatch-go v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 // indirect
github.com/tidwall/buntdb v1.1.2 // indirect
github.com/tidwall/gjson v1.12.1 // indirect
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e // indirect
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect
golang.org/x/net v0.20.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.32.0 // indirect

156
go.sum
View File

@ -5,18 +5,39 @@ github.com/1f349/mjwt v0.2.1 h1:REdiM/MaNjYQwHvI39LaMPhlvMg4Vy9SgomWMsKTNz8=
github.com/1f349/mjwt v0.2.1/go.mod h1:KEs6jd9JjWrQW+8feP2pGAU7pdA3aYTqjkT/YQr73PU=
github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0=
github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o=
github.com/1f349/violet v0.0.12 h1:VIiVYfKptCYJvwaJHFgtOyTUOURRMIltGp5Blw9+isY=
github.com/1f349/violet v0.0.12/go.mod h1:8xyh96shYiSBkwumvG/KkiY78tAhxiOomDlT7phZAbA=
github.com/1f349/violet v0.0.13 h1:lJpTz15Ea83Uc1VAISXTjtKuzr8Pe8NM4cMGp3Aiyhk=
github.com/1f349/violet v0.0.13/go.mod h1:Ga5/hWqI+EkR6J1mAMNzs7aJhuGcA89XFqgQaDXC7Jo=
github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc=
github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug=
github.com/MrMelon54/rescheduler v0.0.2 h1:efrRwr0BYlkaXFucZDjQqRyIawZiMEAnzjea46Bs9Oc=
github.com/MrMelon54/rescheduler v0.0.2/go.mod h1:OQDFtZHdS4/qA/r7rtJUQA22/hbpnZ9MGQCXOPjhC6w=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA=
github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/emersion/go-message v0.18.0 h1:7LxAXHRpSeoO/Wom3ZApVZYG7c3d17yCScYce8WiXA8=
github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
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/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/go-oauth2/oauth2/v4 v4.5.2 h1:CuZhD3lhGuI6aNLyUbRHXsgG2RwGRBOuCBfd4WQKqBQ=
github.com/go-oauth2/oauth2/v4 v4.5.2/go.mod h1:wk/2uLImWIa9VVQDgxz99H2GDbhmfi/9/Xr+GvkSUSQ=
github.com/go-session/session v3.1.2+incompatible h1:yStchEObKg4nk2F7JGE7KoFIrA/1Y078peagMWcrncg=
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -26,7 +47,6 @@ github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:x
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.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/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=
@ -34,73 +54,171 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
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.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo=
github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI=
github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/gjson v1.12.1 h1:ikuZsLdhr8Ws0IdROXUS1Gi4v9Z4pGqpX/CvJkxvfpo=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
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.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4=
github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/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-20190108225652-1e06a53dbb7e/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-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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20190904154756-749cb33beabd/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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
@ -109,15 +227,21 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ
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.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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=

View File

@ -41,12 +41,16 @@ func NewManagerForTests(services []*WellKnownOIDC) *Manager {
return l
}
func (l *Manager) CheckNamespace(namespace string) bool {
_, ok := l.m[namespace]
func (m *Manager) CheckNamespace(namespace string) bool {
_, ok := m.m[namespace]
return ok
}
func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
func (m *Manager) GetService(namespace string) *WellKnownOIDC {
return m.m[namespace]
}
func (m *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
// @ should have at least one byte before it
n := strings.IndexByte(login, '@')
if n < 1 {
@ -57,5 +61,5 @@ func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC {
if n2 != -1 {
return nil
}
return l.m[login[n+1:]]
return m.GetService(login[n+1:])
}

34
openid/config.go Normal file
View File

@ -0,0 +1,34 @@
package openid
import (
"strings"
)
type Config struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
ResponseTypesSupported []string `json:"response_types_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
func GenConfig(baseUrl string, scopes, claims []string) Config {
baseUrlRaw := baseUrl
if !strings.HasSuffix(baseUrl, "/") {
baseUrl += "/"
}
return Config{
Issuer: baseUrlRaw,
AuthorizationEndpoint: baseUrl + "authorize",
TokenEndpoint: baseUrl + "token",
UserInfoEndpoint: baseUrl + "userinfo",
ResponseTypesSupported: []string{"code"},
ScopesSupported: scopes,
ClaimsSupported: claims,
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
}
}

19
openid/config_test.go Normal file
View File

@ -0,0 +1,19 @@
package openid
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestGenConfig(t *testing.T) {
assert.Equal(t, Config{
Issuer: "https://example.com",
AuthorizationEndpoint: "https://example.com/authorize",
TokenEndpoint: "https://example.com/token",
UserInfoEndpoint: "https://example.com/userinfo",
ResponseTypesSupported: []string{"code"},
ScopesSupported: []string{"openid", "email"},
ClaimsSupported: []string{"name", "email", "preferred_username"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
}, GenConfig("https://example.com", []string{"openid", "email"}, []string{"name", "email", "preferred_username"}))
}

20
pages/index-guest.go.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<div>Not logged in</div>
<div>
<form method="GET" action="/login">
<button type="submit">Login</button>
</form>
</div>
</main>
</body>
</html>

31
pages/index.go.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<div>Logged in as: {{.DisplayName}} ({{.Subject}})</div>
<div>
<form method="GET" action="/manage/apps">
<button type="submit">Manage Applications</button>
</form>
</div>
<div>
<form method="GET" action="/manage/users">
<button type="submit">Manage Users</button>
</form>
</div>
<div>
<form method="POST" action="/logout">
<input type="hidden" name="nonce" value="{{.Nonce}}">
<button type="submit">Log Out</button>
</form>
</div>
</main>
</body>
</html>

View File

@ -11,13 +11,13 @@
<main>
<div>Log in as: <span>{{.LoginName}}</span></div>
<div>
<form method="POST" action="/popup">
<form method="POST" action="/login">
<input type="hidden" name="origin" value="{{.Origin}}"/>
<button type="submit" name="not-you" value="1">Not You?</button>
</form>
</div>
<div>
<form method="POST" action="/popup">
<form method="POST" action="/login">
<input type="hidden" name="origin" value="{{.Origin}}"/>
<input type="hidden" name="loginname" value="{{.LoginName}}"/>
<button type="submit">Continue</button>

View File

@ -9,7 +9,7 @@
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="POST" action="/popup">
<form method="POST" action="/login">
<input type="hidden" name="origin" value="{{.Origin}}"/>
<div>
<label for="field_loginname">Login Name:</label>

148
pages/manage-apps.go.html Normal file
View File

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<script>
window.addEventListener("load", function () {
selectText("app-secret");
});
// Thanks again: https://stackoverflow.com/a/987376
function selectText(nodeId) {
const node = document.getElementById(nodeId);
if (document.body.createTextRange) {
const range = document.body.createTextRange();
range.moveToElementText(node);
range.select();
} else if (window.getSelection) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
} else {
console.warn("Could not select text in node: Unsupported browser.");
}
}
</script>
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="GET" action="/">
<button type="submit">Home</button>
</form>
{{if .NewAppSecret}}
<div>New application secret: <span id="app-secret">{{.NewAppSecret}}</span> for {{.NewAppName}}</div>
{{end}}
{{if .Edit}}
<h2>Edit Client Application</h2>
<form method="POST" action="/manage/apps">
<input type="hidden" name="action" value="edit"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<input type="hidden" name="subject" value="{{.Edit.Sub}}"/>
<div>
<label>ID: {{.Edit.Sub}}</label>
</div>
<div>
<label for="field_name">Name:</label>
<input type="text" name="name" id="field_name" value="{{.Edit.Name}}" required/>
</div>
<div>
<label for="field_domain">Domain:</label>
<input type="text" name="domain" id="field_domain" value="{{.Edit.Domain}}" required/>
</div>
{{if .IsAdmin}}
<div>
<label for="field_sso">SSO: <input type="checkbox" name="sso" id="field_sso"
{{if .Edit.SSO}}checked{{end}}/></label>
</div>
{{end}}
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active"
{{if .Edit.Active}}checked{{end}}/></label>
</div>
<button type="submit">Edit</button>
</form>
<form method="GET" action="/manage/apps">
<input type="hidden" name="offset" value="{{.Offset}}"/>
<button type="submit">Cancel</button>
</form>
{{else}}
<h2>Manage Client Applications</h2>
{{if eq (len .Apps) 0}}
<div>No client applications found</div>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Domain</th>
<th>SSO</th>
<th>Active</th>
<th>Owner</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Apps}}
<tr>
<td>{{.Sub}}</td>
<td>{{.Name}}</td>
<td>{{.Domain}}</td>
<td>{{.SSO}}</td>
<td>{{.Active}}</td>
<td>{{.Owner}}</td>
<td>
<form method="GET" action="/manage/apps">
<input type="hidden" name="offset" value="{{$.Offset}}"/>
<input type="hidden" name="edit" value="{{.Sub}}"/>
<button type="submit">Edit</button>
</form>
<form method="POST" action="/manage/apps?offset={{$.Offset}}">
<input type="hidden" name="action" value="secret"/>
<input type="hidden" name="offset" value="{{$.Offset}}"/>
<input type="hidden" name="subject" value="{{.Sub}}"/>
<button type="submit">Reset Secret</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<h2>Create Client Application</h2>
<form method="POST" action="/manage/apps">
<input type="hidden" name="action" value="create"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<div>
<label for="field_name">Name:</label>
<input type="text" name="name" id="field_name" required/>
</div>
<div>
<label for="field_domain">Domain:</label>
<input type="text" name="domain" id="field_domain" required/>
</div>
{{if .IsAdmin}}
<div>
<label for="field_sso">SSO: <input type="checkbox" name="sso" id="field_sso"/></label>
</div>
{{end}}
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active"
checked/></label>
</div>
<button type="submit">Create</button>
</form>
{{end}}
</main>
</body>
</html>

100
pages/manage-users.go.html Normal file
View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="GET" action="/">
<button type="submit">Home</button>
</form>
{{if .Edit}}
<h2>Edit User</h2>
<form method="POST" action="/manage/users">
<input type="hidden" name="action" value="edit"/>
<input type="hidden" name="offset" value="{{.Offset}}"/>
<div>
<label for="field_subject">Subject:</label>
<input type="text" name="subject" id="field_subject" value="{{.Edit.Subject}}" required/>
</div>
<div>
<label for="field_roles">Roles:</label>
<input type="text" name="roles" id="field_roles" value="{{.Edit.Roles}}"/>
</div>
<div>
<label for="field_active">Active: <input type="checkbox" name="active" id="field_active" checked/></label>
</div>
<button type="submit">Edit</button>
</form>
<form method="GET" action="/manage/users">
<input type="hidden" name="offset" value="{{.Offset}}"/>
<button type="submit">Cancel</button>
</form>
{{else}}
<h2>Manage Users</h2>
{{if eq (len .Users) 0}}
<div>No users found, this is definitely a bug.</div>
{{else}}
<table>
<thead>
<tr>
<th>Subject</th>
<th>Email</th>
<th>Email Verified</th>
<th>Roles</th>
<th>Last Updated</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Sub}}</td>
<th>
{{if $.EmailShow}}
<span>{{.Email}}</span>
{{else}}
<span>{{emailHide .Email}}</span>
{{end}}
</th>
<th>{{.EmailVerified}}</th>
<th>{{.Roles}}</th>
<th>{{.UpdatedAt}}</th>
<td>{{.Active}}</td>
<td>
{{if eq $.CurrentAdmin .Sub}}
<span></span>
{{else}}
<form method="GET" action="/manage/users">
<input type="hidden" name="offset" value="{{$.Offset}}"/>
<input type="hidden" name="edit" value="{{.Sub}}"/>
<button type="submit">Edit</button>
</form>
<form method="POST" action="/reset-password">
<input type="hidden" name="email" value="{{.Email}}"/>
<button type="submit">Send Reset Password Email</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<form method="GET" action="/manage/users">
<input type="hidden" name="offset" value="{{.Offset}}"/>
{{if not .EmailShow}}
<input type="hidden" name="show-email"/>
{{end}}
<button type="submit">{{if .EmailShow}}Hide Email Addresses{{else}}Show email addresses{{end}}</button>
</form>
{{end}}
{{end}}
</main>
</body>
</html>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main>
<form method="POST" action="/authorize">
<div>The application {{.AppName}} wants to access your account ({{.DisplayName}}). It requests the following permissions:</div>
<div>
<ul>
{{range .WantsList}}
<li>{{.}}</li>
{{end}}
</ul>
</div>
<div>
<input type="hidden" name="response_type" value="{{.ResponseType}}"/>
<input type="hidden" name="response_mode" value="{{.ResponseMode}}">
<input type="hidden" name="client_id" value="{{.ClientID}}"/>
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}"/>
<input type="hidden" name="state" value="{{.State}}"/>
<input type="hidden" name="scope" value="{{.Scope}}"/>
<input type="hidden" name="nonce" value="{{.Nonce}}"/>
<button class="oauth-action-authorize" name="oauth_action" value="authorize">Authorize</button>
<button class="oauth-action-cancel" name="oauth_action" value="cancel">Cancel</button>
</div>
<div>Authorizing this action will redirect you to {{.AppDomain}} with access to the permissions requested above.</div>
</form>
</main>
</body>
</html>

View File

@ -16,14 +16,14 @@ import (
var (
//go:embed *.go.html
flowPages embed.FS
flowTemplates *template.Template
loadOnce sync.Once
wwwPages embed.FS
wwwTemplates *template.Template
loadOnce sync.Once
)
func LoadPages(wd string) (err error) {
loadOnce.Do(func() {
var o fs.FS = flowPages
var o fs.FS = wwwPages
if wd != "" {
wwwDir := filepath.Join(wd, "www")
err = os.Mkdir(wwwDir, os.ModePerm)
@ -31,16 +31,28 @@ func LoadPages(wd string) (err error) {
return
}
wdFs := os.DirFS(wwwDir)
o = overlapfs.OverlapFS{A: flowPages, B: wdFs}
o = overlapfs.OverlapFS{A: wwwPages, B: wdFs}
}
flowTemplates, err = template.ParseFS(o, "*.go.html")
wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{
"emailHide": EmailHide,
}).ParseFS(o, "*.go.html")
})
return err
}
func RenderPageTemplate(wr io.Writer, name string, data any) {
err := flowTemplates.ExecuteTemplate(wr, name+".go.html", data)
err := wwwTemplates.ExecuteTemplate(wr, name+".go.html", data)
if err != nil {
log.Printf("Failed to render page: %s: %s\n", name, err)
}
}
func EmailHide(a string) string {
b := []byte(a)
for i := range b {
if b[i] != '@' && b[i] != '.' {
b[i] = 'x'
}
}
return string(b)
}

11
pages/pages_test.go Normal file
View File

@ -0,0 +1,11 @@
package pages
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestEmailHide(t *testing.T) {
assert.Equal(t, "xx", EmailHide("hi"))
assert.Equal(t, "xxxxxxx@xxxxxxx.xxx", EmailHide("example@example.com"))
}

19
password/secret.go Normal file
View File

@ -0,0 +1,19 @@
package password
import "crypto/rand"
func GenerateApiSecret(length int) (string, error) {
const secretChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_."
var _ = secretChars[63] // compiler check: ensure there is at least 64 chars here
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
return "", err
}
for i := range b {
b[i] = secretChars[b[i]&0x3f] // only use the lower 6 bits
}
return string(b), nil
}

80
scope/scope.go Normal file
View File

@ -0,0 +1,80 @@
package scope
import (
"errors"
"strings"
)
var ErrInvalidScope = errors.New("invalid scope")
var scopeDescription = map[string]string{
"openid": "Verify your user identity",
"name": "Access your name",
"username": "Access your username",
"profile": "Access your public profile",
"email": "Access your email",
"birthdate": "Access your birthdate",
"age": "Access your current age",
"zoneinfo": "Access time zone setting",
"locale": "Access your language setting",
}
func ScopesExist(scope string) bool {
_, err := internalGetScopes(scope, func(key, desc string) string { return "" })
return err == nil
}
// FancyScopeList takes a scope string and outputs a slice of scope descriptions
func FancyScopeList(scope string) (arr []string) {
a, err := internalGetScopes(scope, func(key, desc string) string {
return desc
})
if err != nil {
return nil
}
return a
}
func internalGetScopes(scope string, f func(key, desc string) string) (arr []string, err error) {
seen := make(map[string]struct{})
outer:
for {
n := strings.IndexAny(scope, ", ")
var key string
switch n {
case 0:
// first char is matching, no key name found, just continue
scope = scope[1:]
continue outer
case -1:
// no more matching chars, if scope is empty then we are done
if len(scope) == 0 {
return
}
// otherwise set the key and empty scope
key = scope
scope = ""
default:
// set the key and trim from scope
key = scope[:n]
scope = scope[n+1:]
}
// check if key has been seen already
if _, ok := seen[key]; ok {
continue outer
}
// set seen flag
seen[key] = struct{}{}
// output the description
if d, ok := scopeDescription[key]; ok && d != "" {
arr = append(arr, f(key, d))
continue
}
err = ErrInvalidScope
}
}

42
scope/scope_test.go Normal file
View File

@ -0,0 +1,42 @@
package scope
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestScopesExist(t *testing.T) {
desc := scopeDescription
scopeDescription = map[string]string{
"a": "A",
"b": "B",
"c": "C",
}
assert.True(t, ScopesExist("a b c"))
assert.False(t, ScopesExist("a b d"))
assert.True(t, ScopesExist("a,b c"))
assert.False(t, ScopesExist("a,b d"))
scopeDescription = desc
}
func TestFancyScopeList(t *testing.T) {
desc := scopeDescription
scopeDescription = map[string]string{
"a": "A",
"b": "B",
"c": "C",
}
assert.Equal(t, []string{"A"}, FancyScopeList("a"))
assert.Equal(t, []string{"A", "B"}, FancyScopeList("a b"))
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a b c"))
assert.Equal(t, []string{"A", "B"}, FancyScopeList("a,b"))
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a,b,c"))
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a b,c"))
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a,b c"))
assert.Equal(t, []string{"A", "B", "C"}, FancyScopeList("a, b, c"))
scopeDescription = desc
}

133
server/auth.go Normal file
View File

@ -0,0 +1,133 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"github.com/1f349/lavender/database"
"github.com/go-session/session"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
"strings"
)
type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
type UserAuth struct {
Session session.Store
Data SessionData
}
type SessionData struct {
ID string
DisplayName string
}
func (u UserAuth) IsGuest() bool {
return u.Data.ID == ""
}
func (u UserAuth) SaveSessionData() error {
u.Session.Set("session-data", u.Data)
return u.Session.Save()
}
func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
var roles string
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.Data.ID)
return
}) {
return
}
if HasRole(roles, "lavender:admin") {
http.Error(rw, "403 Forbidden", http.StatusForbidden)
return
}
next(rw, req, params, auth)
})
}
func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
return h.OptionalAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
if auth.IsGuest() {
redirectUrl := PrepareRedirectUrl("/login", req.URL)
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
return
}
next(rw, req, params, auth)
})
}
func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
auth, err := internalAuthenticationHandler(rw, req)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
if auth.IsGuest() {
if loginCookie, err := req.Cookie("login-data"); err == nil {
if decryptedBytes, err := base64.RawStdEncoding.DecodeString(loginCookie.Value); err == nil {
if decryptedData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), decryptedBytes, []byte("login-data")); err == nil {
auth.Data.ID = string(decryptedData)
}
}
}
}
next(rw, req, params, auth)
}
}
func internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) {
ss, err := session.Start(req.Context(), rw, req)
if err != nil {
return UserAuth{}, fmt.Errorf("failed to start session")
}
// get auth object
userIdRaw, ok := ss.Get("session-data")
if !ok {
return UserAuth{Session: ss}, nil
}
userData, ok := userIdRaw.(SessionData)
if !ok {
ss.Delete("session-data")
err := ss.Save()
if err != nil {
return UserAuth{Session: ss}, fmt.Errorf("failed to reset invalid session data")
}
}
return UserAuth{Session: ss, Data: userData}, nil
}
func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL {
// find start of query parameters in target path
n := strings.IndexByte(targetPath, '?')
v := url.Values{}
// parse existing query parameters
if n != -1 {
q, err := url.ParseQuery(targetPath[n+1:])
if err != nil {
panic("PrepareRedirectUrl: invalid hardcoded target path query parameters")
}
v = q
targetPath = targetPath[:n]
}
// add path of origin as a new query parameter
orig := origin.Path
if origin.RawQuery != "" || origin.ForceQuery {
orig += "?" + origin.RawQuery
}
if orig != "" {
v.Set("redirect", orig)
}
return &url.URL{Path: targetPath, RawQuery: v.Encode()}
}

View File

@ -2,20 +2,12 @@ package server
import (
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/utils"
)
type Conf struct {
Listen string `json:"listen"`
BaseUrl string `json:"base_url"`
ServiceName string `json:"service_name"`
Issuer string `json:"issuer"`
SsoServices []issuer.SsoConfig `json:"sso_services"`
AllowedClients []AllowedClient `json:"allowed_clients"`
Users UserConfig `json:"users"`
}
type AllowedClient struct {
Url utils.JsonUrl `json:"url"`
Permissions []string `json:"permissions"`
Listen string `json:"listen"`
BaseUrl string `json:"base_url"`
ServiceName string `json:"service_name"`
Issuer string `json:"issuer"`
SsoServices []issuer.SsoConfig `json:"sso_services"`
}

33
server/db.go Normal file
View File

@ -0,0 +1,33 @@
package server
import (
"github.com/1f349/lavender/database"
"log"
"net/http"
)
// DbTx wraps a database transaction with http error messages and a simple action
// function. If the action function returns an error the transaction will be
// rolled back. If there is no error then the transaction is committed.
func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Tx) error) bool {
tx, err := h.db.Begin()
if err != nil {
http.Error(rw, "Failed to begin database transaction", http.StatusInternalServerError)
return true
}
defer tx.Rollback()
err = action(tx)
if err != nil {
http.Error(rw, "Database error", http.StatusInternalServerError)
log.Println("Database action error:", err)
return true
}
err = tx.Commit()
if err != nil {
http.Error(rw, "Database error", http.StatusInternalServerError)
log.Println("Database commit error:", err)
}
return false
}

View File

@ -1,146 +0,0 @@
package server
import (
"context"
_ "embed"
"fmt"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/server/pages"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"golang.org/x/oauth2"
"net/http"
"net/url"
"strings"
"time"
)
var uuidNewStringState = uuid.NewString
var uuidNewStringAti = uuid.NewString
var uuidNewStringRti = uuid.NewString
var testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) {
return oa2conf.Exchange(ctx, code)
}
var testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
client := oidc.OAuth2Config.Client(ctx, exchange)
return client.Get(oidc.UserInfoEndpoint)
}
func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil {
pages.RenderPageTemplate(rw, "flow-popup-memory", map[string]any{
"ServiceName": h.conf.Load().ServiceName,
"Origin": req.URL.Query().Get("origin"),
"LoginName": cookie.Value,
})
return
}
pages.RenderPageTemplate(rw, "flow-popup", map[string]any{
"ServiceName": h.conf.Load().ServiceName,
"Origin": req.URL.Query().Get("origin"),
})
}
func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
if req.PostFormValue("not-you") == "1" {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: "",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(rw, req, (&url.URL{
Path: "/popup",
RawQuery: url.Values{
"origin": []string{req.PostFormValue("origin")},
}.Encode(),
}).String(), http.StatusFound)
return
}
loginName := req.PostFormValue("loginname")
login := h.manager.Load().FindServiceFromLogin(loginName)
if login == nil {
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
return
}
// the @ must exist if the service is defined
n := strings.IndexByte(loginName, '@')
loginUn := loginName[:n]
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: loginName,
Path: "/",
Expires: future,
MaxAge: int(future.Sub(now).Seconds()),
Secure: true,
SameSite: http.SameSiteStrictMode,
})
targetOrigin := req.PostFormValue("origin")
allowedService, found := (*h.services.Load())[targetOrigin]
if !found {
http.Error(rw, "Invalid target origin", http.StatusBadRequest)
return
}
// save state for use later
state := login.Config.Namespace + ":" + uuidNewStringState()
h.flowState.Set(state, flowStateData{
login,
allowedService,
}, time.Now().Add(15*time.Minute))
// generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config
oa2conf.RedirectURL = h.conf.Load().BaseUrl + "/callback"
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
http.Redirect(rw, req, nextUrl, http.StatusFound)
}
func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
err := req.ParseForm()
if err != nil {
http.Error(rw, "Error parsing form", http.StatusBadRequest)
return
}
q := req.URL.Query()
state := q.Get("state")
n := strings.IndexByte(state, ':')
if n == -1 || !h.manager.Load().CheckNamespace(state[:n]) {
http.Error(rw, "Invalid state namespace", http.StatusBadRequest)
return
}
v, found := h.flowState.Get(state)
if !found {
http.Error(rw, "Invalid state", http.StatusBadRequest)
return
}
oa2conf := v.sso.OAuth2Config
oa2conf.RedirectURL = h.conf.Load().BaseUrl + "/callback"
exchange, err := testOa2Exchange(oa2conf, context.Background(), q.Get("code"))
if err != nil {
fmt.Println("Failed exchange:", err)
http.Error(rw, "Failed to exchange code", http.StatusInternalServerError)
return
}
h.finishTokenGenerateFlow(rw, req, v, exchange, func(accessToken, refreshToken string, v3 map[string]any) {
pages.RenderPageTemplate(rw, "flow-callback", map[string]any{
"ServiceName": h.conf.Load().ServiceName,
"TargetOrigin": v.target.Url.String(),
"TargetMessage": v3,
"AccessToken": accessToken,
"RefreshToken": refreshToken,
})
})
}

View File

@ -1,445 +0,0 @@
package server
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"github.com/1f349/cache"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/server/pages"
"github.com/1f349/lavender/utils"
"github.com/1f349/mjwt"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"unicode"
)
const lavenderDomain = "http://localhost:0"
const clientAppDomain = "http://localhost:1"
const loginDomain = "http://localhost:2"
var clientAppMeta AllowedClient
var testSigner mjwt.Signer
var testOidc = &issuer.WellKnownOIDC{
Config: issuer.SsoConfig{
Addr: utils.JsonUrl{},
Namespace: "example.com",
Client: issuer.SsoConfigClient{
ID: "test-id",
Secret: "test-secret",
Scopes: []string{"openid"},
},
},
Issuer: "https://example.com",
AuthorizationEndpoint: loginDomain + "/authorize",
TokenEndpoint: loginDomain + "/token",
UserInfoEndpoint: loginDomain + "/userinfo",
ResponseTypesSupported: nil,
ScopesSupported: nil,
ClaimsSupported: nil,
GrantTypesSupported: nil,
OAuth2Config: oauth2.Config{
ClientID: "test-id",
ClientSecret: "test-secret",
Endpoint: oauth2.Endpoint{
AuthURL: loginDomain + "/authorize",
TokenURL: loginDomain + "/token",
AuthStyle: oauth2.AuthStyleInHeader,
},
Scopes: nil,
},
}
var testManager = issuer.NewManagerForTests([]*issuer.WellKnownOIDC{testOidc})
var testHttpServer = HttpServer{
r: nil,
flowState: cache.New[string, flowStateData](),
}
func init() {
testHttpServer.conf.Store(&Conf{
BaseUrl: lavenderDomain,
ServiceName: "Test Lavender Service",
})
testHttpServer.manager.Store(testManager)
testHttpServer.services.Store(&map[string]AllowedClient{
clientAppDomain: {},
})
err := pages.LoadPages("")
if err != nil {
panic(err)
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
testSigner = mjwt.NewMJwtSigner("https://example.com", key)
testHttpServer.signer = testSigner
parse, err := url.Parse(clientAppDomain)
if err != nil {
panic(err)
}
clientAppMeta = AllowedClient{
Url: utils.JsonUrl{URL: parse},
Permissions: []string{"test-perm"},
}
}
func TestFlowPopup(t *testing.T) {
h := HttpServer{}
h.conf.Store(&Conf{ServiceName: "Test Service Name"})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/popup?"+url.Values{"origin": []string{clientAppDomain}}.Encode(), nil)
h.flowPopup(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Test Service Name</title>
<link rel="stylesheet" href="/theme/style.css">
</head>
<body>
<header>
<h1>Test Service Name</h1>
</header>
<main>
<form method="POST" action="/popup">
<input type="hidden" name="origin" value="%s"/>
<div>
<label for="field_loginname">Login Name:</label>
<input type="text" name="loginname" id="field_loginname" required/>
</div>
<button type="submit">Continue</button>
</form>
</main>
</body>
</html>
`, clientAppDomain), rec.Body.String())
}
func TestFlowPopupPost(t *testing.T) {
// test no login service error
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
"loginname": []string{"test@missing.example.com"},
"origin": []string{clientAppDomain},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowPopupPost(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "No login service defined for this username\n", rec.Body.String())
// test invalid target origin error
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
"loginname": []string{"test@example.com"},
"origin": []string{"http://localhost:1010"},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowPopupPost(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "Invalid target origin\n", rec.Body.String())
// test successful request
nextState := uuid.NewString()
uuidNewStringState = func() string { return nextState }
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/popup", strings.NewReader(url.Values{
"loginname": []string{"test@example.com"},
"origin": []string{clientAppDomain},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowPopupPost(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusFound, rec.Code)
assert.Equal(t, "", rec.Body.String())
assert.Equal(t, loginDomain+"/authorize?"+url.Values{
"client_id": []string{"test-id"},
"login_name": []string{"test"},
"redirect_uri": []string{lavenderDomain + "/callback"},
"response_type": []string{"code"},
"state": []string{"example.com:" + nextState},
}.Encode(), rec.Header().Get("Location"))
}
func TestFlowCallback(t *testing.T) {
expiryTime := time.Now().Add(15 * time.Minute)
nextState := uuid.NewString()
testHttpServer.flowState.Set("example.com:"+nextState, flowStateData{
sso: testOidc,
target: clientAppMeta,
}, expiryTime)
testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) {
return nil, errors.New("no exchange should be made")
}
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
return nil, errors.New("no userinfo should be fetched")
}
// test parse form error
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback?%+"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "Error parsing form\n", rec.Body.String())
// test invalid namespace
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"missing.example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "Invalid state namespace\n", rec.Body.String())
// test invalid state
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:invalid"},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "Invalid state\n", rec.Body.String())
// test failed exchange
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, "Failed to exchange code\n", rec.Body.String())
testOa2Exchange = func(oa2conf oauth2.Config, ctx context.Context, code string) (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: "abcd1234",
TokenType: "",
RefreshToken: "efgh5678",
Expiry: expiryTime,
}, nil
}
// test failed userinfo
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, "Failed to get userinfo\n", rec.Body.String())
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusInternalServerError)
return rec.Result(), nil
}
// test failed userinfo status code
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, "Failed to get userinfo: unexpected status code\n", rec.Body.String())
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.Body.WriteString("{")
return rec.Result(), nil
}
// test failed userinfo decode
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, "Failed to decode userinfo\n", rec.Body.String())
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.Body.WriteString("{\"sub\":1}")
return rec.Result(), nil
}
// test invalid subject in userinfo
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, "Invalid subject in userinfo\n", rec.Body.String())
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.Body.WriteString("{\"sub\":\"1\",\"aud\":1}")
return rec.Result(), nil
}
// test invalid audience in userinfo
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, "Invalid audience in userinfo\n", rec.Body.String())
testOa2UserInfo = func(oidc *issuer.WellKnownOIDC, ctx context.Context, exchange *oauth2.Token) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
_, _ = rec.Body.WriteString(fmt.Sprintf(`{
"sub": "test-user",
"aud": "%s",
"test-field": "ok"
}
`, clientAppDomain))
return rec.Result(), nil
}
// test successful request
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/callback?"+url.Values{
"state": []string{"example.com:" + nextState},
"origin": []string{clientAppDomain},
}.Encode(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
testHttpServer.flowCallback(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusOK, rec.Code)
const p1 = `<!DOCTYPE html>
<html lang="en">
<head>
<title>Test Lavender Service</title>
<link rel="stylesheet" href="/theme/style.css">
<script>
let loginData = {
target:"%s",
tokens: `
const p2 = `,
userinfo:{"aud":"%s","sub":"test-user","test-field":"ok"},
};
window.addEventListener("load", function () {
window.opener.postMessage(loginData, loginData.target);
setTimeout(function () {
window.close();
}, 500);
});
</script>
</head>
<body>
<header>
<h1>Test Lavender Service</h1>
</header>
<main id="mainBody">Loading...</main>
</body>
</html>
`
var p1v = fmt.Sprintf(p1, clientAppDomain)
var p2v = fmt.Sprintf(p2, clientAppDomain)
a := make([]byte, len(p1v))
n, err := rec.Body.Read(a)
assert.NoError(t, err)
assert.Equal(t, len(p1v), n)
assert.Equal(t, p1v, string(a))
var accessToken, refreshToken string
findByte(rec.Body, '{')
findString(rec.Body, "access:")
readQuotedString(rec.Body, &accessToken)
findByte(rec.Body, ',')
findString(rec.Body, "refresh:")
readQuotedString(rec.Body, &refreshToken)
findByte(rec.Body, ',')
findByte(rec.Body, '}')
assert.Equal(t, p2v, rec.Body.String())
}
func findByte(buf *bytes.Buffer, v byte) {
for {
readByte, err := buf.ReadByte()
if err != nil {
panic(err)
}
if readByte == v {
break
}
if !unicode.IsSpace(rune(readByte)) {
panic(fmt.Sprint("Found non space rune: ", readByte))
}
}
}
func findString(buf *bytes.Buffer, v string) {
if len(v) == 0 {
panic("Cannot find empty string")
}
findByte(buf, v[0])
if len(v) > 1 {
a2 := make([]byte, len(v)-1)
n, err := buf.Read(a2)
if err != nil {
panic(err)
}
if n != len(a2) {
panic("Probably found end of buffer")
}
if bytes.Compare([]byte(v[1:]), a2) != 0 {
panic("Failed to find string in buffer")
}
}
}
func readQuotedString(buf *bytes.Buffer, p *string) {
findByte(buf, '"')
b, err := buf.ReadBytes('"')
if err != nil {
panic(err)
}
*p = string(b)
}

34
server/home.go Normal file
View File

@ -0,0 +1,34 @@
package server
import (
"github.com/1f349/lavender/pages"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"net/http"
)
func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
rw.Header().Set("Content-Type", "text/html")
rw.WriteHeader(http.StatusOK)
if auth.IsGuest() {
pages.RenderPageTemplate(rw, "index-guest", map[string]any{
"ServiceName": h.conf.ServiceName,
})
return
}
lNonce := uuid.NewString()
auth.Session.Set("action-nonce", lNonce)
if auth.Session.Save() != nil {
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
pages.RenderPageTemplate(rw, "index", map[string]any{
"ServiceName": h.conf.ServiceName,
"Auth": auth,
"Subject": auth.Data.ID,
"DisplayName": auth.Data.DisplayName,
"Nonce": lNonce,
})
}

79
server/login.go Normal file
View File

@ -0,0 +1,79 @@
package server
import (
"github.com/1f349/lavender/pages"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"golang.org/x/oauth2"
"net/http"
"net/url"
"strings"
"time"
)
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil {
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
"ServiceName": h.conf.ServiceName,
"Origin": req.URL.Query().Get("origin"),
"LoginName": cookie.Value,
})
return
}
pages.RenderPageTemplate(rw, "login", map[string]any{
"ServiceName": h.conf.ServiceName,
"Origin": req.URL.Query().Get("origin"),
})
}
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
if req.PostFormValue("not-you") == "1" {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: "",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(rw, req, (&url.URL{
Path: "/login",
RawQuery: url.Values{
"origin": []string{req.PostFormValue("origin")},
}.Encode(),
}).String(), http.StatusFound)
return
}
loginName := req.PostFormValue("loginname")
login := h.manager.FindServiceFromLogin(loginName)
if login == nil {
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
return
}
// the @ must exist if the service is defined
n := strings.IndexByte(loginName, '@')
loginUn := loginName[:n]
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: loginName,
Path: "/",
Expires: future,
MaxAge: int(future.Sub(now).Seconds()),
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// save state for use later
state := login.Config.Namespace + ":" + uuid.NewString()
h.flowState.Set(state, flowStateData{login}, time.Now().Add(15*time.Minute))
// generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config
oa2conf.RedirectURL = h.conf.BaseUrl + "/callback"
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
http.Redirect(rw, req, nextUrl, http.StatusFound)
}

149
server/manage-apps.go Normal file
View File

@ -0,0 +1,149 @@
package server
import (
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/go-oauth2/oauth2/v4"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
"strconv"
)
func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
offset := 0
q := req.URL.Query()
if q.Has("offset") {
var err error
offset, err = strconv.Atoi(q.Get("offset"))
if err != nil {
http.Error(rw, "400 Bad Request: Invalid offset", http.StatusBadRequest)
return
}
}
var roles string
var appList []database.ClientInfoDbOutput
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.Data.ID)
if err != nil {
return
}
appList, err = tx.GetAppList(offset)
return
}) {
return
}
m := map[string]any{
"ServiceName": h.conf.ServiceName,
"Apps": appList,
"Offset": offset,
"IsAdmin": HasRole(roles, "lavender:admin"),
"NewAppName": q.Get("NewAppName"),
"NewAppSecret": q.Get("NewAppSecret"),
}
if q.Has("edit") {
for _, i := range appList {
if i.Sub == q.Get("edit") {
m["Edit"] = i
goto validEdit
}
}
http.Error(rw, "400 Bad Request: Invalid client app to edit", http.StatusBadRequest)
return
}
validEdit:
rw.Header().Set("Content-Type", "text/html")
rw.WriteHeader(http.StatusOK)
pages.RenderPageTemplate(rw, "manage-apps", m)
}
func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
err := req.ParseForm()
if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)
return
}
offset := req.Form.Get("offset")
action := req.Form.Get("action")
name := req.Form.Get("name")
domain := req.Form.Get("domain")
sso := req.Form.Has("sso")
active := req.Form.Has("active")
if sso {
var roles string
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.Data.ID)
return
}) {
return
}
if HasRole(roles, "lavender:admin") {
http.Error(rw, "400 Bad Request: Only admin users can create SSO client applications", http.StatusBadRequest)
return
}
}
switch action {
case "create":
if h.DbTx(rw, func(tx *database.Tx) error {
return tx.InsertClientApp(name, domain, sso, active, auth.Data.ID)
}) {
return
}
case "edit":
if h.DbTx(rw, func(tx *database.Tx) error {
sub, err := uuid.Parse(req.Form.Get("subject"))
if err != nil {
return err
}
return tx.UpdateClientApp(sub, auth.Data.ID, name, domain, sso, active)
}) {
return
}
case "secret":
var info oauth2.ClientInfo
var secret string
if h.DbTx(rw, func(tx *database.Tx) error {
sub, err := uuid.Parse(req.Form.Get("subject"))
if err != nil {
return err
}
info, err = tx.GetClientInfo(sub.String())
if err != nil {
return err
}
secret, err = tx.ResetClientAppSecret(sub, auth.Data.ID)
return err
}) {
return
}
appName := "Unknown..."
if getName, ok := info.(interface{ GetName() string }); ok {
appName = getName.GetName()
}
h.ManageAppsGet(rw, &http.Request{
URL: &url.URL{
RawQuery: url.Values{
"offset": []string{offset},
"NewAppName": []string{appName},
"NewAppSecret": []string{secret},
}.Encode(),
},
}, httprouter.Params{}, auth)
return
default:
http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest)
return
}
redirectUrl := url.URL{Path: "/manage/apps", RawQuery: url.Values{"offset": []string{offset}}.Encode()}
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
}

104
server/manage-users.go Normal file
View File

@ -0,0 +1,104 @@
package server
import (
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/pages"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
"strconv"
)
func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
offset := 0
q := req.URL.Query()
if q.Has("offset") {
var err error
offset, err = strconv.Atoi(q.Get("offset"))
if err != nil {
http.Error(rw, "400 Bad Request: Invalid offset", http.StatusBadRequest)
return
}
}
var roles string
var userList []database.User
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.Data.ID)
if err != nil {
return
}
userList, err = tx.GetUserList(offset)
return
}) {
return
}
if HasRole(roles, "lavender:admin") {
http.Error(rw, "403 Forbidden", http.StatusForbidden)
return
}
m := map[string]any{
"ServiceName": h.conf.ServiceName,
"Users": userList,
"Offset": offset,
"EmailShow": req.URL.Query().Has("show-email"),
"CurrentAdmin": auth.Data.ID,
}
if q.Has("edit") {
for _, i := range userList {
if i.Sub == q.Get("edit") {
m["Edit"] = i
goto validEdit
}
}
http.Error(rw, "400 Bad Request: Invalid user to edit", http.StatusBadRequest)
return
}
validEdit:
rw.Header().Set("Content-Type", "text/html")
rw.WriteHeader(http.StatusOK)
pages.RenderPageTemplate(rw, "manage-users", m)
}
func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
err := req.ParseForm()
if err != nil {
http.Error(rw, "400 Bad Request: Failed to parse form", http.StatusBadRequest)
return
}
var roles string
if h.DbTx(rw, func(tx *database.Tx) (err error) {
roles, err = tx.GetUserRoles(auth.Data.ID)
return
}) {
return
}
if HasRole(roles, "lavender:admin") {
http.Error(rw, "400 Bad Request: Only admin users can manage users", http.StatusBadRequest)
return
}
offset := req.Form.Get("offset")
action := req.Form.Get("action")
newRoles := req.Form.Get("role")
active := req.Form.Has("active")
switch action {
case "edit":
if h.DbTx(rw, func(tx *database.Tx) error {
sub := req.Form.Get("subject")
return tx.UpdateUser(sub, newRoles, active)
}) {
return
}
default:
http.Error(rw, "400 Bad Request: Invalid action", http.StatusBadRequest)
return
}
redirectUrl := url.URL{Path: "/manage/users", RawQuery: url.Values{"offset": []string{offset}}.Encode()}
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
}

151
server/oauth.go Normal file
View File

@ -0,0 +1,151 @@
package server
import (
"github.com/1f349/lavender/pages"
"github.com/1f349/lavender/scope"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
)
func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
// function is only called with GET or POST method
isPost := req.Method == http.MethodPost
var form url.Values
if isPost {
err := req.ParseForm()
if err != nil {
http.Error(rw, "Failed to parse form", http.StatusInternalServerError)
return
}
form = req.PostForm
} else {
form = req.URL.Query()
}
clientID := form.Get("client_id")
client, err := h.oauthMgr.GetClient(req.Context(), clientID)
if err != nil {
http.Error(rw, "Invalid client", http.StatusBadRequest)
return
}
redirectUri := form.Get("redirect_uri")
if redirectUri != client.GetDomain() {
http.Error(rw, "Incorrect redirect URI", http.StatusBadRequest)
return
}
if form.Has("cancel") {
uCancel, err := url.Parse(client.GetDomain())
if err != nil {
http.Error(rw, "Invalid redirect URI", http.StatusBadRequest)
return
}
q := uCancel.Query()
q.Set("error", "access_denied")
uCancel.RawQuery = q.Encode()
http.Redirect(rw, req, uCancel.String(), http.StatusFound)
return
}
var isSSO bool
if clientIsSSO, ok := client.(interface{ IsSSO() bool }); ok {
isSSO = clientIsSSO.IsSSO()
}
switch {
case isSSO && isPost:
http.Error(rw, "400 Bad Request: Not sure how you even managed to send a POST request for an SSO application", http.StatusBadRequest)
return
case !isSSO && !isPost:
// find application redirect domain and name
appUrlFull, err := url.Parse(client.GetDomain())
if err != nil {
http.Error(rw, "500 Internal Server Error: Failed to parse application redirect URL", http.StatusInternalServerError)
return
}
appDomain := appUrlFull.Scheme + "://" + appUrlFull.Host
appName := appUrlFull.Host
if clientGetName, ok := client.(interface{ GetName() string }); ok {
n := clientGetName.GetName()
if n != "" {
appName = n
}
}
scopeList := form.Get("scope")
if !scope.ScopesExist(scopeList) {
http.Error(rw, "Invalid scopes", http.StatusBadRequest)
return
}
rw.WriteHeader(http.StatusOK)
pages.RenderPageTemplate(rw, "oauth-authorize", map[string]any{
"ServiceName": h.conf.ServiceName,
"AppName": appName,
"AppDomain": appDomain,
"DisplayName": auth.Data.DisplayName,
"WantsList": scope.FancyScopeList(scopeList),
"ResponseType": form.Get("response_type"),
"ResponseMode": form.Get("response_mode"),
"ClientID": form.Get("client_id"),
"RedirectUri": form.Get("redirect_uri"),
"State": form.Get("state"),
"Scope": scopeList,
"Nonce": form.Get("nonce"),
})
return
}
// redirect with an error if the action is not authorize
if form.Get("oauth_action") == "authorize" || isSSO {
if err := h.oauthSrv.HandleAuthorizeRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
}
return
}
parsedRedirect, err := url.Parse(redirectUri)
if err != nil {
http.Error(rw, "400 Bad Request: Invalid redirect URI", http.StatusBadRequest)
return
}
q := parsedRedirect.Query()
q.Set("error", "user_cancelled")
parsedRedirect.RawQuery = q.Encode()
http.Redirect(rw, req, parsedRedirect.String(), http.StatusFound)
}
func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Request) (string, error) {
err := req.ParseForm()
if err != nil {
return "", err
}
auth, err := internalAuthenticationHandler(rw, req)
if err != nil {
return "", err
}
if auth.IsGuest() {
// handle redirecting to oauth
var q url.Values
switch req.Method {
case http.MethodPost:
q = req.PostForm
case http.MethodGet:
q = req.URL.Query()
default:
http.Error(rw, "405 Method Not Allowed", http.StatusMethodNotAllowed)
return "", err
}
redirectUrl := PrepareRedirectUrl("/login", &url.URL{Path: "/authorize", RawQuery: q.Encode()})
http.Redirect(rw, req, redirectUrl.String(), http.StatusFound)
return "", nil
}
return auth.Data.ID, nil
}

View File

@ -1,33 +0,0 @@
package server
// UserConfig is the structure for storing a user's role and owned domains
type UserConfig map[string]struct {
Roles []string `json:"roles"`
Domains []string `json:"domains"`
}
func (u UserConfig) AllRoles(user string) []string {
return u[user].Roles
}
func (u UserConfig) HasRole(user, role string) bool {
for _, i := range u[user].Roles {
if i == role {
return true
}
}
return false
}
func (u UserConfig) AllDomains(user string) []string {
return u[user].Domains
}
func (u UserConfig) OwnsDomain(user, domain string) bool {
for _, i := range u[user].Domains {
if i == domain {
return true
}
}
return false
}

View File

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{.ServiceName}}</title>
<link rel="stylesheet" href="/theme/style.css">
<script>
let loginData = {
target:{{.TargetOrigin}},
tokens: {
access:{{.AccessToken}},
refresh:{{.RefreshToken}},
},
userinfo:{{.TargetMessage}},
};
window.addEventListener("load", function () {
window.opener.postMessage(loginData, loginData.target);
setTimeout(function () {
window.close();
}, 500);
});
</script>
</head>
<body>
<header>
<h1>{{.ServiceName}}</h1>
</header>
<main id="mainBody">Loading...</main>
</body>
</html>

View File

@ -1,183 +0,0 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/1f349/mjwt/auth"
"github.com/1f349/mjwt/claims"
"github.com/golang-jwt/jwt/v4"
"github.com/julienschmidt/httprouter"
"golang.org/x/oauth2"
"net/http"
"net/mail"
"strings"
"time"
)
func (h *HttpServer) refreshHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
ref := strings.TrimSuffix(req.Referer(), "/")
allowedClient, ok := (*h.services.Load())[ref]
if !ok {
http.Error(rw, "Invalid origin", http.StatusBadRequest)
return
}
loginNameCookie, err := req.Cookie("lavender-login-name")
if err != nil {
http.Error(rw, "Failed to read cookie", http.StatusBadRequest)
return
}
loginService := h.manager.Load().FindServiceFromLogin(loginNameCookie.Value)
cookie, err := req.Cookie("sso-exchange")
if err != nil {
http.Error(rw, "Failed to read cookie", http.StatusBadRequest)
return
}
rawEncrypt, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
http.Error(rw, "Internal Server Error", http.StatusBadRequest)
return
}
rawTokens, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signer.PrivateKey(), rawEncrypt, []byte("sso-exchange"))
if err != nil {
http.Error(rw, "Internal Server Error", http.StatusBadRequest)
return
}
var exchange oauth2.Token
err = json.Unmarshal(rawTokens, &exchange)
if err != nil {
http.Error(rw, "Internal Server Error", http.StatusBadRequest)
return
}
h.finishTokenGenerateFlow(rw, req, flowStateData{
sso: loginService,
target: allowedClient,
}, &exchange, func(accessToken string, refreshToken string, v3 map[string]any) {
tokens := map[string]any{
"target": allowedClient.Url.String(),
"userinfo": v3,
"tokens": map[string]any{
"access": accessToken,
"refresh": refreshToken,
},
}
_ = json.NewEncoder(rw).Encode(tokens)
})
}
func (h *HttpServer) finishTokenGenerateFlow(rw http.ResponseWriter, req *http.Request, v flowStateData, exchange *oauth2.Token, response func(accessToken string, refreshToken string, v3 map[string]any)) {
// fetch user info
v2, err := testOa2UserInfo(v.sso, req.Context(), exchange)
if err != nil {
fmt.Println("Failed to get userinfo:", err)
http.Error(rw, "Failed to get userinfo", http.StatusInternalServerError)
return
}
defer v2.Body.Close()
if v2.StatusCode != http.StatusOK {
http.Error(rw, "Failed to get userinfo: unexpected status code", http.StatusInternalServerError)
return
}
// encrypt exchange tokens for cookie storage
marshal, err := json.Marshal(exchange)
if err != nil {
fmt.Println("Failed to marshal exchange tokens", err)
http.Error(rw, "Internal server error", http.StatusInternalServerError)
return
}
oaepBytes, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signer.PublicKey(), marshal, []byte("sso-exchange"))
if err != nil {
fmt.Println("Failed to encrypt exchange tokens", err)
http.Error(rw, "Internal server error", http.StatusInternalServerError)
return
}
http.SetCookie(rw, &http.Cookie{
Name: "sso-exchange",
Value: base64.RawURLEncoding.EncodeToString(oaepBytes),
Path: "/",
Expires: time.Now().AddDate(0, 3, 0),
Secure: true,
SameSite: http.SameSiteLaxMode,
})
var v3 map[string]any
if err = json.NewDecoder(v2.Body).Decode(&v3); err != nil {
fmt.Println("Failed to decode userinfo:", err)
http.Error(rw, "Failed to decode userinfo", http.StatusInternalServerError)
return
}
sub, ok := v3["sub"].(string)
if !ok {
http.Error(rw, "Invalid subject in userinfo", http.StatusInternalServerError)
return
}
aud, ok := v3["aud"].(string)
if !ok {
http.Error(rw, "Invalid audience in userinfo", http.StatusInternalServerError)
return
}
var needsMailFlag, needsDomains bool
ps := claims.NewPermStorage()
for _, i := range v.target.Permissions {
if strings.HasPrefix(i, "dynamic:") {
switch i {
case "dynamic:mail-inbox":
needsMailFlag = true
case "dynamic:domain-owns":
needsDomains = true
}
} else {
ps.Set(i)
}
}
if needsMailFlag {
if verified, ok := v3["email_verified"].(bool); ok && verified {
if mailAddress, ok := v3["email"].(string); ok {
address, err := mail.ParseAddress(mailAddress)
if err != nil {
http.Error(rw, "Invalid email in userinfo", http.StatusInternalServerError)
return
}
n := strings.IndexByte(address.Address, '@')
if n != -1 {
if address.Address[n+1:] == v.sso.Config.Namespace {
ps.Set("mail:inbox=" + address.Address)
}
}
}
}
}
if needsDomains {
a := h.conf.Load().Users.AllDomains(sub + "@" + v.sso.Config.Namespace)
for _, i := range a {
ps.Set("domain:owns=" + i)
}
}
nsSub := sub + "@" + v.sso.Config.Namespace
ati := uuidNewStringAti()
accessToken, err := h.signer.GenerateJwt(nsSub, ati, jwt.ClaimStrings{aud}, 15*time.Minute, auth.AccessTokenClaims{
Perms: ps,
})
if err != nil {
http.Error(rw, "Error generating access token", http.StatusInternalServerError)
return
}
refreshToken, err := h.signer.GenerateJwt(nsSub, uuidNewStringRti(), jwt.ClaimStrings{aud}, 15*time.Minute, auth.RefreshTokenClaims{AccessTokenId: ati})
if err != nil {
http.Error(rw, "Error generating refresh token", http.StatusInternalServerError)
return
}
response(accessToken, refreshToken, v3)
}

17
server/roles.go Normal file
View File

@ -0,0 +1,17 @@
package server
import (
"bufio"
"strings"
)
func HasRole(roles, test string) bool {
sc := bufio.NewScanner(strings.NewReader(roles))
sc.Split(bufio.ScanWords)
for sc.Scan() {
if sc.Text() == test {
return true
}
}
return false
}

12
server/roles_test.go Normal file
View File

@ -0,0 +1,12 @@
package server
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestHasRole(t *testing.T) {
assert.True(t, HasRole("lavender:admin test:something-else", "lavender:admin"))
assert.False(t, HasRole("lavender:admin,test:something-else", "lavender:admin"))
assert.False(t, HasRole("lavender: test:something-else", "lavender:admin"))
}

View File

@ -2,36 +2,48 @@ package server
import (
"bytes"
"crypto/subtle"
"encoding/json"
"fmt"
"github.com/1f349/cache"
clientStore "github.com/1f349/lavender/client-store"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/openid"
scope2 "github.com/1f349/lavender/scope"
"github.com/1f349/lavender/theme"
"github.com/1f349/mjwt"
"github.com/go-oauth2/oauth2/v4/errors"
"github.com/go-oauth2/oauth2/v4/generates"
"github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server"
"github.com/go-oauth2/oauth2/v4/store"
"github.com/julienschmidt/httprouter"
"github.com/rs/cors"
"log"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
)
var errInvalidScope = errors.New("missing required scope")
type HttpServer struct {
Server *http.Server
r *httprouter.Router
conf atomic.Pointer[Conf]
manager atomic.Pointer[issuer.Manager]
signer mjwt.Signer
flowState *cache.Cache[string, flowStateData]
services atomic.Pointer[map[string]AllowedClient]
r *httprouter.Router
oauthSrv *server.Server
oauthMgr *manage.Manager
db *database.DB
conf Conf
signingKey mjwt.Signer
manager *issuer.Manager
flowState *cache.Cache[string, flowStateData]
}
type flowStateData struct {
sso *issuer.WellKnownOIDC
target AllowedClient
sso *issuer.WellKnownOIDC
}
func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer {
func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server {
r := httprouter.New()
// remove last slash from baseUrl
@ -42,80 +54,186 @@ func NewHttpServer(conf Conf, signer mjwt.Signer) *HttpServer {
}
}
hs := &HttpServer{
Server: &http.Server{
Addr: conf.Listen,
Handler: r,
ReadTimeout: time.Minute,
ReadHeaderTimeout: time.Minute,
WriteTimeout: time.Minute,
IdleTimeout: time.Minute,
MaxHeaderBytes: 2500,
},
r: r,
signer: signer,
flowState: cache.New[string, flowStateData](),
}
err := hs.UpdateConfig(conf)
openIdConf := openid.GenConfig(conf.BaseUrl, []string{"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale"}, []string{"sub", "name", "preferred_username", "profile", "picture", "website", "email", "email_verified", "gender", "birthdate", "zoneinfo", "locale", "updated_at"})
openIdBytes, err := json.Marshal(openIdConf)
if err != nil {
log.Fatalln("Failed to load initial config:", err)
return nil
log.Fatalln("Failed to generate OpenID configuration:", err)
}
r.GET("/", func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
rw.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(rw, "What is this?")
})
r.GET("/popup", hs.flowPopup)
r.POST("/popup", hs.flowPopupPost)
r.GET("/callback", hs.flowCallback)
oauthManager := manage.NewDefaultManager()
oauthSrv := server.NewServer(server.NewConfig(), oauthManager)
hs := &HttpServer{
r: httprouter.New(),
oauthSrv: oauthSrv,
oauthMgr: oauthManager,
db: db,
conf: conf,
signingKey: signingKey,
flowState: cache.New[string, flowStateData](),
}
hs.manager, err = issuer.NewManager(conf.SsoServices)
if err != nil {
log.Fatal("Failed to reload SSO service manager: %w", err)
}
oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
oauthManager.MustTokenStorage(store.NewMemoryTokenStore())
oauthManager.MapAccessGenerate(generates.NewAccessGenerate())
oauthManager.MapClientStorage(clientStore.New(db))
oauthSrv.SetResponseErrorHandler(func(re *errors.Response) {
log.Printf("Response error: %#v\n", re)
})
oauthSrv.SetClientInfoHandler(func(req *http.Request) (clientID, clientSecret string, err error) {
cId, cSecret, err := server.ClientBasicHandler(req)
if cId == "" && cSecret == "" {
cId, cSecret, err = server.ClientFormHandler(req)
}
if err != nil {
return "", "", err
}
return cId, cSecret, nil
})
oauthSrv.SetUserAuthorizationHandler(hs.oauthUserAuthorization)
oauthSrv.SetAuthorizeScopeHandler(func(rw http.ResponseWriter, req *http.Request) (scope string, err error) {
var form url.Values
if req.Method == http.MethodPost {
form = req.PostForm
} else {
form = req.URL.Query()
}
a := form.Get("scope")
if !scope2.ScopesExist(a) {
return "", errInvalidScope
}
return a, nil
})
r.GET("/.well-known/openid-configuration", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(openIdBytes)
})
r.GET("/", hs.OptionalAuthentication(hs.Home))
// login
r.GET("/login", hs.loginGet)
r.POST("/login", hs.loginPost)
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
lNonce, ok := auth.Session.Get("action-nonce")
if !ok {
http.Error(rw, "Missing nonce", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(lNonce.(string)), []byte(req.PostFormValue("nonce"))) == 1 {
auth.Session.Delete("session-data")
if auth.Session.Save() != nil {
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
http.SetCookie(rw, &http.Cookie{
Name: "login-data",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(rw, req, "/", http.StatusFound)
return
}
http.Error(rw, "Logout failed", http.StatusInternalServerError)
}))
// theme styles
r.GET("/theme/style.css", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
http.ServeContent(rw, req, "style.css", time.Now(), bytes.NewReader(theme.DefaultThemeCss))
})
// setup CORS options for `/verify` and `/refresh` endpoints
var corsAccessControl = cors.New(cors.Options{
AllowOriginFunc: func(origin string) bool {
load := hs.services.Load()
_, ok := (*load)[strings.TrimSuffix(origin, "/")]
return ok
},
AllowedMethods: []string{http.MethodPost, http.MethodOptions},
AllowedHeaders: []string{"Content-Type"},
AllowCredentials: true,
// management pages
r.GET("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsGet))
r.POST("/manage/apps", hs.RequireAdminAuthentication(hs.ManageAppsPost))
r.GET("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersGet))
r.POST("/manage/users", hs.RequireAdminAuthentication(hs.ManageUsersPost))
// oauth pages
r.GET("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
r.POST("/authorize", hs.RequireAuthentication(hs.authorizeEndpoint))
r.POST("/token", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
if err := oauthSrv.HandleTokenRequest(rw, req); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
})
r.GET("/userinfo", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
token, err := oauthSrv.ValidationBearerToken(req)
if err != nil {
http.Error(rw, "403 Forbidden", http.StatusForbidden)
return
}
userId := token.GetUserID()
fmt.Printf("Using token for user: %s by app: %s with scope: '%s'\n", userId, token.GetClientID(), token.GetScope())
claims := ParseClaims(token.GetScope())
if !claims["openid"] {
http.Error(rw, "Invalid scope", http.StatusBadRequest)
return
}
m := map[string]any{}
m["sub"] = userId
m["aud"] = token.GetClientID()
m["updated_at"] = time.Now().Unix()
_ = json.NewEncoder(rw).Encode(m)
})
// `/verify` and `/refresh` need CORS headers to be usable on other domains
r.POST("/verify", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
corsAccessControl.ServeHTTP(rw, req, func(writer http.ResponseWriter, request *http.Request) {
hs.verifyHandler(rw, req, params)
})
})
r.POST("/refresh", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
corsAccessControl.ServeHTTP(rw, req, func(writer http.ResponseWriter, request *http.Request) {
hs.refreshHandler(rw, req, params)
})
})
r.OPTIONS("/refresh", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
corsAccessControl.ServeHTTP(rw, req, func(_ http.ResponseWriter, _ *http.Request) {})
})
return hs
return &http.Server{
Addr: conf.Listen,
Handler: r,
ReadTimeout: time.Minute,
ReadHeaderTimeout: time.Minute,
WriteTimeout: time.Minute,
IdleTimeout: time.Minute,
MaxHeaderBytes: 2500,
}
}
func (h *HttpServer) UpdateConfig(conf Conf) error {
m, err := issuer.NewManager(conf.SsoServices)
func (h *HttpServer) SafeRedirect(rw http.ResponseWriter, req *http.Request) {
redirectUrl := req.FormValue("redirect")
if redirectUrl == "" {
http.Redirect(rw, req, "/", http.StatusFound)
return
}
parse, err := url.Parse(redirectUrl)
if err != nil {
return fmt.Errorf("failed to reload SSO service manager: %w", err)
http.Error(rw, "Failed to parse redirect url: "+redirectUrl, http.StatusBadRequest)
return
}
clientLookup := make(map[string]AllowedClient)
for _, i := range conf.AllowedClients {
clientLookup[i.Url.String()] = i
if parse.Scheme != "" && parse.Opaque != "" && parse.User != nil && parse.Host != "" {
http.Error(rw, "Invalid redirect url: "+redirectUrl, http.StatusBadRequest)
return
}
h.conf.Store(&conf)
h.manager.Store(m)
h.services.Store(&clientLookup)
return nil
http.Redirect(rw, req, parse.String(), http.StatusFound)
}
func ParseClaims(claims string) map[string]bool {
m := make(map[string]bool)
for {
n := strings.IndexByte(claims, ' ')
if n == -1 {
if claims != "" {
m[claims] = true
}
break
}
a := claims[:n]
claims = claims[n+1:]
if a != "" {
m[a] = true
}
}
return m
}

View File

@ -1,33 +0,0 @@
package server
import (
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"github.com/1f349/violet/utils"
"github.com/julienschmidt/httprouter"
"net/http"
)
func (h *HttpServer) verifyHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
// find bearer token
bearer := utils.GetBearer(req)
if bearer == "" {
http.Error(rw, "Missing bearer", http.StatusForbidden)
return
}
// after this mjwt is considered valid
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signer, bearer)
if err != nil {
http.Error(rw, "Invalid token", http.StatusForbidden)
return
}
// check issuer against config
if b.Issuer != h.conf.Load().Issuer {
http.Error(rw, "Invalid issuer", http.StatusBadRequest)
return
}
rw.WriteHeader(http.StatusOK)
}

View File

@ -1,61 +0,0 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"github.com/1f349/mjwt/claims"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestVerifyHandler(t *testing.T) {
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
assert.NoError(t, err)
invalidSigner := mjwt.NewMJwtSigner("Invalid Issuer", privKey)
h := HttpServer{
signer: mjwt.NewMJwtSigner("Test Issuer", privKey),
}
h.conf.Store(&Conf{Issuer: "Test Issuer"})
// test for missing bearer response
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "https://example.localhost", nil)
h.verifyHandler(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, "Missing bearer\n", rec.Body.String())
// test for invalid token response
rec = httptest.NewRecorder()
req.Header.Set("Authorization", "Bearer abcd")
h.verifyHandler(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, "Invalid token\n", rec.Body.String())
// test for invalid issuer response
rec = httptest.NewRecorder()
accessToken, err := invalidSigner.GenerateJwt("a", "a", nil, 15*time.Minute, auth.AccessTokenClaims{
Perms: claims.NewPermStorage(),
})
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+accessToken)
h.verifyHandler(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "Invalid issuer\n", rec.Body.String())
// test for invalid issuer response
rec = httptest.NewRecorder()
accessToken, err = h.signer.GenerateJwt("a", "a", nil, 15*time.Minute, auth.AccessTokenClaims{
Perms: claims.NewPermStorage(),
})
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+accessToken)
h.verifyHandler(rec, req, httprouter.Params{})
assert.Equal(t, http.StatusOK, rec.Code)
}