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
9db5d90899afc1d15e8ba34d3819ce7d7eddf0cf
Author
Author date
Mon, 19 Feb 2024 13:49:46 +0100
Committer
Committer date
Mon, 19 Feb 2024 13:49:46 +0100
Actions
Add support for public clients

Closes: https://todo.sr.ht/~emersion/sinwon/19
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 {
			Client *Client
		}{
			Client: client,
		}
		if err := tpl.ExecuteTemplate(w, "manage-client.html", &data); err != nil {
			panic(err)
		}
		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
	}

	client.ClientName = req.PostFormValue("client_name")
	client.ClientURI = req.PostFormValue("client_uri")
	client.RedirectURIs = req.PostFormValue("redirect_uris")
	isPublic := req.PostFormValue("client_type") == "public"

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

	var clientSecret string
	if client.ID == 0 {
		clientSecret, err = client.Generate()
		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 {
		ClientID     string
		ClientSecret string
	}{
		ClientID:     client.ClientID,
		ClientSecret: clientSecret,
	}
	if err := tpl.ExecuteTemplate(w, "client-secret.html", &data); err != nil {
		panic(err)
	}
}
package main

import (
	"crypto/rand"
	"crypto/sha512"
	"crypto/subtle"
	"database/sql"
	"database/sql/driver"
	"encoding/base64"
	"fmt"
	"strconv"
	"strings"
	"time"

	"golang.org/x/crypto/bcrypt"
)

const authCodeExpiration = 10 * time.Minute

type entity interface {
	columns() map[string]interface{}
}

var (
	_ entity = (*User)(nil)
	_ entity = (*Client)(nil)
	_ entity = (*AccessToken)(nil)
	_ entity = (*AuthCode)(nil)
)

type ID[T entity] int64

var (
	_ sql.Scanner   = (*ID[*User])(nil)
	_ driver.Valuer = ID[*User](0)
)

func ParseID[T entity](s string) (ID[T], error) {
	u, _ := strconv.ParseUint(s, 10, 63)
	if u == 0 {
		return 0, fmt.Errorf("invalid ID")
	}
	return ID[T](u), nil
}

func (ptr *ID[T]) Scan(v interface{}) error {
	if v == nil {
		*ptr = 0
		return nil
	}
	id, ok := v.(int64)
	if !ok {
		return fmt.Errorf("cannot scan ID from %T", v)
	}
	*ptr = ID[T](id)
	return nil
}

func (id ID[T]) Value() (driver.Value, error) {
	if id == 0 {
		return nil, nil
	} else {
		return int64(id), nil
	}
}

type nullString string

var (
	_ sql.Scanner   = (*nullString)(nil)
	_ driver.Valuer = (*nullString)(nil)
)

func (ptr *nullString) Scan(v interface{}) error {
	if v == nil {
		*ptr = ""
		return nil
	}
	s, ok := v.(string)
	if !ok {
		return fmt.Errorf("cannot scan nullStringPtr from %T", v)
	}
	*ptr = nullString(s)
	return nil
}

func (ptr *nullString) Value() (driver.Value, error) {
	if *ptr == "" {
		return nil, nil
	} else {
		return string(*ptr), nil
	}
}

type User struct {
	ID           ID[*User]
	Username     string
	PasswordHash string
	Admin        bool
}

func (user *User) columns() map[string]interface{} {
	return map[string]interface{}{
		"id":            &user.ID,
		"username":      &user.Username,
		"password_hash": (*nullString)(&user.PasswordHash),
		"admin":         &user.Admin,
	}
}

func (user *User) VerifyPassword(password string) error {
	// TODO: upgrade hash
	return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
}

func (user *User) SetPassword(password string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	user.PasswordHash = string(hash)
	return nil
}

func (user *User) PasswordNeedsRehash() bool {
	cost, _ := bcrypt.Cost([]byte(user.PasswordHash))
	return cost != bcrypt.DefaultCost
}

type Client struct {
	ID               ID[*Client]
	ClientID         string
	ClientSecretHash []byte
	Owner            ID[*User]
	RedirectURIs     string
	ClientName       string
	ClientURI        string
}

func (client *Client) Generate() (secret string, err error) {
func (client *Client) Generate(isPublic bool) (secret string, err error) {
	id, err := generateUID()
	if err != nil {
		return "", fmt.Errorf("failed to generate client ID: %v", err)
	}
	secret, hash, err := generateSecret()
	if err != nil {
		return "", fmt.Errorf("failed to generate client secret: %v", err)
	client.ClientID = id

	if !isPublic {
		var hash []byte
		secret, hash, err = generateSecret()
		if err != nil {
			return "", fmt.Errorf("failed to generate client secret: %v", err)
		}
		client.ClientSecretHash = hash
	}
	client.ClientID = id
	client.ClientSecretHash = hash

	return secret, nil
}

func (client *Client) columns() map[string]interface{} {
	return map[string]interface{}{
		"id":                 &client.ID,
		"client_id":          &client.ClientID,
		"client_secret_hash": &client.ClientSecretHash,
		"owner":              &client.Owner,
		"redirect_uris":      (*nullString)(&client.RedirectURIs),
		"client_name":        (*nullString)(&client.ClientName),
		"client_uri":         (*nullString)(&client.ClientURI),
	}
}

func (client *Client) VerifySecret(secret string) bool {
	return verifyHash(client.ClientSecretHash, secret)
}

func (client *Client) IsPublic() bool {
	return client.ClientSecretHash == nil
}

type AccessToken struct {
	ID        ID[*AccessToken]
	Hash      []byte
	User      ID[*User]
	Client    ID[*Client]
	Scope     string
	IssuedAt  time.Time
	ExpiresAt time.Time
}

func (token *AccessToken) Generate() (secret string, err error) {
	secret, hash, err := generateSecret()
	if err != nil {
		return "", fmt.Errorf("failed to generate access token secret: %v", err)
	}
	token.Hash = hash
	token.IssuedAt = time.Now()
	token.ExpiresAt = time.Now().Add(2 * time.Hour)
	return secret, nil
}

func NewAccessTokenFromAuthCode(authCode *AuthCode) (token *AccessToken, secret string, err error) {
	token = &AccessToken{
		User:   authCode.User,
		Client: authCode.Client,
		Scope:  authCode.Scope,
	}
	secret, err = token.Generate()
	return token, secret, err
}

func (token *AccessToken) columns() map[string]interface{} {
	return map[string]interface{}{
		"id":         &token.ID,
		"hash":       &token.Hash,
		"user":       &token.User,
		"client":     &token.Client,
		"scope":      (*nullString)(&token.Scope),
		"issued_at":  &token.IssuedAt,
		"expires_at": &token.ExpiresAt,
	}
}

func (token *AccessToken) VerifySecret(secret string) bool {
	return verifyHash(token.Hash, secret) && verifyExpiration(token.ExpiresAt)
}

type AuthCode struct {
	ID        ID[*AuthCode]
	Hash      []byte
	CreatedAt time.Time
	User      ID[*User]
	Client    ID[*Client]
	Scope     string
}

func NewAuthCode(user ID[*User], client ID[*Client], scope string) (code *AuthCode, secret string, err error) {
	secret, hash, err := generateSecret()
	if err != nil {
		return nil, "", fmt.Errorf("failed to generate authentication code secret: %v", err)
	}
	code = &AuthCode{
		Hash:      hash,
		CreatedAt: time.Now(),
		User:      user,
		Client:    client,
		Scope:     scope,
	}
	return code, secret, nil
}

func (code *AuthCode) columns() map[string]interface{} {
	return map[string]interface{}{
		"id":         &code.ID,
		"hash":       &code.Hash,
		"created_at": &code.CreatedAt,
		"user":       &code.User,
		"client":     &code.Client,
		"scope":      (*nullString)(&code.Scope),
	}
}

func (code *AuthCode) VerifySecret(secret string) bool {
	return verifyHash(code.Hash, secret) && verifyExpiration(code.CreatedAt.Add(authCodeExpiration))
}

func UnmarshalSecret[T entity](s string) (id ID[T], secret string, err error) {
	idStr, secret, _ := strings.Cut(s, ".")
	id, err = ParseID[T](idStr)
	return id, secret, err
}

func MarshalSecret[T entity](id ID[T], secret string) string {
	if id == 0 {
		panic("cannot marshal zero ID")
	}
	return fmt.Sprintf("%v.%v", int64(id), secret)
}

func generateUID() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func generateSecret() (secret string, hash []byte, err error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", nil, err
	}
	secret = base64.RawURLEncoding.EncodeToString(b)
	h := sha512.Sum512(b)
	return secret, h[:], nil
}

func verifyHash(hash []byte, secret string) bool {
	b, _ := base64.RawURLEncoding.DecodeString(secret)
	h := sha512.Sum512(b)
	return subtle.ConstantTimeCompare(hash, h[:]) == 1
}

func verifyExpiration(t time.Time) bool {
	return time.Now().Before(t)
}
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) {
	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"
	}
	issuer := issuerURL.String()

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

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 {
			Client *Client
		}{
			Client: client,
		}
		if err := tpl.ExecuteTemplate(w, "authorize.html", data); err != nil {
			panic(err)
		}
		return
	}

	authCode, secret, err := NewAuthCode(loginToken.User, client.ID, scope)
	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")

	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.ClientSecretHash != nil {
	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
	}

	// TODO: check redirect_uri

	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
	}

	var client *Client
	if clientID, clientSecret, ok := req.BasicAuth(); ok {
		client, err = db.FetchClientByClientID(ctx, clientID)
		if err == errNoDBRows || (err == nil && !client.VerifySecret(clientSecret)) {
			oauthError(w, &oauth2.Error{
				Code:        oauth2.ErrorCodeInvalidClient,
				Description: "Invalid client ID or secret",
			})
			return
		} else if err != nil {
			oauthError(w, fmt.Errorf("failed to fetch client: %v", 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 {
			if client.ClientSecretHash != nil {
			if !client.IsPublic() {
				oauthError(w, &oauth2.Error{
					Code:        oauth2.ErrorCodeInvalidClient,
					Description: "Missing client ID and secret",
				})
				return
			}

			client, err = db.FetchClient(ctx, token.Client)
			if err != nil {
				oauthError(w, fmt.Errorf("failed to fetch client: %v", err))
				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 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
	}

	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
}
{{ template "head.html" }}

<main>

<h1>sinwon</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>

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

</main>

{{ template "foot.html" }}