Refactored OAuth Dynamic Client Registration

OAuth clients can only register using Dynamic Client Registration
(DCR). The initial OAuth work implemented DCR and Dynamic Client
Management (DCM). This removes DCM entirely and implements ephemaral
OAuth clients. Each time a client starts an authorization process, it
will need to register a short lived (10 minutes) client first.

The reasoning behind this is that, since client registration is already
public, there's no real need to store clients (their information is
stored with the token though) and force developers to manage clients
(updates, deletion after token revocation, etc.). Moreover, even with
the current model, a client is necessary almost each time you create a
new token (no real difference in database usage).

This model also simplifies potential future implementations:

- IndieAuth clients (https://indieauth.spec.indieweb.org/)
- x509 signed software attestations with a Readeck CA

With this commit, the new workflow is slightly easier:

- register a client
- use the client ID in the next 10 minutes to ask for authorization
- that's it!

When a client is created, it's stored in Readeck K/V with a 10 minute
TTL. Every subsequent request that needs the client ID
(authorize, device, token, etc.) will fetch the necessary client
information from there. The client is removed when the process ends
(or after the TTL).

The created token contains the client information.

This commit comes with a migration that removes a table and a field that
only make it to nightly.

Enforce oauth client grant_types during authorization

- during authorization code or device code grant, check that the client
  accepts the matching grant type
- requires that client redirect_uris is not empty when grant_types
  contains "authorization_code"
This commit is contained in:
Olivier Meunier
2025-10-31 17:13:11 +01:00
parent bf260c3d21
commit adb409503d
20 changed files with 624 additions and 856 deletions

View File

@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<h3 class="title text-lg mb-2">{{ gettext("Application information") }}</h3>
<ul>
<li>{{ gettext("Name") }}: <strong>{{ .Client.Name }}</strong></li>
<li>{{ gettext("Website") }}: <a class="link" href="{{ .Client.Website }}" target="_blank">{{ .Client.Website }}</a></li>
<li>{{ gettext("Website") }}: <a class="link" href="{{ .Client.URI }}" target="_blank">{{ .Client.URI }}</a></li>
<li>{{ gettext("Version") }}: <strong>{{ .Client.SoftwareVersion }}</strong></li>
</ul>

View File

@@ -42,7 +42,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="py-4 max-md:pl-4 grow">
<h2 class="title text-lg mb-0">{{ .ClientName }}</h2>
<p><a class="link" href="{{ .ClientURI }}" target="_blank">{{ .ClientURI }}</a></p>
<p>{{ gettext("Version: %s", .ClientVersion) }}</p>
<p class="mt-1 text-sm">{{ gettext("Authorized on: %s", date(.Created, pgettext("datetime", "%e %B %Y"))) }}
{{- if .LastUsed -}}

View File

@@ -14,19 +14,17 @@ import (
var Keys = KeyMaterial{}
const (
keyToken = "api_token"
keySession = "session"
keyOauthClientToken = "oauth_client_token" //nolint:gosec
keyOauthRequest = "oauth_request"
keyToken = "api_token"
keySession = "session"
keyOauthRequest = "oauth_request"
)
// KeyMaterial contains the signing and encryption keys.
type KeyMaterial struct {
prk []byte // Main pseudorandom key
tokenKey SigningKey
sessionKey []byte
oauthClientTokenKey SigningKey
oauthRequestKey []byte
prk []byte // Main pseudorandom key
tokenKey SigningKey
sessionKey []byte
oauthRequestKey []byte
}
func hkdfHashFunc() hash.Hash {
@@ -51,12 +49,6 @@ func (km KeyMaterial) SessionKey() []byte {
return km.sessionKey
}
// OauthClientTokenKey returns the key used to generate a client's
// authorization token.
func (km KeyMaterial) OauthClientTokenKey() SigningKey {
return km.oauthClientTokenKey
}
// OauthRequestKey returns a 256-bit key used to encode the oauth
// authorization payload.
func (km KeyMaterial) OauthRequestKey() []byte {
@@ -86,6 +78,5 @@ func loadKeys() {
Keys.tokenKey = Keys.mustExpand(keyToken, 32)
Keys.sessionKey = Keys.mustExpand(keySession, 32)
Keys.oauthClientTokenKey = Keys.mustExpand(keyOauthClientToken, 32)
Keys.oauthRequestKey = Keys.mustExpand(keyOauthRequest, 32)
}

View File

@@ -5,31 +5,22 @@
package oauth2
import (
"context"
"errors"
"net/http"
"strings"
"slices"
"time"
"github.com/doug-martin/goqu/v9"
"github.com/go-chi/chi/v5"
"codeberg.org/readeck/readeck/configs"
"codeberg.org/readeck/readeck/internal/auth/tokens"
"codeberg.org/readeck/readeck/internal/bus"
"codeberg.org/readeck/readeck/internal/server"
"codeberg.org/readeck/readeck/internal/server/urls"
"codeberg.org/readeck/readeck/pkg/ctxr"
"codeberg.org/readeck/readeck/pkg/forms"
)
type (
ctxClientKey struct{}
const (
clientTTL = time.Minute * 10
)
var withClient, getClient = ctxr.WithGetter[*Client](ctxClientKey{})
type clientResponse struct {
type oauthClient struct {
ID string `json:"client_id"`
ClientURI string `json:"registration_client_uri"`
AccessToken string `json:"registration_access_token"`
Name string `json:"client_name"`
URI string `json:"client_uri"`
Logo string `json:"logo_uri"`
@@ -41,91 +32,48 @@ type clientResponse struct {
ResponseTypes []string `json:"response_types"`
}
func newClientResponse(ctx context.Context, client *Client) clientResponse {
token, _ := configs.Keys.OauthClientTokenKey().Encode(client.UID)
func loadClient(id string, grantType string) (*oauthClient, error) {
c := &oauthClient{}
if err := bus.GetJSON("oauth:client:"+id, c); err != nil {
return nil, errServerError.withError(err)
}
if c.ID == "" {
return nil, errInvalidClient
}
return clientResponse{
ID: client.UID,
ClientURI: urls.AbsoluteURLContext(ctx, "/api/oauth/client", client.UID).String(),
AccessToken: token,
Name: client.Name,
URI: client.Website,
RedirectURIs: client.RedirectURIs,
Logo: client.Logo,
SoftwareID: client.SoftwareID,
SoftwareVersion: client.SoftwareVersion,
TokenEndpointAuthMethod: "none",
GrantTypes: []string{"authorization_code"},
ResponseTypes: []string{"code"},
if !slices.Contains(c.GrantTypes, grantType) {
return nil, errUnauthorizedClient
}
return c, nil
}
func (c *oauthClient) store() error {
return bus.SetJSON("oauth:client:"+c.ID, c, clientTTL)
}
func (c *oauthClient) remove() error {
if c.ID == "" {
return nil
}
return bus.Store().Del("oauth:client:" + c.ID)
}
func (c *oauthClient) toClientInfo() *tokens.ClientInfo {
return &tokens.ClientInfo{
ID: c.ID,
Name: c.Name,
Website: c.URI,
Logo: c.Logo,
GrantTypes: c.GrantTypes,
SoftwareID: c.SoftwareID,
SoftwareVersion: c.SoftwareVersion,
}
}
type clientAPI struct {
chi.Router
}
func newClientAPI() *clientAPI {
api := &clientAPI{chi.NewRouter()}
api.Post("/", api.clientCreate)
api.With(
api.withClient,
).Route("/{uid}", func(r chi.Router) {
r.Get("/", api.clientInfo)
r.Put("/", api.clientUpdate)
r.Delete("/", api.clientDelete)
})
return api
}
// withAuthenticatedClient retrieves the client from a provided
// bearer token.
func withAuthenticatedClient(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessToken, ok := strings.CutPrefix(r.Header.Get("authorization"), "Bearer ")
if !ok {
server.Err(w, r, errInvalidClient)
return
}
accessToken = strings.TrimSpace(accessToken)
token, err := configs.Keys.OauthClientTokenKey().Decode(accessToken)
if err != nil {
server.Err(w, r, errInvalidClient)
return
}
client, err := Clients.GetOne(goqu.C("uid").Eq(token))
if err != nil {
if errors.Is(err, ErrNotFound) {
server.Err(w, r, errInvalidClient)
} else {
server.Err(w, r, errServerError.withError(err))
}
return
}
ctx := withClient(r.Context(), client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// withClient wraps [withAuthenticatedClient] and checks that the
// path's client ID matches the authenticated client.
func (api *clientAPI) withClient(next http.Handler) http.Handler {
return withAuthenticatedClient(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if getClient(r.Context()).UID != chi.URLParam(r, "uid") {
server.Err(w, r, errInvalidClient)
return
}
next.ServeHTTP(w, r)
}),
)
}
func (api *clientAPI) clientCreate(w http.ResponseWriter, r *http.Request) {
// clientCreate creates a new client that is stored in the K/V store
// for [clientTTL] duration.
func (api *oauthAPI) clientCreate(w http.ResponseWriter, r *http.Request) {
f := newClientForm(server.Locale(r))
forms.Bind(f, r)
@@ -140,44 +88,5 @@ func (api *clientAPI) clientCreate(w http.ResponseWriter, r *http.Request) {
return
}
server.Render(w, r, http.StatusCreated, newClientResponse(r.Context(), client))
}
func (api *clientAPI) clientInfo(w http.ResponseWriter, r *http.Request) {
server.Render(w, r, http.StatusOK,
newClientResponse(r.Context(), getClient(r.Context())),
)
}
func (api *clientAPI) clientUpdate(w http.ResponseWriter, r *http.Request) {
client := getClient(r.Context())
f := newClientForm(server.Locale(r))
f.setClient(client)
forms.Bind(f, r)
if !f.IsValid() {
server.Err(w, r, f.getError())
return
}
res, err := f.updateClient(client)
if err != nil {
server.Err(w, r, errServerError.withError(err))
return
}
if len(res) > 0 {
client, _ = Clients.GetOne(goqu.C("id").Eq(client.ID))
}
server.Render(w, r, http.StatusOK, newClientResponse(r.Context(), client))
}
func (api *clientAPI) clientDelete(w http.ResponseWriter, r *http.Request) {
if err := getClient(r.Context()).Delete(); err != nil {
server.Err(w, r, errServerError.withError(err))
return
}
w.WriteHeader(http.StatusNoContent)
server.Render(w, r, http.StatusCreated, client)
}

View File

@@ -29,6 +29,7 @@ var (
errInvalidScope = oauthError{name: "invalid_scope"}
errServerError = oauthError{name: "server_error", status: http.StatusInternalServerError}
errSlowDown = oauthError{name: "slow_down"}
errUnauthorizedClient = oauthError{name: "unauthorized_client"}
)
// oauthError describes an OAuth error as specified in

View File

@@ -12,6 +12,8 @@ import (
"encoding/json"
"errors"
"image/png"
"net"
"net/http"
"net/netip"
"net/url"
"slices"
@@ -19,19 +21,17 @@ import (
"time"
"github.com/doug-martin/goqu/v9"
"github.com/google/uuid"
"golang.org/x/net/idna"
"codeberg.org/readeck/readeck/configs"
"codeberg.org/readeck/readeck/internal/auth"
"codeberg.org/readeck/readeck/internal/auth/tokens"
"codeberg.org/readeck/readeck/internal/auth/users"
"codeberg.org/readeck/readeck/internal/db/types"
"codeberg.org/readeck/readeck/pkg/base58"
"codeberg.org/readeck/readeck/pkg/forms"
)
type (
ctxClientFormKey struct{}
)
const (
grantTypeAuthCode = "authorization_code"
grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
@@ -45,45 +45,51 @@ func newClientForm(tr forms.Translator) *clientForm {
return &clientForm{
forms.Must(
forms.WithTranslator(context.Background(), tr),
forms.NewTextField("client_id", forms.Trim, forms.ValueValidatorFunc[string](func(f forms.Field, v string) error {
c, _ := forms.GetForm(f).Context().Value(ctxClientFormKey{}).(*Client)
if c == nil {
return nil
}
// Per RFC 7592:
// The client MUST include its "client_id" field in the request, and it
// MUST be the same as its currently issued client identifier.
if c.UID != v {
return errors.New("client ID doesn't match")
}
return nil
})),
forms.NewTextField("client_name", forms.Trim, forms.Required, forms.StrLen(0, 128)),
forms.NewTextField("client_uri", forms.Trim, forms.Required, forms.StrLen(0, 256), forms.IsURL("https")),
forms.NewTextField("client_uri", forms.Trim, forms.Required, forms.StrLen(0, 256), isValidClientURI),
forms.NewTextField("logo_uri", forms.Trim, forms.StrLen(0, 8<<10), isValidLogoURI),
forms.NewTextField("software_id", forms.Trim, forms.Required, forms.StrLen(0, 128)),
forms.NewTextField("software_version", forms.Trim, forms.Required, forms.StrLen(0, 64)),
forms.NewTextListField("redirect_uris", forms.Trim, forms.Required, isValidRedirectURI),
forms.NewTextListField("redirect_uris",
forms.Trim,
forms.FieldValidatorFunc(func(f forms.Field) error {
if !slices.Contains(
forms.GetForm(f).Get("grant_types").(*forms.TextListField).V(),
grantTypeAuthCode,
) {
return nil
}
if len(f.(*forms.ListField[string]).V()) == 0 {
return forms.ErrRequired
}
return nil
}),
isValidRedirectURI,
),
forms.NewTextListField("grant_types",
forms.ChoicesPairs([][2]string{
{grantTypeAuthCode, grantTypeAuthCode},
{grantTypeDeviceCode, grantTypeDeviceCode},
}),
forms.Default([]string{grantTypeAuthCode, grantTypeDeviceCode}),
),
// Ignored fields but we want to coerce their values
forms.NewTextField("token_endpoint_auth_method", forms.ChoicesPairs([][2]string{{"none", "none"}})),
forms.NewTextListField("grant_types", forms.ChoicesPairs([][2]string{
{grantTypeAuthCode, grantTypeAuthCode},
{grantTypeDeviceCode, grantTypeDeviceCode},
})),
forms.NewTextListField("response_types", forms.ChoicesPairs([][2]string{
{"code", "code"},
})),
forms.NewTextField("token_endpoint_auth_method",
forms.ChoicesPairs([][2]string{{"none", "none"}}),
forms.Default("none"),
),
forms.NewTextListField("response_types",
forms.ChoicesPairs([][2]string{
{"code", "code"},
}),
forms.Default([]string{"code"}),
),
),
}
}
func (f *clientForm) setClient(c *Client) {
ctx := context.WithValue(f.Context(), ctxClientFormKey{}, c)
f.SetContext(ctx)
}
func (f *clientForm) getError() oauthError {
switch {
case len(f.Get("redirect_uris").Errors()) > 0:
@@ -93,60 +99,27 @@ func (f *clientForm) getError() oauthError {
}
}
func (f *clientForm) createClient() (*Client, error) {
client := &Client{
Name: f.Get("client_name").String(),
Website: f.Get("client_uri").String(),
Logo: f.Get("logo_uri").String(),
RedirectURIs: f.Get("redirect_uris").Value().([]string),
SoftwareID: f.Get("software_id").String(),
SoftwareVersion: f.Get("software_version").String(),
func (f *clientForm) createClient() (*oauthClient, error) {
client := &oauthClient{
ID: uuid.New().URN(),
Name: f.Get("client_name").String(),
URI: f.Get("client_uri").String(),
Logo: f.Get("logo_uri").String(),
RedirectURIs: f.Get("redirect_uris").(*forms.TextListField).V(),
GrantTypes: f.Get("grant_types").(*forms.TextListField).V(),
TokenEndpointAuthMethod: f.Get("token_endpoint_auth_method").String(),
ResponseTypes: f.Get("response_types").(*forms.TextListField).V(),
SoftwareID: f.Get("software_id").String(),
SoftwareVersion: f.Get("software_version").String(),
}
if err := Clients.Create(client); err != nil {
if err := client.store(); err != nil {
return nil, err
}
return client, nil
}
func (f *clientForm) updateClient(client *Client) (res map[string]any, err error) {
if !f.IsBound() {
err = errors.New("form is not bound")
return
}
res = make(map[string]any)
for _, field := range f.Fields() {
if !field.IsBound() || field.IsNil() {
continue
}
switch field.Name() {
case "client_name":
res["name"] = field.String()
case "client_uri":
res["website"] = field.String()
case "logo_uri":
res["logo"] = field.String()
case "redirect_uris":
res["redirect_uris"] = types.Strings(field.Value().([]string))
case "software_version":
res["software_version"] = field.String()
}
}
if len(res) == 0 {
return
}
if err = client.Update(res); err != nil {
f.AddErrors("", forms.ErrUnexpected)
return
}
return
}
type authorizationForm struct {
*forms.Form
}
@@ -318,28 +291,62 @@ func newRevokeTokenForm(tr forms.Translator) *revokeTokenForm {
)}
}
func (f *revokeTokenForm) revoke(client *Client) error {
func (f *revokeTokenForm) revoke(r *http.Request) error {
tokenID, err := configs.Keys.TokenKey().Decode(f.Get("token").String())
if err != nil {
return err
}
token, err := tokens.Tokens.GetOne(goqu.C("uid").Eq(tokenID))
if err != nil && !errors.Is(err, tokens.ErrNotFound) {
return err
}
if token == nil {
return nil
// must be authenticated with the same token
if tokenID != auth.GetRequestAuthInfo(r).Provider.ID {
return errAccessDenied
}
// A client can only remove its own tokens
if *token.ClientID != client.ID {
return errInvalidRequest
token, err := tokens.Tokens.GetOne(goqu.C("uid").Eq(tokenID))
if err != nil {
if errors.Is(err, tokens.ErrNotFound) {
return nil
}
return err
}
return token.Delete()
}
// isValidClientURI checks the given client URL.
// It must be https only and resolve to an ip that is not
// private or a loopback address.
var isValidClientURI = forms.TypedValidator(func(v string) bool {
u, err := url.Parse(v)
if err != nil {
return false
}
if u.Scheme != "https" {
return false
}
if u.Hostname() == "" {
return false
}
host, err := idna.ToASCII(u.Hostname())
if err != nil {
return false
}
ips, err := net.LookupIP(host)
if err != nil {
return false
}
// Private and loopback is not allowed
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() {
return false
}
}
return true
}, errors.New("invalid client URI"))
var isValidLogoURI = forms.TypedValidator(func(v string) bool {
if v == "" {
return true

View File

@@ -111,7 +111,6 @@ func newAuthorizeViewRouter() *authorizeViewRouter {
server.WithSession(),
server.WithRedirectLogin,
auth.Required,
router.withClient,
).Route("/", func(r chi.Router) {
r.Get("/", router.authorizeHandler)
r.Post("/", router.authorizeHandler)
@@ -120,29 +119,6 @@ func newAuthorizeViewRouter() *authorizeViewRouter {
return router
}
func (h *authorizeViewRouter) withClient(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
server.Err(w, r, errInvalidClient)
return
}
client, err := Clients.GetOne(goqu.C("uid").Eq(clientID))
if err != nil {
if errors.Is(err, ErrNotFound) {
server.Err(w, r, errInvalidClient)
} else {
server.Err(w, r, errServerError.withError(err))
}
return
}
ctx := withClient(r.Context(), client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// authorizeHandler is the authorization page returned to a user.
// It shows a form with an accept or deny action. Once the form is
// submitted, it returns a 302 response with the redirection
@@ -161,7 +137,11 @@ func (h *authorizeViewRouter) authorizeHandler(w http.ResponseWriter, r *http.Re
forms.Bind(f, r)
}
client := getClient(r.Context())
client, err := loadClient(f.Get("client_id").String(), grantTypeAuthCode)
if err != nil {
server.Err(w, r, err)
return
}
// Validate redirect URI first
redir, _ := url.Parse(f.Get("redirect_uri").String())
@@ -208,6 +188,7 @@ func (h *authorizeViewRouter) authorizeHandler(w http.ResponseWriter, r *http.Re
}
if !f.Get("granted").(*forms.BooleanField).V() {
client.remove() //nolint:errcheck
errAccessDenied.withDescription("access denied").redirect(w, r, redir, params)
return
}
@@ -248,19 +229,20 @@ func (api *oauthAPI) authorizationCodeHandler(w http.ResponseWriter, r *http.Req
return
}
client, err := Clients.GetOne(goqu.C("uid").Eq(req.ClientID))
client, err := loadClient(req.ClientID, grantTypeAuthCode)
if err != nil {
server.Err(w, r, errInvalidClient.withDescription("client not found").withError(err))
server.Err(w, r, err)
return
}
defer client.remove() //nolint:errcheck
t := &tokens.Token{
UID: req.TokenID,
UserID: &user.ID,
ClientID: &client.ID,
IsEnabled: true,
Application: client.Name,
Roles: req.Scopes,
ClientInfo: client.toClientInfo(),
}
if err = tokens.Tokens.Create(t); err != nil {
server.Err(w, r,

View File

@@ -7,7 +7,6 @@ package oauth2
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -80,7 +79,7 @@ type deviceAuthorizationResponse struct {
// The request is stored in Readeck's key/value store with
// a TTL of 5 minutes.
type deviceAuthorizationRequest struct {
ClientID int `json:"c"`
ClientID string `json:"c"`
UserID int `json:"u"`
TokenID int `json:"t"`
Expires time.Time `json:"e"`
@@ -90,27 +89,15 @@ type deviceAuthorizationRequest struct {
}
func (r *deviceAuthorizationRequest) store(code userCode) error {
data, err := json.Marshal(r)
if err != nil {
return err
}
duration := r.Expires.Sub(time.Now().UTC())
return bus.Store().Set(
fmt.Sprintf("oauth:device-code:%s", code),
string(data),
duration,
)
return bus.SetJSON("oauth:device-code:"+string(code), r, r.Expires.Sub(time.Now().UTC()))
}
func loadDeviceAuthorizationRequest(code userCode) (*deviceAuthorizationRequest, error) {
data := bus.Store().Get(fmt.Sprintf("oauth:device-code:%s", code))
if data == "" {
return &deviceAuthorizationRequest{}, nil
}
r := &deviceAuthorizationRequest{}
err := json.Unmarshal([]byte(data), r)
return r, err
if err := bus.GetJSON("oauth:device-code:"+string(code), r); err != nil {
return nil, errServerError.withError(err)
}
return r, nil
}
// newUserCode generate a random [userCode] of 8 letters from [deviceCodeAlphabet].
@@ -210,7 +197,7 @@ func (h *deviceViewRouter) authorizeHandler(w http.ResponseWriter, r *http.Reque
switch req.Status {
case codeRequestPending:
client, err := Clients.GetOne(goqu.C("id").Eq(req.ClientID))
client, err := loadClient(req.ClientID, grantTypeDeviceCode)
if err != nil {
server.Err(w, r, err)
return
@@ -278,9 +265,9 @@ func (api *oauthAPI) deviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
client, err := Clients.GetOne(goqu.C("uid").Eq(f.Get("client_id").String()))
client, err := loadClient(f.Get("client_id").String(), grantTypeDeviceCode)
if err != nil {
server.Err(w, r, errInvalidClient)
server.Err(w, r, err)
return
}
@@ -326,9 +313,9 @@ func (api *oauthAPI) deviceHandler(w http.ResponseWriter, r *http.Request) {
// "urn:ietf:params:oauth:grant-type:device_code" grant_type.
func (api *oauthAPI) deviceCodeHandler(w http.ResponseWriter, r *http.Request) {
f := getTokenForm(r.Context())
client, err := Clients.GetOne(goqu.C("uid").Eq(f.Get("client_id").String()))
client, err := loadClient(f.Get("client_id").String(), grantTypeDeviceCode)
if err != nil {
server.Err(w, r, errInvalidClient)
server.Err(w, r, err)
return
}
@@ -375,10 +362,10 @@ func (api *oauthAPI) deviceCodeHandler(w http.ResponseWriter, r *http.Request) {
// Create a token when it doesn't exist.
t = &tokens.Token{
UserID: &user.ID,
ClientID: &client.ID,
IsEnabled: true,
Application: client.Name,
Roles: req.Scopes,
ClientInfo: client.toClientInfo(),
}
if err = tokens.Tokens.Create(t); err != nil {
server.Err(w, r,

View File

@@ -1,112 +0,0 @@
// SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package oauth2
import (
"errors"
"time"
"github.com/doug-martin/goqu/v9"
"codeberg.org/readeck/readeck/internal/db"
"codeberg.org/readeck/readeck/internal/db/types"
"codeberg.org/readeck/readeck/pkg/base58"
)
const (
// TableName is the database table.
TableName = "oauth2_client"
)
var (
// Clients is the model manager for [Client] instances.
Clients = Manager{}
// ErrNotFound is returned when a token record was not found.
ErrNotFound = errors.New("not found")
)
// Client is an oauth2 client record in database.
type Client struct {
ID int `db:"id" goqu:"skipinsert,skipupdate"`
UID string `db:"uid"`
Created time.Time `db:"created" goqu:"skipupdate"`
Name string `db:"name"`
Website string `db:"website"`
Logo string `db:"logo"`
RedirectURIs types.Strings `db:"redirect_uris"`
SoftwareID string `db:"software_id"`
SoftwareVersion string `db:"software_version"`
}
// Manager is a query helper for client entries.
type Manager struct{}
// Query returns a prepared [goqu.SelectDataset] that can be extended later.
func (m *Manager) Query() *goqu.SelectDataset {
return db.Q().From(goqu.T(TableName).As("c")).Prepared(true)
}
// GetOne executes the a select query and returns the first result or an error
// when there's no result.
func (m *Manager) GetOne(expressions ...goqu.Expression) (*Client, error) {
var c Client
found, err := m.Query().Where(expressions...).ScanStruct(&c)
switch {
case err != nil:
return nil, err
case !found:
return nil, ErrNotFound
}
return &c, nil
}
// Create insert a new client in the database.
func (m *Manager) Create(client *Client) error {
client.Created = time.Now().UTC()
client.UID = base58.NewUUID()
ds := db.Q().Insert(TableName).
Rows(client).
Prepared(true)
id, err := db.InsertWithID(ds, "id")
if err != nil {
return err
}
client.ID = id
return nil
}
// Update updates some bookmark values.
func (c *Client) Update(v interface{}) error {
if c.ID == 0 {
return errors.New("no ID")
}
_, err := db.Q().Update(TableName).Prepared(true).
Set(v).
Where(goqu.C("id").Eq(c.ID)).
Executor().Exec()
return err
}
// Save updates all the token values.
func (c *Client) Save() error {
return c.Update(c)
}
// Delete removes a token from the database.
func (c *Client) Delete() error {
_, err := db.Q().Delete(TableName).Prepared(true).
Where(goqu.C("id").Eq(c.ID)).
Executor().Exec()
return err
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"codeberg.org/readeck/readeck/internal/auth"
"codeberg.org/readeck/readeck/internal/server"
"codeberg.org/readeck/readeck/pkg/ctxr"
"codeberg.org/readeck/readeck/pkg/forms"
@@ -35,10 +36,10 @@ type oauthAPI struct {
func newOAuthAPI() *oauthAPI {
router := &oauthAPI{chi.NewRouter()}
router.Mount("/client", newClientAPI())
router.Post("/client", router.clientCreate)
router.Post("/device", router.deviceHandler)
router.Post("/token", router.tokenHandler)
router.With(withAuthenticatedClient).Post("/revoke", router.revokeToken)
router.With(auth.Required).Post("/revoke", router.revokeToken)
return router
}
@@ -75,8 +76,7 @@ func (api *oauthAPI) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
client := getClient(r.Context())
if err := f.revoke(client); err != nil {
if err := f.revoke(r); err != nil {
switch err.(type) {
case oauthError:
server.Err(w, r, err)

View File

@@ -14,13 +14,21 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"codeberg.org/readeck/readeck/configs"
"codeberg.org/readeck/readeck/internal/auth/oauth2"
"codeberg.org/readeck/readeck/internal/db/types"
. "codeberg.org/readeck/readeck/internal/testing" //revive:disable:dot-imports
)
func registerClient(t *testing.T, client *Client) string {
rsp := client.RequestJSON(http.MethodPost, "/api/oauth/client", map[string]any{
"client_name": "Test App",
"client_uri": "https://example.net/",
"software_id": uuid.NewString(),
"software_version": "1.0.2",
"redirect_uris": []string{"http://[::1]:4000/callback"},
})
require.Equal(t, 201, rsp.StatusCode)
return rsp.JSON.(map[string]any)["client_id"].(string)
}
func TestServerMetadata(t *testing.T) {
app := NewTestApp(t)
defer app.Close(t)
@@ -52,205 +60,261 @@ func TestAuthorizationCodeFlow(t *testing.T) {
client := NewClient(t, app)
appClient := &oauth2.Client{
Name: "Test App",
Website: "https://example.net",
RedirectURIs: types.Strings{"http://[::1]:4000/callback"},
SoftwareID: uuid.NewString(),
SoftwareVersion: "1.0.2",
}
require.NoError(t, oauth2.Clients.Create(appClient))
appToken, err := configs.Keys.OauthClientTokenKey().Encode(appClient.UID)
require.NoError(t, err)
t.Run("authorization form", func(t *testing.T) {
clientID := registerClient(t, client)
codeVerifier := "210e967c91ae52d32bf414f1439769fb0eda1f828fc8d49c78e18ac5"
h := sha256.New()
h.Write([]byte(codeVerifier))
codeVerifier := "210e967c91ae52d32bf414f1439769fb0eda1f828fc8d49c78e18ac5"
h := sha256.New()
h.Write([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
params := url.Values{}
params.Set("client_id", appClient.UID)
params.Set("redirect_uri", "http://[::1]:4000/callback")
params.Set("scope", "bookmarks:read bookmarks:write")
params.Set("state", "random.state")
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
codeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
params := url.Values{}
params.Set("client_id", clientID)
params.Set("redirect_uri", "http://[::1]:4000/callback")
params.Set("scope", "bookmarks:read bookmarks:write")
params.Set("state", "random.state")
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
tokenCode := ""
accessToken := ""
_ = tokenCode
_ = accessToken
RunRequestSequence(t, client, "", RequestTest{
Target: "/authorize",
ExpectStatus: 303,
})
RunRequestSequence(t, client, "", RequestTest{
Target: "/authorize",
ExpectStatus: 303,
RunRequestSequence(t, client, "user",
RequestTest{
Name: "authorize ko",
Target: "/authorize",
ExpectStatus: 401,
ExpectJSON: `{"error": "invalid_client"}`,
},
RequestTest{
Name: "authorize form",
Target: "/authorize?" + params.Encode(),
ExpectContains: `Authorize</button>`,
},
RequestTest{
Name: "authorize ok",
Target: "{{ (index .History 0).URL }}",
Method: http.MethodPost,
Form: url.Values{
"granted": []string{"1"},
},
ExpectStatus: http.StatusFound,
Assert: func(t *testing.T, r *Response) {
assert := require.New(t)
location := r.Header.Get("location")
assert.NotEmpty(location)
u, err := url.Parse(location)
assert.NoError(err)
query := u.Query()
assert.Contains(query, "code")
assert.NotEmpty(query.Get("code"))
assert.Equal("random.state", query.Get("state"))
},
},
RequestTest{
Name: "authorize deny",
Target: "{{ (index .History 0).URL }}",
Method: http.MethodPost,
Form: url.Values{
"granted": []string{"0"},
},
ExpectStatus: http.StatusFound,
Assert: func(t *testing.T, r *Response) {
assert := require.New(t)
location := r.Header.Get("location")
assert.NotEmpty(location)
u, err := url.Parse(location)
assert.NoError(err)
query := u.Query()
assert.Equal("access_denied", query.Get("error"))
assert.Equal("access denied", query.Get("error_description"))
assert.Contains(query, "code")
assert.Empty(query.Get("code"))
},
},
RequestTest{
Name: "client gone after deny",
Target: "{{ (index .History 0).URL }}",
Method: http.MethodGet,
ExpectStatus: http.StatusUnauthorized,
},
)
})
RunRequestSequence(t, client, "user",
RequestTest{
Name: "authorize ko",
Target: "/authorize",
ExpectStatus: 401,
ExpectJSON: `{"error": "invalid_client"}`,
},
RequestTest{
Name: "authorize form",
Target: "/authorize?" + params.Encode(),
ExpectContains: `Authorize</button>`,
},
RequestTest{
Name: "authorize ok",
Target: "{{ (index .History 0).URL }}",
Method: http.MethodPost,
Form: url.Values{
"granted": []string{"1"},
},
ExpectStatus: http.StatusFound,
Assert: func(t *testing.T, r *Response) {
assert := require.New(t)
location := r.Header.Get("location")
assert.NotEmpty(location)
u, err := url.Parse(location)
assert.NoError(err)
t.Run("token", func(t *testing.T) {
clientID := registerClient(t, client)
query := u.Query()
assert.Contains(query, "code")
assert.NotEmpty(query.Get("code"))
assert.Equal("random.state", query.Get("state"))
tokenCode = query.Get("code")
},
},
RequestTest{
Name: "authorize deny",
Target: "{{ (index .History 0).URL }}",
Method: http.MethodPost,
Form: url.Values{
"granted": []string{"0"},
},
ExpectStatus: http.StatusFound,
Assert: func(t *testing.T, r *Response) {
assert := require.New(t)
location := r.Header.Get("location")
assert.NotEmpty(location)
u, err := url.Parse(location)
assert.NoError(err)
codeVerifier := "210e967c91ae52d32bf414f1439769fb0eda1f828fc8d49c78e18ac5"
h := sha256.New()
h.Write([]byte(codeVerifier))
query := u.Query()
assert.Equal("access_denied", query.Get("error"))
assert.Equal("access denied", query.Get("error_description"))
assert.Contains(query, "code")
assert.Empty(query.Get("code"))
},
},
)
codeChallenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
params := url.Values{}
params.Set("client_id", clientID)
params.Set("redirect_uri", "http://[::1]:4000/callback")
params.Set("scope", "bookmarks:read bookmarks:write")
params.Set("state", "random.state")
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
RunRequestSequence(t, client, "",
RequestTest{
Name: "token challenge ko",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{},
ExpectStatus: 400,
ExpectJSON: `{
"error":"invalid_request",
"error_description":"error on field \"grant_type\": field is required"
}`,
},
RequestTest{
Name: "token challenge ko",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{"pnIEw47"},
"code_verifier": []string{"FaRhB"},
},
ExpectStatus: 400,
ExpectJSON: `{
"error":"invalid_grant",
"error_description":"code is not valid"
}`,
},
RequestTest{
Name: "token challenge ko",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{tokenCode},
"code_verifier": []string{"8GrkY4Fk1XpnIEw47w71XDEoMFaRhB8SvLhs9ZLCCNI"},
},
ExpectStatus: 400,
ExpectJSON: `{
"error":"invalid_grant",
"error_description":"code is not valid"
}`,
},
RequestTest{
Name: "token ok",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{tokenCode},
"code_verifier": []string{codeVerifier},
},
ExpectStatus: http.StatusCreated,
ExpectJSON: `{
"access_token": "<<PRESENCE>>",
"id": "<<PRESENCE>>",
"scope": "bookmarks:read bookmarks:write",
"token_type": "Bearer"
}`,
Assert: func(t *testing.T, r *Response) {
accessToken = r.JSON.(map[string]any)["access_token"].(string)
t.Log(accessToken)
},
},
)
tokenCode := ""
accessToken := ""
RunRequestSequence(t, client, "",
RequestTest{
Name: "profile with new token",
Target: "/api/profile",
Headers: map[string]string{
"Authorization": "Bearer " + accessToken,
RunRequestSequence(t, client, "user",
RequestTest{
Name: "authorize form",
Target: "/authorize?" + params.Encode(),
ExpectContains: `Authorize</button>`,
},
ExpectStatus: 200,
},
RequestTest{
Name: "revoke token",
Target: "/api/oauth/revoke",
Method: http.MethodPost,
Headers: map[string]string{
"Authorization": "Bearer " + appToken,
RequestTest{
Name: "authorize ok",
Target: "{{ (index .History 0).URL }}",
Method: http.MethodPost,
Form: url.Values{
"granted": []string{"1"},
},
ExpectStatus: http.StatusFound,
Assert: func(t *testing.T, r *Response) {
u, err := url.Parse(r.Header.Get("location"))
require.NoError(t, err)
query := u.Query()
tokenCode = query.Get("code")
},
},
JSON: map[string]string{
"token": accessToken,
)
RunRequestSequence(t, client, "",
RequestTest{
Name: "token challenge ko",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{},
ExpectStatus: 400,
ExpectJSON: `{
"error":"invalid_request",
"error_description":"error on field \"grant_type\": field is required"
}`,
},
ExpectStatus: 200,
},
RequestTest{
Name: "profile with revoked token",
Target: "/api/profile",
Headers: map[string]string{
"Authorization": "Bearer " + accessToken,
RequestTest{
Name: "token challenge ko",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{"pnIEw47"},
"code_verifier": []string{"FaRhB"},
},
ExpectStatus: 400,
ExpectJSON: `{
"error":"invalid_grant",
"error_description":"code is not valid"
}`,
},
ExpectStatus: 401,
},
RequestTest{
Name: "revoke token already revoked",
Target: "/api/oauth/revoke",
Method: http.MethodPost,
Headers: map[string]string{
"Authorization": "Bearer " + appToken,
RequestTest{
Name: "token challenge ko",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{tokenCode},
"code_verifier": []string{"8GrkY4Fk1XpnIEw47w71XDEoMFaRhB8SvLhs9ZLCCNI"},
},
ExpectStatus: 400,
ExpectJSON: `{
"error":"invalid_grant",
"error_description":"code is not valid"
}`,
},
JSON: map[string]string{
"token": accessToken,
RequestTest{
Name: "token ok",
Target: "/api/oauth/token",
Method: http.MethodPost,
Form: url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{tokenCode},
"code_verifier": []string{codeVerifier},
},
ExpectStatus: http.StatusCreated,
ExpectJSON: `{
"access_token": "<<PRESENCE>>",
"id": "<<PRESENCE>>",
"scope": "bookmarks:read bookmarks:write",
"token_type": "Bearer"
}`,
Assert: func(t *testing.T, r *Response) {
accessToken = r.JSON.(map[string]any)["access_token"].(string)
t.Log(accessToken)
require.Empty(t, Store().Get("oauth:client:"+clientID))
},
},
ExpectStatus: 200,
},
)
)
RunRequestSequence(t, client, "",
RequestTest{
Name: "profile with new token",
Target: "/api/profile",
Headers: map[string]string{
"Authorization": "Bearer " + accessToken,
},
ExpectStatus: 200,
},
)
RunRequestSequence(t, client, "user",
RequestTest{
Name: "revoke token with session auth",
Target: "/api/oauth/revoke",
Method: http.MethodPost,
JSON: map[string]string{
"token": accessToken,
},
ExpectStatus: 400,
ExpectJSON: `{
"error":"access_denied"
}`,
},
)
RunRequestSequence(t, client, "",
RequestTest{
Name: "revoke token",
Target: "/api/oauth/revoke",
Method: http.MethodPost,
Headers: map[string]string{
"Authorization": "Bearer " + accessToken,
},
JSON: map[string]string{
"token": accessToken,
},
ExpectStatus: 200,
},
RequestTest{
Name: "profile with revoked token",
Target: "/api/profile",
Headers: map[string]string{
"Authorization": "Bearer " + accessToken,
},
ExpectStatus: 401,
},
RequestTest{
Name: "revoke token already revoked",
Target: "/api/oauth/revoke",
Method: http.MethodPost,
Headers: map[string]string{
"Authorization": "Bearer " + accessToken,
},
JSON: map[string]string{
"token": accessToken,
},
ExpectStatus: 401,
},
)
})
}
func TestDeviceCodeFlow(t *testing.T) {
@@ -259,16 +323,8 @@ func TestDeviceCodeFlow(t *testing.T) {
client := NewClient(t, app)
appClient := &oauth2.Client{
Name: "Test App",
Website: "https://example.net",
RedirectURIs: types.Strings{"http://[::1]:4000/callback"},
SoftwareID: uuid.NewString(),
SoftwareVersion: "1.0.2",
}
require.NoError(t, oauth2.Clients.Create(appClient))
t.Run("granted access", func(t *testing.T) {
clientID := registerClient(t, client)
deviceCode := ""
userCode := ""
@@ -278,7 +334,7 @@ func TestDeviceCodeFlow(t *testing.T) {
Target: "/api/oauth/device",
Method: http.MethodPost,
Form: url.Values{
"client_id": {appClient.UID},
"client_id": {clientID},
"scope": {"bookmarks:read"},
},
ExpectStatus: 200,
@@ -311,7 +367,7 @@ func TestDeviceCodeFlow(t *testing.T) {
JSON: map[string]any{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": "{{ (index .History 0).JSON.device_code }}",
"client_id": appClient.UID,
"client_id": clientID,
},
ExpectStatus: 400,
ExpectJSON: `{"error": "authorization_pending"}`,
@@ -323,7 +379,7 @@ func TestDeviceCodeFlow(t *testing.T) {
JSON: map[string]any{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": "{{ (index .History 1).JSON.device_code }}",
"client_id": appClient.UID,
"client_id": clientID,
},
ExpectStatus: 400,
ExpectJSON: `{"error": "slow_down"}`,
@@ -387,7 +443,7 @@ func TestDeviceCodeFlow(t *testing.T) {
JSON: map[string]any{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": deviceCode,
"client_id": appClient.UID,
"client_id": clientID,
},
ExpectStatus: 201,
ExpectJSON: `{
@@ -409,6 +465,7 @@ func TestDeviceCodeFlow(t *testing.T) {
})
t.Run("denied access", func(t *testing.T) {
clientID := registerClient(t, client)
deviceCode := ""
userCode := ""
@@ -418,7 +475,7 @@ func TestDeviceCodeFlow(t *testing.T) {
Target: "/api/oauth/device",
Method: http.MethodPost,
Form: url.Values{
"client_id": {appClient.UID},
"client_id": {clientID},
"scope": {"bookmarks:read"},
},
ExpectStatus: 200,
@@ -465,7 +522,7 @@ func TestDeviceCodeFlow(t *testing.T) {
JSON: map[string]any{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": deviceCode,
"client_id": appClient.UID,
"client_id": clientID,
},
ExpectStatus: 400,
ExpectJSON: `{"error": "access_denied"}`,
@@ -474,6 +531,7 @@ func TestDeviceCodeFlow(t *testing.T) {
})
t.Run("expired code", func(t *testing.T) {
clientID := registerClient(t, client)
deviceCode := ""
userCode := ""
@@ -483,7 +541,7 @@ func TestDeviceCodeFlow(t *testing.T) {
Target: "/api/oauth/device",
Method: http.MethodPost,
Form: url.Values{
"client_id": {appClient.UID},
"client_id": {clientID},
"scope": {"bookmarks:read"},
},
ExpectStatus: 200,
@@ -519,7 +577,7 @@ func TestDeviceCodeFlow(t *testing.T) {
JSON: map[string]any{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": deviceCode,
"client_id": appClient.UID,
"client_id": clientID,
},
ExpectStatus: 400,
ExpectJSON: `{"error": "expired_token"}`,
@@ -534,23 +592,6 @@ func TestClientRegistration(t *testing.T) {
client := NewClient(t, app)
appClient1 := &oauth2.Client{
Name: "Test App 1",
Website: "https://example.net",
RedirectURIs: types.Strings{"http://[::1]:4000/callback"},
SoftwareID: uuid.NewString(),
SoftwareVersion: "1.0.2",
}
appClient2 := &oauth2.Client{
Name: "Test App 2",
Website: "https://example.net",
RedirectURIs: types.Strings{"http://[::1]:4000/callback"},
SoftwareID: uuid.NewString(),
SoftwareVersion: "1.0.2",
}
require.NoError(t, oauth2.Clients.Create(appClient1))
require.NoError(t, oauth2.Clients.Create(appClient2))
t.Run("invalid_redirect_uri", func(t *testing.T) {
tests := []string{
"http://test.localhost/",
@@ -575,31 +616,33 @@ func TestClientRegistration(t *testing.T) {
RunRequestSequence(t, client, "", seq...)
})
t.Run("client bearer", func(t *testing.T) {
t1, err := configs.Keys.OauthClientTokenKey().Encode(appClient1.UID)
require.NoError(t, err)
t.Run("invalid client_uri", func(t *testing.T) {
tests := []string{
"http://example.com/",
"https://192.168.0.1:4000/test",
"https://[fc00::1]/",
"https://localhost/",
"https://test.localhost/",
}
t2, err := configs.Keys.OauthClientTokenKey().Encode(appClient2.UID)
require.NoError(t, err)
RunRequestSequence(t, client, "",
RequestTest{
Name: "ok",
Target: "/api/oauth/client/" + appClient1.UID,
Headers: map[string]string{
"Authorization": "Bearer " + t1,
seq := []RequestTest{}
for _, test := range tests {
seq = append(seq, RequestTest{
Name: "no client URI",
Target: "/api/oauth/client",
Method: "POST",
JSON: map[string]any{
"client_name": "test",
"client_uri": test,
"redirect_uris": []string{"https://example.org/callback"},
},
ExpectStatus: 200,
},
RequestTest{
Name: "mismatch id and token",
Target: "/api/oauth/client/" + appClient1.UID,
Headers: map[string]string{
"Authorization": "Bearer " + t2,
},
ExpectStatus: 401,
},
)
ExpectJSON: `{
"error": "invalid_client_metadata",
"error_description": "error on field \"client_uri\": invalid client URI"
}`,
})
}
RunRequestSequence(t, client, "", seq...)
})
RunRequestSequence(t, client, "",
@@ -616,7 +659,7 @@ func TestClientRegistration(t *testing.T) {
}`,
},
RequestTest{
Name: "no client name",
Name: "no client URI",
Target: "/api/oauth/client",
Method: "POST",
JSON: map[string]any{
@@ -650,8 +693,6 @@ func TestClientRegistration(t *testing.T) {
ExpectStatus: 201,
ExpectJSON: `{
"client_id":"<<PRESENCE>>",
"registration_access_token": "<<PRESENCE>>",
"registration_client_uri": "<<PRESENCE>>",
"client_name": "test",
"client_uri": "https://example.org/",
"logo_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==",
@@ -666,97 +707,9 @@ func TestClientRegistration(t *testing.T) {
"software_id":"10098d11-8b3f-4ebb-b519-cab2301975fa",
"software_version":"1.0.0",
"token_endpoint_auth_method":"none",
"grant_types":["authorization_code"],
"grant_types":["authorization_code","urn:ietf:params:oauth:grant-type:device_code"],
"response_types":["code"]
}`,
},
RequestTest{
Name: "client info",
Target: "/api/oauth/client/{{ (index .History 0).JSON.client_id }}",
ExpectStatus: 401,
},
RequestTest{
Name: "client info",
Target: "/api/oauth/client/{{ (index .History 1).JSON.client_id }}",
Headers: map[string]string{
"Authorization": "Bearer {{ (index .History 1).JSON.registration_access_token }}",
},
ExpectStatus: 200,
ExpectJSON: `{
"client_id":"<<PRESENCE>>",
"registration_access_token": "<<PRESENCE>>",
"registration_client_uri": "<<PRESENCE>>",
"client_name": "test",
"client_uri": "https://example.org/",
"logo_uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==",
"redirect_uris": [
"https://example.org/callback",
"https://example.org/callback2",
"net.myapp:oauth-callback",
"net.myapp:///oauth-callback",
"http://127.0.0.8:8000/callback",
"http://[::1]:8000/callback"
],
"software_id": "10098d11-8b3f-4ebb-b519-cab2301975fa",
"software_version": "1.0.0",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"]
}`,
},
RequestTest{
Name: "client update",
Target: "/api/oauth/client/{{ (index .History 0).JSON.client_id }}",
Method: "PUT",
Headers: map[string]string{
"Authorization": "Bearer {{ (index .History 0).JSON.registration_access_token }}",
},
JSON: map[string]any{
"client_id": "{{ (index .History 0).JSON.client_id }}",
"client_name": "test",
"client_uri": "https://example.org/",
"logo_uri": "",
"redirect_uris": []string{"http://[::1]:8000/callback"},
"software_id": "10098d11-8b3f-4ebb-b519-cab2301975fa",
"software_version": "1.0.0",
"token_endpoint_auth_method": "none",
"grant_types": []string{"authorization_code"},
"response_types": []string{"code"},
},
ExpectStatus: 200,
ExpectJSON: `{
"client_id":"<<PRESENCE>>",
"registration_access_token": "<<PRESENCE>>",
"registration_client_uri": "<<PRESENCE>>",
"client_name":"test",
"client_uri":"https://example.org/",
"logo_uri":"",
"redirect_uris":[
"http://[::1]:8000/callback"
],
"software_id":"10098d11-8b3f-4ebb-b519-cab2301975fa",
"software_version":"1.0.0",
"token_endpoint_auth_method":"none",
"grant_types":["authorization_code"],
"response_types":["code"]
}`,
},
RequestTest{
Name: "client delete",
Target: "/api/oauth/client/{{ (index .History 0).JSON.client_id }}",
Method: "DELETE",
Headers: map[string]string{
"Authorization": "Bearer {{ (index .History 0).JSON.registration_access_token }}",
},
ExpectStatus: 204,
},
RequestTest{
Name: "client info",
Target: "/api/oauth/client/{{ (index .History 1).JSON.client_id }}",
Headers: map[string]string{
"Authorization": "Bearer {{ (index .History 1).JSON.registration_access_token }}",
},
ExpectStatus: 401,
},
)
}

View File

@@ -7,6 +7,8 @@
package tokens
import (
"database/sql/driver"
"encoding/json"
"errors"
"strings"
"time"
@@ -37,7 +39,7 @@ type Token struct {
ID int `db:"id" goqu:"skipinsert,skipupdate"`
UID string `db:"uid"`
UserID *int `db:"user_id"`
ClientID *int `db:"client_id"`
ClientInfo *ClientInfo `db:"client_info"`
Created time.Time `db:"created" goqu:"skipupdate"`
LastUsed *time.Time `db:"last_used"`
Expires *time.Time `db:"expires"`
@@ -158,6 +160,44 @@ func (t *Token) IsExpired() bool {
return time.Now().UTC().After(*t.Expires)
}
// ClientInfo contains a token's OAuth registered client.
type ClientInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Website string `json:"website"`
Logo string `json:"logo"`
SoftwareID string `json:"software_id"`
SoftwareVersion string `json:"software_version"`
GrantTypes []string `json:"grant_types"`
}
// Scan loads a UserSettings instance from a column.
func (s *ClientInfo) Scan(value any) error {
if value == nil {
return nil
}
v, err := types.JSONBytes(value)
if err != nil {
return err
}
json.Unmarshal(v, s) //nolint:errcheck
return nil
}
// Value encodes a UserSettings value for storage.
func (s *ClientInfo) Value() (driver.Value, error) {
if s == nil {
return nil, nil
}
v, err := json.Marshal(s)
if err != nil {
return "", err
}
return string(v), nil
}
// TokenAndUser is a result of a joint query on user and token tables.
type TokenAndUser struct {
Token *Token `db:"t"`

30
internal/bus/store.go Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package bus
import (
"encoding/json"
"time"
)
// SetJSON stores a value as a JSON string.
func SetJSON(key string, value any, expiration time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return store.Set(key, string(data), expiration)
}
// GetJSON retrieves a value as a JSON string. It returns [ErrNotExists]
// when the value was not in the store already.
func GetJSON(key string, value any) error {
data := store.Get(key)
if data == "" {
return nil
}
return json.Unmarshal([]byte(data), value)
}

View File

@@ -101,5 +101,5 @@ var migrationList = []migrationEntry{
newMigrationEntry(19, "bookmark_text_normalization", migrations.M19bookmarkTextNormalization),
newMigrationEntry(20, "bookmark_removed", applyMigrationFile("20_bookmark_removed.sql")),
newMigrationEntry(21, "token_roles", migrations.M21tokenRoles),
newMigrationEntry(22, "oauth2", applyMigrationFile("22_oauth2.sql")),
newMigrationEntry(23, "oauth2", migrations.M23oauth),
}

View File

@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package migrations
import (
"io/fs"
"github.com/doug-martin/goqu/v9"
)
// M23oauth adds a client_info column in the token table.
// It also takes care of removing the never released (but in nightly builds)
// oauth2_client table and the token.client_id column.
func M23oauth(db *goqu.TxDatabase, _ fs.FS) error {
var err error
// What should be the script without nightly clean-up
switch db.Dialect() {
case "sqlite3":
_, err = db.Exec(`ALTER TABLE token ADD COLUMN client_info json NULL`)
case "postgres":
_, err = db.Exec(`ALTER TABLE token ADD COLUMN client_info jsonb NULL`)
}
if err != nil {
return err
}
// Remove unreleased token.client_id column
var found bool
switch db.Dialect() {
case "sqlite3":
var cname string
found, err = db.ScanVal(&cname, `SELECT name FROM pragma_table_info('token') WHERE name = 'client_id'`)
if err != nil {
return err
}
case "postgres":
var cname string
found, err = db.ScanVal(&cname, `SELECT column_name FROM information_schema.columns WHERE table_name = 'token' AND column_name = 'client_id' `)
if err != nil {
return err
}
}
if found {
if _, err = db.Exec(`DELETE FROM token WHERE client_id IS NOT NULL`); err != nil {
return err
}
if _, err = db.Exec(`ALTER TABLE token DROP COLUMN client_id`); err != nil {
return err
}
}
// Clean up unreleased oauth2_client table
_, err = db.Exec(`DROP TABLE IF EXISTS oauth2_client`)
return err
}

View File

@@ -1,18 +0,0 @@
-- SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
--
-- SPDX-License-Identifier: AGPL-3.0-only
CREATE TABLE IF NOT EXISTS oauth2_client (
id SERIAL PRIMARY KEY,
uid varchar(32) UNIQUE NOT NULL,
created timestamptz NOT NULL,
name varchar(128) NOT NULL,
website varchar(256) NULL,
logo text NULL,
redirect_uris jsonb NOT NULL,
software_id varchar(128) NOT NULL,
software_version varchar(128) NOT NULL
);
ALTER TABLE token ADD COLUMN client_id integer NULL
CONSTRAINT fk_token_oauth2_client REFERENCES oauth2_client(id) ON DELETE CASCADE;

View File

@@ -21,32 +21,19 @@ CREATE TABLE IF NOT EXISTS "user" (
seed integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS oauth2_client (
id SERIAL PRIMARY KEY,
uid varchar(32) UNIQUE NOT NULL,
created timestamptz NOT NULL,
name varchar(128) NOT NULL,
website varchar(256) NULL,
logo text NULL,
redirect_uris jsonb NOT NULL,
software_id varchar(128) NOT NULL,
software_version varchar(128) NOT NULL
);
CREATE TABLE IF NOT EXISTS token (
id SERIAL PRIMARY KEY,
uid varchar(32) UNIQUE NOT NULL,
user_id integer NOT NULL,
client_id integer NULL,
created timestamptz NOT NULL,
last_used timestamptz NULL,
expires timestamptz NULL,
is_enabled boolean NOT NULL DEFAULT true,
application varchar(128) NOT NULL,
roles jsonb NOT NULL DEFAULT '[]',
client_info jsonb NULL,
CONSTRAINT fk_token_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE,
CONSTRAINT fk_token_oauth2_client FOREIGN KEY (client_id) REFERENCES oauth2_client(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "credential" (

View File

@@ -1,18 +0,0 @@
-- SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
--
-- SPDX-License-Identifier: AGPL-3.0-only
CREATE TABLE IF NOT EXISTS oauth2_client (
id integer PRIMARY KEY AUTOINCREMENT,
uid text UNIQUE NOT NULL,
created datetime NOT NULL,
name text NOT NULL,
website text NULL,
logo text NULL,
redirect_uris json NOT NULL,
software_id text NOT NULL,
software_version text NOT NULL
);
ALTER TABLE token ADD COLUMN client_id integer NULL
CONSTRAINT fk_token_oauth2_client REFERENCES oauth2_client(id) ON DELETE CASCADE;

View File

@@ -21,32 +21,19 @@ CREATE TABLE IF NOT EXISTS user (
seed integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS oauth2_client (
id integer PRIMARY KEY AUTOINCREMENT,
uid text UNIQUE NOT NULL,
created datetime NOT NULL,
name text NOT NULL,
website text NULL,
logo text NULL,
redirect_uris json NOT NULL,
software_id text NOT NULL,
software_version text NOT NULL
);
CREATE TABLE IF NOT EXISTS token (
id integer PRIMARY KEY AUTOINCREMENT,
uid text UNIQUE NOT NULL,
user_id integer NOT NULL,
client_id integer NULL,
created datetime NOT NULL,
last_used datetime NULL,
expires datetime NULL,
is_enabled integer NOT NULL DEFAULT 1,
application text NOT NULL,
roles json NOT NULL DEFAULT "",
client_info json NULL,
CONSTRAINT fk_token_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
CONSTRAINT fk_token_oauth2_client FOREIGN KEY (client_id) REFERENCES oauth2_client(id) ON DELETE CASCADE
CONSTRAINT fk_token_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS credential (

View File

@@ -13,7 +13,6 @@ import (
"github.com/go-chi/chi/v5"
"codeberg.org/readeck/readeck/internal/auth"
"codeberg.org/readeck/readeck/internal/auth/oauth2"
"codeberg.org/readeck/readeck/internal/auth/tokens"
"codeberg.org/readeck/readeck/internal/auth/users"
"codeberg.org/readeck/readeck/internal/db/scanner"
@@ -171,7 +170,6 @@ func (api *profileAPI) withTokenList(t tokenType) func(next http.Handler) http.H
}
ds := tokens.Tokens.Query().
LeftOuterJoin(goqu.T(oauth2.TableName).As("c"), goqu.On(goqu.I("c.id").Eq(goqu.I("t.client_id")))).
Where(
goqu.C("user_id").Eq(auth.GetRequestUser(r).ID),
).
@@ -184,9 +182,9 @@ func (api *profileAPI) withTokenList(t tokenType) func(next http.Handler) http.H
switch t {
case userToken:
ds = ds.Where(goqu.C("client_id").Is(nil))
ds = ds.Where(goqu.C("client_info").IsNull())
case clientToken:
ds = ds.Where(goqu.C("client_id").IsNot(nil))
ds = ds.Where(goqu.C("client_info").IsNotNull())
}
var res *tokenItemList
@@ -208,7 +206,6 @@ func (api *profileAPI) withToken(t tokenType) func(next http.Handler) http.Handl
uid := chi.URLParam(r, "uid")
ds := tokens.Tokens.Query().
LeftOuterJoin(goqu.T(oauth2.TableName).As("c"), goqu.On(goqu.I("c.id").Eq(goqu.I("t.client_id")))).
Where(
goqu.C("uid").Table("t").Eq(uid),
goqu.C("user_id").Eq(auth.GetRequestUser(r).ID),
@@ -216,12 +213,12 @@ func (api *profileAPI) withToken(t tokenType) func(next http.Handler) http.Handl
switch t {
case userToken:
ds = ds.Where(goqu.C("client_id").Is(nil))
ds = ds.Where(goqu.C("client_info").IsNull())
case clientToken:
ds = ds.Where(goqu.C("client_id").IsNot(nil))
ds = ds.Where(goqu.C("client_info").IsNotNull())
}
t := new(tokenAndClient)
t := new(tokens.Token)
found, err := ds.ScanStruct(t)
if !found {
@@ -257,17 +254,6 @@ func (api *profileAPI) tokenDelete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
type tokenAndClient struct {
*tokens.Token `db:"t"`
Client struct {
UID *string `db:"uid"`
Name *string `db:"name"`
URI *string `db:"website"`
Logo *string `db:"logo"`
Version *string `db:"software_version"`
} `db:"c"`
}
type tokenItemList struct {
Count int64
Pagination server.Pagination
@@ -277,25 +263,24 @@ type tokenItemList struct {
type tokenItem struct {
*tokens.Token `json:"-"`
ID string `json:"id"`
Href string `json:"href"`
Created time.Time `json:"created"`
LastUsed *time.Time `json:"last_used"`
Expires *time.Time `json:"expires"`
IsEnabled bool `json:"is_enabled"`
IsDeleted bool `json:"is_deleted"`
Roles []string `json:"roles"`
RoleNames []string `json:"-"`
ClientName string `json:"client_name"`
ClientURI string `json:"client_uri"`
ClientLogo string `json:"client_logo"`
ClientVersion string `json:"client_version"`
ID string `json:"id"`
Href string `json:"href"`
Created time.Time `json:"created"`
LastUsed *time.Time `json:"last_used"`
Expires *time.Time `json:"expires"`
IsEnabled bool `json:"is_enabled"`
IsDeleted bool `json:"is_deleted"`
Roles []string `json:"roles"`
RoleNames []string `json:"-"`
ClientName string `json:"client_name"`
ClientURI string `json:"client_uri"`
ClientLogo string `json:"client_logo"`
}
func newTokenItem(ctx context.Context, t *tokenAndClient) *tokenItem {
func newTokenItem(ctx context.Context, t *tokens.Token) *tokenItem {
tr := server.LocaleContext(ctx)
res := &tokenItem{
Token: t.Token,
Token: t,
ID: t.UID,
Href: urls.AbsoluteURLContext(ctx, ".", t.UID).String(),
Created: t.Created,
@@ -307,11 +292,10 @@ func newTokenItem(ctx context.Context, t *tokenAndClient) *tokenItem {
RoleNames: users.GroupNames(tr, t.Roles),
}
if t.Client.UID != nil {
res.ClientName = *t.Client.Name
res.ClientURI = *t.Client.URI
res.ClientLogo = *t.Client.Logo
res.ClientVersion = *t.Client.Version
if t.ClientInfo != nil && t.ClientInfo.ID != "" {
res.ClientName = t.ClientInfo.Name
res.ClientURI = t.ClientInfo.Website
res.ClientLogo = t.ClientInfo.Logo
}
return res