Lindenii Project Forge
Login

server

Vireo IdP server

Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!

Commit info
ID
3118ac0a8e2a42fb54ae102fba58559125e2848b
Author
Author date
Tue, 20 Feb 2024 12:18:14 +0100
Committer
Committer date
Tue, 20 Feb 2024 12:18:14 +0100
Actions
Add server name config directive

References: https://todo.sr.ht/~emersion/sinwon/24
package main

import (
	"fmt"
	"net/http"
	"net/url"
	"strings"

	"github.com/go-chi/chi/v5"
)

func manageClient(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)
	tpl := templateFromContext(ctx)

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		http.Redirect(w, req, "/login", http.StatusFound)
		return
	}

	me, err := db.FetchUser(ctx, loginToken.User)
	if err != nil {
		httpError(w, err)
		return
	} else if !me.Admin {
		http.Error(w, "Access denied", http.StatusForbidden)
		return
	}

	client := &Client{Owner: loginToken.User}
	if idStr := chi.URLParam(req, "id"); idStr != "" {
		id, err := ParseID[*Client](idStr)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		client, err = db.FetchClient(ctx, id)
		if err != nil {
			httpError(w, err)
			return
		}
	}

	if req.Method != http.MethodPost {
		data := struct {
			TemplateBaseData
			Client *Client
		}{
			Client: client,
		}
		if err := tpl.ExecuteTemplate(w, "manage-client.html", &data); err != nil {
			panic(err)
		}
		tpl.MustExecuteTemplate(w, "manage-client.html", &data)
		return
	}

	_ = req.ParseForm()
	if _, ok := req.PostForm["delete"]; ok {
		if err := db.DeleteClient(ctx, client.ID); err != nil {
			httpError(w, err)
			return
		}
		http.Redirect(w, req, "/", http.StatusFound)
		return
	}

	_, rotate := req.PostForm["rotate"]

	var isPublic bool
	if client.ID != 0 {
		isPublic = client.IsPublic()
	} else {
		isPublic = req.PostFormValue("client_type") == "public"
	}

	if !rotate {
		client.ClientName = req.PostFormValue("client_name")
		client.ClientURI = req.PostFormValue("client_uri")
		client.RedirectURIs = req.PostFormValue("redirect_uris")

		if err := validateAllowedRedirectURIs(client.RedirectURIs); err != nil {
			// TODO: nicer error message
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
	}

	var clientSecret string
	if client.ID == 0 || rotate {
		clientSecret, err = client.Generate(isPublic)
		if err != nil {
			httpError(w, err)
			return
		}
	}

	if err := db.StoreClient(ctx, client); err != nil {
		httpError(w, err)
		return
	}

	if clientSecret == "" {
		http.Redirect(w, req, "/", http.StatusFound)
		return
	}

	data := struct {
		TemplateBaseData
		ClientID     string
		ClientSecret string
	}{
		ClientID:     client.ClientID,
		ClientSecret: clientSecret,
	}
	if err := tpl.ExecuteTemplate(w, "client-secret.html", &data); err != nil {
		panic(err)
	}
	tpl.MustExecuteTemplate(w, "client-secret.html", &data)
}

func validateAllowedRedirectURIs(rawRedirectURIs string) error {
	for _, s := range strings.Split(rawRedirectURIs, "\n") {
		if s == "" {
			continue
		}
		u, err := url.Parse(s)
		if err != nil {
			// TODO: nicer error message
			return fmt.Errorf("Invalid redirect URI %q: %v", s, err)
		}
		switch u.Scheme {
		case "https":
			// ok
		case "http":
			if u.Host != "localhost" {
				return fmt.Errorf("Only http://localhost is allowed for insecure HTTP URIs")
			}
		default:
			if !strings.Contains(u.Scheme, ".") {
				return fmt.Errorf("Only private-use URIs referring to domain names are allowed")
			}
		}
	}
	return nil
}

func revokeClient(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)

	id, err := ParseID[*Client](chi.URLParam(req, "id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		http.Redirect(w, req, "/login", http.StatusFound)
		return
	}

	if err := db.RevokeAccessTokens(ctx, id, loginToken.User); err != nil {
		httpError(w, err)
		return
	}

	http.Redirect(w, req, "/", http.StatusFound)
}
package main

import (
	"os"

	"git.sr.ht/~emersion/go-scfg"
)

type Config struct {
	Listen   string `scfg:"listen"`
	Database string `scfg:"database"`
	Listen     string `scfg:"listen"`
	Database   string `scfg:"database"`
	ServerName string `scfg:"server-name"`
}

func loadConfig(filename string) (*Config, error) {
	f, err := os.Open(filename)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	var config Config
	err = scfg.NewDecoder(f).Decode(&config)
	return &config, err
}
listen localhost:8080
database /var/lib/sinwon/main.db
server-name sinwon
package main

import (
	"context"
	"embed"
	"flag"
	"html/template"
	"log"
	"net"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
)

var (
	//go:embed template
	templateFS embed.FS
	//go:embed static
	staticFS embed.FS
)

func main() {
	var configFilename, listenAddr string
	flag.StringVar(&configFilename, "config", "/etc/sinwon/config", "Configuration filename")
	flag.StringVar(&listenAddr, "listen", "", "HTTP listen address")
	flag.Parse()

	cfg, err := loadConfig(configFilename)
	if err != nil {
		log.Fatalf("Failed to load config file: %v", err)
	}

	if listenAddr == "" {
		listenAddr = cfg.Listen
	}
	if listenAddr == "" {
		log.Fatalf("Missing listen configuration")
	}
	if cfg.Database == "" {
		log.Fatalf("Missing database configuration")
	}

	db, err := openDB(cfg.Database)
	if err != nil {
		log.Fatalf("Failed to open DB: %v", err)
	}

	tpl := template.Must(template.ParseFS(templateFS, "template/*.html"))
	tplBaseData := &TemplateBaseData{
		ServerName: cfg.ServerName,
	}
	if tplBaseData.ServerName == "" {
		tplBaseData.ServerName = "sinwon"
	}
	tpl, err := loadTemplate(templateFS, "template/*.html", tplBaseData)
	if err != nil {
		log.Fatalf("Failed to load template: %v", err)
	}

	mux := chi.NewRouter()
	mux.Handle("/static/*", http.FileServer(http.FS(staticFS)))
	mux.Get("/", index)
	mux.HandleFunc("/login", login)
	mux.Post("/logout", logout)
	mux.HandleFunc("/client/new", manageClient)
	mux.HandleFunc("/client/{id}", manageClient)
	mux.Post("/client/{id}/revoke", revokeClient)
	mux.HandleFunc("/user/new", manageUser)
	mux.HandleFunc("/user/{id}", manageUser)
	mux.Get("/.well-known/oauth-authorization-server", getOAuthServerMetadata)
	mux.HandleFunc("/authorize", authorize)
	mux.Post("/token", exchangeToken)
	mux.Post("/introspect", introspectToken)
	mux.Post("/revoke", revokeToken)

	go maintainDBLoop(db)

	server := http.Server{
		Addr:    listenAddr,
		Handler: loginTokenMiddleware(mux),
		BaseContext: func(net.Listener) context.Context {
			return newBaseContext(db, tpl)
		},
	}
	log.Printf("OAuth server listening on %v", server.Addr)
	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("Failed to listen and serve: %v", err)
	}
}

func httpError(w http.ResponseWriter, err error) {
	log.Print(err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
}

func maintainDBLoop(db *DB) {
	ticker := time.NewTicker(15 * time.Minute)
	defer ticker.Stop()

	for range ticker.C {
		ctx := context.Background()
		ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
		if err := db.Maintain(ctx); err != nil {
			log.Printf("Failed to perform database maintenance: %v", err)
		}
		cancel()
	}
}
package main

import (
	"context"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"mime"
	"net/http"
)

const (
	loginCookieName    = "sinwon-login"
	internalTokenScope = "_sinwon"
)

type contextKey string

const (
	contextKeyDB         = "db"
	contextKeyTemplate   = "template"
	contextKeyLoginToken = "login-token"
)

func dbFromContext(ctx context.Context) *DB {
	return ctx.Value(contextKeyDB).(*DB)
}

func templateFromContext(ctx context.Context) *template.Template {
	return ctx.Value(contextKeyTemplate).(*template.Template)
func templateFromContext(ctx context.Context) *Template {
	return ctx.Value(contextKeyTemplate).(*Template)
}

func loginTokenFromContext(ctx context.Context) *AccessToken {
	v := ctx.Value(contextKeyLoginToken)
	if v == nil {
		return nil
	}
	return v.(*AccessToken)
}

func newBaseContext(db *DB, tpl *template.Template) context.Context {
func newBaseContext(db *DB, tpl *Template) context.Context {
	ctx := context.Background()
	ctx = context.WithValue(ctx, contextKeyDB, db)
	ctx = context.WithValue(ctx, contextKeyTemplate, tpl)
	return ctx
}

func setLoginTokenCookie(w http.ResponseWriter, req *http.Request, token *AccessToken, secret string) {
	http.SetCookie(w, &http.Cookie{
		Name:     loginCookieName,
		Value:    MarshalSecret(token.ID, secret),
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
		Secure:   isForwardedHTTPS(req),
	})
}

func unsetLoginTokenCookie(w http.ResponseWriter, req *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name:     loginCookieName,
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
		Secure:   isForwardedHTTPS(req),
		MaxAge:   -1,
	})
}

func isForwardedHTTPS(req *http.Request) bool {
	if forwarded := req.Header.Get("Forwarded"); forwarded != "" {
		_, params, _ := mime.ParseMediaType("_; " + forwarded)
		return params["proto"] == "https"
	}
	if forwardedProto := req.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
		return forwardedProto == "https"
	}
	return false
}

func loginTokenMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		cookie, _ := req.Cookie(loginCookieName)
		if cookie == nil {
			next.ServeHTTP(w, req)
			return
		}

		ctx := req.Context()
		db := dbFromContext(ctx)
		tokenID, tokenSecret, _ := UnmarshalSecret[*AccessToken](cookie.Value)
		token, err := db.FetchAccessToken(ctx, tokenID)
		if err == errNoDBRows || (err == nil && !token.VerifySecret(tokenSecret)) {
			unsetLoginTokenCookie(w, req)
			next.ServeHTTP(w, req)
			return
		} else if err != nil {
			httpError(w, fmt.Errorf("failed to fetch access token: %v", err))
			return
		}

		if token.Scope != internalTokenScope {
			http.Error(w, "Invalid login token scope", http.StatusForbidden)
			return
		}
		if token.User == 0 {
			panic("login token with zero user ID")
		}

		ctx = context.WithValue(ctx, contextKeyLoginToken, token)
		req = req.WithContext(ctx)
		next.ServeHTTP(w, req)
	})
}

type TemplateBaseData struct {
	ServerName string
}

func (data *TemplateBaseData) Base() *TemplateBaseData {
	return data
}

type TemplateData interface {
	Base() *TemplateBaseData
}

type Template struct {
	tpl      *template.Template
	baseData *TemplateBaseData
}

func loadTemplate(fs fs.FS, pattern string, baseData *TemplateBaseData) (*Template, error) {
	tpl, err := template.ParseFS(fs, pattern)
	if err != nil {
		return nil, err
	}
	return &Template{tpl: tpl, baseData: baseData}, nil
}

func (tpl *Template) MustExecuteTemplate(w io.Writer, filename string, data TemplateData) {
	if data == nil {
		data = tpl.baseData
	} else {
		*data.Base() = *tpl.baseData
	}
	if err := tpl.tpl.ExecuteTemplate(w, filename, data); err != nil {
		panic(err)
	}
}
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"mime"
	"net"
	"net/http"
	"net/url"
	"strings"
	"time"

	"git.sr.ht/~emersion/go-oauth2"
)

func getOAuthServerMetadata(w http.ResponseWriter, req *http.Request) {
	issuer := getIssuer(req)

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(&oauth2.ServerMetadata{
		Issuer:                                     issuer,
		AuthorizationEndpoint:                      issuer + "/authorize",
		TokenEndpoint:                              issuer + "/token",
		IntrospectionEndpoint:                      issuer + "/introspect",
		RevocationEndpoint:                         issuer + "/revoke",
		ResponseTypesSupported:                     []oauth2.ResponseType{oauth2.ResponseTypeCode},
		ResponseModesSupported:                     []oauth2.ResponseMode{oauth2.ResponseModeQuery},
		GrantTypesSupported:                        []oauth2.GrantType{oauth2.GrantTypeAuthorizationCode},
		TokenEndpointAuthMethodsSupported:          []oauth2.AuthMethod{oauth2.AuthMethodNone, oauth2.AuthMethodClientSecretBasic},
		IntrospectionEndpointAuthMethodsSupported:  []oauth2.AuthMethod{oauth2.AuthMethodNone, oauth2.AuthMethodClientSecretBasic},
		RevocationEndpointAuthMethodsSupported:     []oauth2.AuthMethod{oauth2.AuthMethodNone, oauth2.AuthMethodClientSecretBasic},
		AuthorizationResponseIssParameterSupported: true,
	})
}

func getIssuer(req *http.Request) string {
	issuerURL := url.URL{
		Scheme: "https",
		Host:   req.Host,
	}
	if !isForwardedHTTPS(req) && isLoopback(req) {
		// TODO: add config option for allowed reverse proxy IPs
		issuerURL.Scheme = "http"
	}
	return issuerURL.String()
}

func isLoopback(req *http.Request) bool {
	host, _, _ := net.SplitHostPort(req.RemoteAddr)
	ip := net.ParseIP(host)
	return ip.IsLoopback()
}

func authorize(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)
	tpl := templateFromContext(ctx)

	q := req.URL.Query()
	respType := oauth2.ResponseType(q.Get("response_type"))
	clientID := q.Get("client_id")
	rawRedirectURI := q.Get("redirect_uri")
	scope := q.Get("scope")
	state := q.Get("state")

	if clientID == "" {
		http.Error(w, "Missing client ID", http.StatusBadRequest)
		return
	}

	client, err := db.FetchClientByClientID(ctx, clientID)
	if err == errNoDBRows {
		http.Error(w, "Invalid client ID", http.StatusForbidden)
		return
	} else if err != nil {
		httpError(w, fmt.Errorf("failed to fetch client: %v", err))
		return
	}

	var allowedRedirectURIs []*url.URL
	for _, s := range strings.Split(client.RedirectURIs, "\n") {
		if s == "" {
			continue
		}
		u, err := url.Parse(s)
		if err != nil {
			httpError(w, fmt.Errorf("failed to parse client redirect URI"))
			return
		}
		allowedRedirectURIs = append(allowedRedirectURIs, u)
	}

	var redirectURI *url.URL
	if rawRedirectURI != "" {
		redirectURI, err = url.Parse(rawRedirectURI)
		if err != nil {
			http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
			return
		}
		if !validateRedirectURI(redirectURI, allowedRedirectURIs) {
			http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
			return
		}
	} else {
		if len(allowedRedirectURIs) == 0 {
			http.Error(w, "Missing redirect URI", http.StatusBadRequest)
			return
		}
		redirectURI = allowedRedirectURIs[0]
	}

	if respType != oauth2.ResponseTypeCode {
		redirectClientError(w, req, redirectURI, state, &oauth2.Error{
			Code: oauth2.ErrorCodeUnsupportedResponseType,
		})
		return
	}

	// TODO: add support for scope
	if scope != "" {
		redirectClientError(w, req, redirectURI, state, &oauth2.Error{
			Code: oauth2.ErrorCodeInvalidScope,
		})
		return
	}

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		q := make(url.Values)
		q.Set("redirect_uri", req.URL.String())
		u := url.URL{
			Path:     "/login",
			RawQuery: q.Encode(),
		}
		http.Redirect(w, req, u.String(), http.StatusFound)
		return
	}

	_ = req.ParseForm()
	if _, ok := req.PostForm["deny"]; ok {
		redirectClientError(w, req, redirectURI, state, &oauth2.Error{
			Code: oauth2.ErrorCodeAccessDenied,
		})
		return
	}
	if _, ok := req.PostForm["authorize"]; !ok {
		data := struct {
			TemplateBaseData
			Client *Client
		}{
			Client: client,
		}
		if err := tpl.ExecuteTemplate(w, "authorize.html", data); err != nil {
			panic(err)
		}
		tpl.MustExecuteTemplate(w, "authorize.html", &data)
		return
	}

	authCode := AuthCode{
		User:        loginToken.User,
		Client:      client.ID,
		Scope:       scope,
		RedirectURI: rawRedirectURI,
	}
	secret, err := authCode.Generate()
	if err != nil {
		httpError(w, fmt.Errorf("failed to generate authentication code: %v", err))
		return
	}

	if err := db.CreateAuthCode(ctx, &authCode); err != nil {
		httpError(w, fmt.Errorf("failed to create authentication code: %v", err))
		return
	}

	code := MarshalSecret(authCode.ID, secret)

	values := make(url.Values)
	values.Set("code", code)
	if state != "" {
		values.Set("state", state)
	}
	redirectClient(w, req, redirectURI, values)
}

func exchangeToken(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)

	values, err := parseRequestBody(req)
	if err != nil {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidRequest,
			Description: err.Error(),
		})
		return
	}

	clientID := values.Get("client_id")
	grantType := oauth2.GrantType(values.Get("grant_type"))
	scope := values.Get("scope")
	redirectURI := values.Get("redirect_uri")

	authClientID, clientSecret, _ := req.BasicAuth()
	if clientID == "" && authClientID == "" {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidRequest,
			Description: "Missing client ID",
		})
		return
	} else if clientID == "" {
		clientID = authClientID
	} else if clientID != authClientID {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidRequest,
			Description: "Client ID in request body doesn't match Authorization header field",
		})
		return
	}

	client, err := db.FetchClientByClientID(ctx, clientID)
	if err == errNoDBRows {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidClient,
			Description: "Invalid client ID",
		})
		return
	} else if err != nil {
		oauthError(w, fmt.Errorf("failed to fetch client: %v", err))
		return
	}

	if !client.IsPublic() {
		if !client.VerifySecret(clientSecret) {
			oauthError(w, &oauth2.Error{
				Code:        oauth2.ErrorCodeAccessDenied,
				Description: "Invalid client secret",
			})
			return
		}
	}

	if grantType != oauth2.GrantTypeAuthorizationCode {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeUnsupportedGrantType,
			Description: "Unsupported grant type",
		})
		return
	}

	codeID, codeSecret, _ := UnmarshalSecret[*AuthCode](values.Get("code"))
	authCode, err := db.PopAuthCode(ctx, codeID)
	if err == errNoDBRows || (err == nil && !authCode.VerifySecret(codeSecret)) || authCode.Client != client.ID {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeAccessDenied,
			Description: "Invalid authorization code",
		})
		return
	} else if err != nil {
		oauthError(w, fmt.Errorf("failed to fetch authorization code: %v", err))
		return
	}

	if scope != authCode.Scope {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeAccessDenied,
			Description: "Invalid scope",
		})
		return
	}
	if redirectURI != authCode.RedirectURI {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeAccessDenied,
			Description: "Invalid redirect URI",
		})
		return
	}

	token, secret, err := NewAccessTokenFromAuthCode(authCode)
	if err != nil {
		oauthError(w, err)
		return
	}

	if err := db.CreateAccessToken(ctx, token); err != nil {
		oauthError(w, fmt.Errorf("failed to create access token: %v", err))
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	json.NewEncoder(w).Encode(&oauth2.TokenResp{
		AccessToken: MarshalSecret(token.ID, secret),
		TokenType:   oauth2.TokenTypeBearer,
		ExpiresIn:   time.Until(token.ExpiresAt),
		Scope:       strings.Split(token.Scope, " "),
	})
}

func introspectToken(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)

	values, err := parseRequestBody(req)
	if err != nil {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidRequest,
			Description: err.Error(),
		})
		return
	}

	client, err := maybeAuthenticateClient(w, req)
	if err != nil {
		oauthError(w, err)
		return
	}

	tokenID, secret, _ := UnmarshalSecret[*AccessToken](values.Get("token"))
	token, err := db.FetchAccessToken(ctx, tokenID)
	if err == errNoDBRows || (err == nil && !token.VerifySecret(secret)) {
		token = nil
	} else if err != nil {
		oauthError(w, fmt.Errorf("failed to fetch access token: %v", err))
		return
	}

	var resp oauth2.IntrospectionResp
	if token != nil {
		if client == nil {
			client, err = db.FetchClient(ctx, token.Client)
			if err != nil {
				oauthError(w, fmt.Errorf("failed to fetch client: %v", err))
				return
			}

			if !client.IsPublic() {
				oauthError(w, &oauth2.Error{
					Code:        oauth2.ErrorCodeInvalidClient,
					Description: "Missing client ID and secret",
				})
				return
			}
		}

		if client.ID != token.Client {
			oauthError(w, &oauth2.Error{
				Code:        oauth2.ErrorCodeInvalidClient,
				Description: "Invalid client ID or secret",
			})
			return
		}

		user, err := db.FetchUser(ctx, token.User)
		if err != nil {
			oauthError(w, fmt.Errorf("failed to fetch user: %v", err))
			return
		}

		resp.Active = true
		resp.TokenType = oauth2.TokenTypeBearer
		resp.ExpiresAt = token.ExpiresAt
		resp.IssuedAt = token.IssuedAt
		resp.ClientID = client.ClientID
		resp.Username = user.Username
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(&resp)
}

func revokeToken(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)

	values, err := parseRequestBody(req)
	if err != nil {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidRequest,
			Description: err.Error(),
		})
		return
	}

	client, err := maybeAuthenticateClient(w, req)
	if err != nil {
		oauthError(w, err)
		return
	}

	tokenID, secret, _ := UnmarshalSecret[*AccessToken](values.Get("token"))
	token, err := db.FetchAccessToken(ctx, tokenID)
	if err == errNoDBRows || (err == nil && !token.VerifySecret(secret)) {
		return // ignore
	} else if err != nil {
		oauthError(w, fmt.Errorf("failed to fetch access token: %v", err))
		return
	}

	if client == nil {
		client, err = db.FetchClient(ctx, token.Client)
		if err != nil {
			oauthError(w, fmt.Errorf("failed to fetch client: %v", err))
			return
		}

		if !client.IsPublic() {
			oauthError(w, &oauth2.Error{
				Code:        oauth2.ErrorCodeInvalidClient,
				Description: "Missing client ID and secret",
			})
			return
		}
	}

	if client.ID != token.Client {
		oauthError(w, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidClient,
			Description: "Invalid client ID or secret",
		})
		return
	}

	if err := db.DeleteAccessToken(ctx, token.ID); err != nil {
		oauthError(w, err)
		return
	}
}

func parseRequestBody(req *http.Request) (url.Values, error) {
	ct := req.Header.Get("Content-Type")
	if ct != "" {
		mimeType, _, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
		if err != nil {
			return nil, fmt.Errorf("malformed Content-Type header field")
		} else if mimeType != "application/x-www-form-urlencoded" {
			return nil, fmt.Errorf("unsupported request content type")
		}
	}

	r := io.LimitReader(req.Body, 10<<20)
	b, err := io.ReadAll(r)
	if err != nil {
		return nil, fmt.Errorf("failed to read request body: %v", err)
	}

	values, err := url.ParseQuery(string(b))
	if err != nil {
		return nil, fmt.Errorf("failed to parse request body: %v", err)
	}

	return values, nil
}

func oauthError(w http.ResponseWriter, err error) {
	var oauthErr *oauth2.Error
	if !errors.As(err, &oauthErr) {
		oauthErr = &oauth2.Error{Code: oauth2.ErrorCodeServerError}
		log.Print(err)
	}

	statusCode := http.StatusInternalServerError
	switch oauthErr.Code {
	case oauth2.ErrorCodeInvalidRequest, oauth2.ErrorCodeUnsupportedResponseType, oauth2.ErrorCodeInvalidScope, oauth2.ErrorCodeInvalidClient, oauth2.ErrorCodeInvalidGrant, oauth2.ErrorCodeUnsupportedGrantType:
		statusCode = http.StatusBadRequest
	case oauth2.ErrorCodeUnauthorizedClient, oauth2.ErrorCodeAccessDenied:
		statusCode = http.StatusForbidden
	case oauth2.ErrorCodeTemporarilyUnavailable:
		statusCode = http.StatusServiceUnavailable
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(statusCode)
	json.NewEncoder(w).Encode(oauthErr)
}

func redirectClient(w http.ResponseWriter, req *http.Request, redirectURI *url.URL, values url.Values) {
	q := redirectURI.Query()
	for k, v := range values {
		q[k] = v
	}
	q.Set("iss", getIssuer(req))

	u := *redirectURI
	u.RawQuery = q.Encode()

	http.Redirect(w, req, u.String(), http.StatusFound)
}

func redirectClientError(w http.ResponseWriter, req *http.Request, redirectURI *url.URL, state string, err error) {
	var oauthErr *oauth2.Error
	if !errors.As(err, &oauthErr) {
		oauthErr = &oauth2.Error{Code: oauth2.ErrorCodeServerError}
		log.Print(err)
	}

	values := make(url.Values)
	values.Set("error", string(oauthErr.Code))
	if oauthErr.Description != "" {
		values.Set("error_description", oauthErr.Description)
	}
	if oauthErr.URI != "" {
		values.Set("error_uri", oauthErr.URI)
	}
	if state != "" {
		values.Set("state", state)
	}
	redirectClient(w, req, redirectURI, values)
}

func validateRedirectURI(u *url.URL, allowedURIs []*url.URL) bool {
	// Loopback interface, see RFC 8252 section 7.3
	host, _, _ := net.SplitHostPort(u.Host)
	ip := net.ParseIP(host)
	if u.Scheme == "http" && ip.IsLoopback() {
		uu := *u
		uu.Host = "localhost"
		u = &uu
	}

	for _, allowed := range allowedURIs {
		if u.String() == allowed.String() {
			return true
		}
	}

	return false
}

func maybeAuthenticateClient(w http.ResponseWriter, req *http.Request) (*Client, error) {
	ctx := req.Context()
	db := dbFromContext(ctx)

	clientID, clientSecret, ok := req.BasicAuth()
	if !ok {
		return nil, nil
	}

	client, err := db.FetchClientByClientID(ctx, clientID)
	if err == errNoDBRows || (err == nil && !client.VerifySecret(clientSecret)) {
		return nil, &oauth2.Error{
			Code:        oauth2.ErrorCodeInvalidClient,
			Description: "Invalid client ID or secret",
		}
	} else if err != nil {
		return nil, fmt.Errorf("failed to fetch client: %v", err)
	}

	return client, nil
}
{{ template "head.html" }}
{{ template "head.html" .Base }}

<main>

<h1>sinwon</h1>
<h1>{{ .ServerName }}</h1>

<p>
	Authorize
	{{ if .Client.ClientURI }}
		<a href="{{ .Client.ClientURI }}" target="_blank">
	{{ end }}
	{{- if .Client.ClientName -}}
		{{- .Client.ClientName -}}
	{{- else -}}
		<code>{{- .Client.ClientID -}}</code>
	{{- end -}}
	{{- if .Client.ClientURI -}}
		</a>
	{{- end -}}
	?
</p>

<form method="post" action="">
	<button type="submit" name="authorize">Authorize</button>
	<button type="submit" name="deny">Cancel</button>
</form>

</main>

{{ template "foot.html" }}
{{ template "head.html" }}
{{ template "head.html" .Base }}

<main>

<h1>sinwon</h1>
<h1>{{ .ServerName }}</h1>

<p>
	<strong>Client ID</strong>: <code>{{ .ClientID }}</code><br>
	<strong>Client secret</strong>: <code>{{.ClientSecret}}</code>
</p>

<a href="/">Back</a>

</main>

{{ template "foot.html" }}
<!DOCTYPE HTML>
<html lang="en">
<head>
    <meta charset="utf-8"/>
    <title>sinwon</title>
	<title>{{ .ServerName }}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link rel="stylesheet" href="/static/style.css"/>
</head>
<body>
{{ template "head.html" }}
{{ template "head.html" .Base }}

<main>

<h1>sinwon</h1>
<h1>{{ .ServerName }}</h1>

<p>Welcome, {{ .Me.Username }}!</p>

<form method="post">
	<a href="/user/{{ .Me.ID }}"><button type="button">Settings</button></a>
	<button type="submit" formaction="/logout">Logout</button>
</form>

<h2>Authorized clients</h2>

{{ with .AuthorizedClients }}
	<table>
		<tr>
			<th>Client</th>
			<th>Authorized until</th>
			<th></th>
		</tr>
		{{ range . }}
			<tr>
				<td>
					{{ with .Client }}
						{{ if .ClientURI }}
							<a href="{{ .ClientURI }}" target="_blank">
						{{ end }}
						{{ if .ClientName }}
							{{ .ClientName }}
						{{ else }}
							<code>{{ .ClientID }}</code>
						{{ end }}
						{{ if .ClientURI }}
							</a>
						{{ end }}
					{{ end }}
				</td>
				<td>{{ .ExpiresAt }}</td>
				<td>
					<form method="post" action="/client/{{ .Client.ID }}/revoke">
						<button type="submit">Revoke</button>
					</form>
				</td>
			</tr>
		{{ end }}
	</table>
{{ else }}
	<p>No client authorized yet.</p>
{{ end }}

{{ if .Me.Admin }}
	<h2>Registered clients</h2>

	{{ with .Clients }}
		<p>
			<a href="/client/new"><button type="button">Register new client</button></a>
		</p>

		<table>
			<tr>
				<th>Client ID</th>
				<th>Name</th>
			</tr>
			{{ range . }}
				<tr>
					<td><a href="/client/{{ .ID }}"><code>{{ .ClientID }}</code></a></td>
					<td>{{ .ClientName }}</td>
				</tr>
			{{ end }}
		</table>
	{{ else }}
		<p>No client registered yet.</p>
	{{ end }}

	<h2>Users</h2>

	<p>
		<a href="/user/new"><button type="button">Create user</button></a>
	</p>

	<table>
		<tr>
			<th>Username</th>
			<th>Role</th>
		</tr>
		{{ range .Users }}
			<tr>
				<td><a href="/user/{{ .ID }}">{{ .Username }}</a></td>
				<td>
					{{ if .Admin }}
						Administrator
					{{ else }}
						Regular user
					{{ end}}
				</td>
			</tr>
		{{ end }}
	</table>
{{ end }}

</main>

{{ template "foot.html" }}
{{ template "head.html" }}
{{ template "head.html" .Base }}

<main>

<h1>sinwon</h1>
<h1>{{ .ServerName }}</h1>

<form method="post" action="">
	Username: <input type="text" name="username" autocomplete="username"><br>
	Password: <input type="password" name="password"><br>
	<button type="submit">Login</button>
</form>

</main>

{{ template "foot.html" }}
{{ template "head.html" }}
{{ template "head.html" .Base }}

<main>

<h1>sinwon</h1>
<h1>{{ .ServerName }}</h1>

<form method="post" action="">
	{{ if .Client.ClientID }}
		Client ID: <code>{{ .Client.ClientID }}</code><br>
	{{ end }}
	Name: <input type="text" name="client_name" value="{{ .Client.ClientName }}"><br>
	Website: <input type="url" name="client_uri" value="{{ .Client.ClientURI }}"><br>

	Client type:
	{{ if .Client.ID }}
		{{ if .Client.IsPublic }}
			public
		{{ else }}
			confidential
		{{ end }}
		<br>
	{{ else }}
		<br>
		<label>
			<input type="radio" name="client_type" value="confidential" checked>
			Confidential
		</label>
		<br>
		<label>
			<input type="radio" name="client_type" value="public">
			Public
		</label>
		<br>
	{{ end }}

	Redirect URIs:<br>
	<textarea name="redirect_uris">{{ .Client.RedirectURIs }}</textarea><br>
	<small>The special URI <code>http://localhost</code> matches all loopback interfaces.</small><br>

	<button type="submit">
		{{ if .Client.ID }}
			Update client
		{{ else }}
			Create client
		{{ end }}
	</button>
	{{ if .Client.ID }}
		{{ if not .Client.IsPublic }}
			<button type="submit" name="rotate">Rotate client secret</button>
		{{ end }}
		<button type="submit" name="delete">Delete client</button>
	{{ end }}
	<a href="/"><button type="button">Cancel</button></a>
</form>

</main>

{{ template "foot.html" }}
{{ template "head.html" }}
{{ template "head.html" .Base }}

<main>

<h1>sinwon</h1>
<h1>{{ .ServerName }}</h1>

<form method="post" action="">
	Username: <input type="text" name="username" value="{{ .User.Username }}" required><br>
	Password: <input type="password" name="password"><br>
	{{ if not (eq .Me.ID .User.ID) }}
		<label>
			<input type="checkbox" name="admin" {{ if .User.Admin }}checked{{ end }}>
			Administrator
		</label><br>
	{{ end }}

	<button type="submit">
		{{ if .User.ID }}
			Update user
		{{ else }}
			Create user
		{{ end }}
	</button>
	<a href="/"><button type="button">Cancel</button></a>
</form>

</main>

{{ template "foot.html" }}
package main

import (
	"fmt"
	"log"
	"net/http"
	"net/url"
	"time"

	"github.com/go-chi/chi/v5"
)

func index(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)
	tpl := templateFromContext(ctx)

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		http.Redirect(w, req, "/login", http.StatusFound)
		return
	}

	me, err := db.FetchUser(ctx, loginToken.User)
	if err != nil {
		httpError(w, err)
		return
	}

	authorizedClients, err := db.ListAuthorizedClients(ctx, loginToken.User)
	if err != nil {
		httpError(w, err)
		return
	}

	clients, err := db.ListClients(ctx, loginToken.User)
	if err != nil {
		httpError(w, err)
		return
	}

	var users []User
	if me.Admin {
		users, err = db.ListUsers(ctx)
		if err != nil {
			httpError(w, err)
			return
		}
	}

	data := struct {
		TemplateBaseData
		Me                *User
		AuthorizedClients []AuthorizedClient
		Clients           []Client
		Users             []User
	}{
		Me:                me,
		AuthorizedClients: authorizedClients,
		Clients:           clients,
		Users:             users,
	}
	if err := tpl.ExecuteTemplate(w, "index.html", &data); err != nil {
		panic(err)
	}
	tpl.MustExecuteTemplate(w, "index.html", &data)
}

func login(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)
	tpl := templateFromContext(ctx)

	q := req.URL.Query()
	rawRedirectURI := q.Get("redirect_uri")
	if rawRedirectURI == "" {
		rawRedirectURI = "/"
	}

	redirectURI, err := url.Parse(rawRedirectURI)
	if err != nil || redirectURI.Scheme != "" || redirectURI.Opaque != "" || redirectURI.User != nil || redirectURI.Host != "" {
		http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
		return
	}

	if loginTokenFromContext(ctx) != nil {
		http.Redirect(w, req, redirectURI.String(), http.StatusFound)
		return
	}

	username := req.PostFormValue("username")
	password := req.PostFormValue("password")
	if username == "" {
		if err := tpl.ExecuteTemplate(w, "login.html", nil); err != nil {
			panic(err)
		}
		tpl.MustExecuteTemplate(w, "login.html", nil)
		return
	}

	user, err := db.FetchUserByUsername(ctx, username)
	if err != nil && err != errNoDBRows {
		httpError(w, fmt.Errorf("failed to fetch user: %v", err))
		return
	}
	if err == nil {
		err = user.VerifyPassword(password)
	}
	if err != nil {
		log.Printf("login failed for user %q: %v", username, err)
		// TODO: show error message
		if err := tpl.ExecuteTemplate(w, "login.html", nil); err != nil {
			panic(err)
		}
		tpl.MustExecuteTemplate(w, "login.html", nil)
		return
	}

	if user.PasswordNeedsRehash() {
		if err := user.SetPassword(password); err != nil {
			httpError(w, fmt.Errorf("failed to rehash password: %v", err))
			return
		}
		if err := db.StoreUser(ctx, user); err != nil {
			httpError(w, fmt.Errorf("failed to store user: %v", err))
			return
		}
	}

	token := AccessToken{
		User:  user.ID,
		Scope: internalTokenScope,
	}
	secret, err := token.Generate(4 * time.Hour)
	if err != nil {
		httpError(w, fmt.Errorf("failed to generate access token: %v", err))
		return
	}
	if err := db.CreateAccessToken(ctx, &token); err != nil {
		httpError(w, fmt.Errorf("failed to create access token: %v", err))
		return
	}

	setLoginTokenCookie(w, req, &token, secret)
	http.Redirect(w, req, redirectURI.String(), http.StatusFound)
}

func logout(w http.ResponseWriter, req *http.Request) {
	unsetLoginTokenCookie(w, req)
	http.Redirect(w, req, "/login", http.StatusFound)
}

func manageUser(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)
	tpl := templateFromContext(ctx)

	user := new(User)
	if idStr := chi.URLParam(req, "id"); idStr != "" {
		id, err := ParseID[*User](idStr)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		user, err = db.FetchUser(ctx, id)
		if err != nil {
			httpError(w, err)
			return
		}
	}

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		http.Redirect(w, req, "/login", http.StatusFound)
		return
	}

	me, err := db.FetchUser(ctx, loginToken.User)
	if err != nil {
		httpError(w, err)
		return
	} else if loginToken.User != user.ID && !me.Admin {
		http.Error(w, "Access denied", http.StatusForbidden)
		return
	}

	username := req.PostFormValue("username")
	password := req.PostFormValue("password")
	admin := req.PostFormValue("admin") == "on"
	if username == "" {
		data := struct {
			TemplateBaseData
			User *User
			Me   *User
		}{
			User: user,
			Me:   me,
		}
		if err := tpl.ExecuteTemplate(w, "manage-user.html", &data); err != nil {
			panic(err)
		}
		tpl.MustExecuteTemplate(w, "manage-user.html", &data)
		return
	}

	user.Username = username
	if me.Admin && user.ID != me.ID {
		user.Admin = admin
	}
	if password != "" {
		if err := user.SetPassword(password); err != nil {
			httpError(w, err)
			return
		}
	}

	if err := db.StoreUser(ctx, user); err != nil {
		httpError(w, err)
		return
	}

	http.Redirect(w, req, "/", http.StatusFound)
}