mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-04-13 11:57:33 -04:00
groupware: add OIDC authentication support between Groupware backend and Stalwart
* re-implement the auth-api service to authenticate Reva tokens following the OIDC Userinfo endpoint specification * pass the context where necessary and add an authenticator interface to the JMAP HTTP driver, in order to select between master authentication (which is used when GROUPWARE_JMAP_MASTER_USERNAME and GROUPWARE_JMAP_MASTER_PASSWORD are both set) and OIDC token forwarding through bearer auth * add Stalwart directory configuration "idmoidc" which uses the OpenCloud auth-api service API (/auth/) to validate the token it received as bearer auth from the Groupware backend's JMAP client, using it as an OIDC Userinfo endpoint * implement optional additional shared secret to secure the Userinfo service, as an additional path parameter
This commit is contained in:
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -137,8 +137,15 @@
|
||||
"OC_SERVICE_ACCOUNT_ID": "service-account-id",
|
||||
"OC_SERVICE_ACCOUNT_SECRET": "service-account-secret",
|
||||
|
||||
"OC_ADD_RUN_SERVICES": "groupware",
|
||||
"GROUPWARE_LOG_LEVEL": "trace"
|
||||
"OC_ADD_RUN_SERVICES": "auth-api,groupware",
|
||||
"GROUPWARE_LOG_LEVEL": "trace",
|
||||
|
||||
"GROUPWARE_JMAP_MASTER_USERNAME": "",
|
||||
"GROUPWARE_JMAP_MASTER_PASSWORD": "admin",
|
||||
|
||||
"AUTHAPI_HTTP_ADDR": "0.0.0.0:10000",
|
||||
"AUTHAPI_AUTH_REQUIRE_SHARED_SECRET": "true",
|
||||
"AUTHAPI_AUTH_SHARED_SECRETS": "stalwart=maethaR9eiXaiph8ahn8ohH6dahPiequ;unused=eeyaigh6hae1zo5ahGeete6oohaiquei",
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -311,11 +311,11 @@ KEYCLOAK_ADMIN_PASSWORD=
|
||||
# Domain of Stalwart
|
||||
# Defaults to "stalwart.opencloud.test"
|
||||
STALWART_DOMAIN=
|
||||
|
||||
# LDAP configuration to use for Stalwart:
|
||||
# Can either be either
|
||||
# - idmldap: for the built-in IDP/IDM
|
||||
# - ldap: when using KeyCloak and OpenLDAP
|
||||
# - idmldap: for the built-in IDP/IDM, using Master Authentication between Groupware and Stalwart, and LDAP in Stalwart
|
||||
# - idmoidc: built-in IDP/IDM, using OIDC Userinfo between Groupware and Stalwart
|
||||
# - ldap: when using KeyCloak and OpenLDAP, with Master Authentication between Groupware and Stalwart, and LDAP in Stalwart
|
||||
STALWART_AUTH_DIRECTORY=idmldap
|
||||
|
||||
## IMPORTANT ##
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.fallback-admin.user = "mailadmin"
|
||||
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.master.user = "master"
|
||||
directory.oidc.cache.size = 1048576
|
||||
directory.oidc.cache.ttl.negative = "10m"
|
||||
directory.oidc.cache.ttl.positive = "1h"
|
||||
directory.oidc.endpoint.method = "userinfo"
|
||||
directory.oidc.endpoint.url = "http://172.17.0.1:10000/auth/maethaR9eiXaiph8ahn8ohH6dahPiequ"
|
||||
directory.oidc.fields.email = "email"
|
||||
directory.oidc.fields.full-name = "name"
|
||||
directory.oidc.fields.username = "preferred_username"
|
||||
directory.oidc.timeout = "15s"
|
||||
directory.oidc.type = "oidc"
|
||||
http.allowed-endpoint = 200
|
||||
http.hsts = true
|
||||
http.permissive-cors = false
|
||||
http.url = "'https://' + config_get('server.hostname')"
|
||||
http.use-x-forwarded = true
|
||||
metrics.prometheus.auth.secret = "secret"
|
||||
metrics.prometheus.auth.username = "metrics"
|
||||
metrics.prometheus.enable = true
|
||||
server.listener.http.bind = "0.0.0.0:8080"
|
||||
server.listener.http.protocol = "http"
|
||||
server.listener.https.bind = "0.0.0.0:443"
|
||||
server.listener.https.protocol = "http"
|
||||
server.listener.https.tls.implicit = true
|
||||
server.listener.imap.bind = "0.0.0.0:143"
|
||||
server.listener.imap.protocol = "imap"
|
||||
server.listener.imaptls.bind = "0.0.0.0:993"
|
||||
server.listener.imaptls.protocol = "imap"
|
||||
server.listener.imaptls.tls.implicit = true
|
||||
server.listener.pop3.bind = "0.0.0.0:110"
|
||||
server.listener.pop3.protocol = "pop3"
|
||||
server.listener.pop3s.bind = "0.0.0.0:995"
|
||||
server.listener.pop3s.protocol = "pop3"
|
||||
server.listener.pop3s.tls.implicit = true
|
||||
server.listener.sieve.bind = "0.0.0.0:4190"
|
||||
server.listener.sieve.protocol = "managesieve"
|
||||
server.listener.smtp.bind = "0.0.0.0:25"
|
||||
server.listener.smtp.protocol = "smtp"
|
||||
server.listener.submission.bind = "0.0.0.0:587"
|
||||
server.listener.submission.protocol = "smtp"
|
||||
server.listener.submissions.bind = "0.0.0.0:465"
|
||||
server.listener.submissions.protocol = "smtp"
|
||||
server.listener.submissions.tls.implicit = true
|
||||
server.max-connections = 8192
|
||||
server.socket.backlog = 1024
|
||||
server.socket.nodelay = true
|
||||
server.socket.reuse-addr = true
|
||||
server.socket.reuse-port = true
|
||||
sharing.allow-directory-query = false
|
||||
storage.blob = "rocksdb"
|
||||
storage.data = "rocksdb"
|
||||
storage.directory = "oidc"
|
||||
storage.fts = "rocksdb"
|
||||
storage.lookup = "rocksdb"
|
||||
store.rocksdb.compression = "lz4"
|
||||
store.rocksdb.path = "/opt/stalwart/data"
|
||||
store.rocksdb.type = "rocksdb"
|
||||
tracer.console.ansi = true
|
||||
tracer.console.buffered = true
|
||||
tracer.console.enable = true
|
||||
tracer.console.level = "trace"
|
||||
tracer.console.lossy = false
|
||||
tracer.console.multiline = false
|
||||
tracer.console.type = "stdout"
|
||||
@@ -64,6 +64,8 @@ services:
|
||||
GROUPS_LDAP_BIND_PASSWORD: "admin"
|
||||
IDM_LDAPS_ADDR: 0.0.0.0:9235
|
||||
GROUPWARE_JMAP_BASE_URL: https://${STALWART_DOMAIN:-stalwart.opencloud.test}
|
||||
GROUPWARE_JMAP_MASTER_USERNAME: "master"
|
||||
GROUPWARE_JMAP_MASTER_PASSWORD: "admin"
|
||||
volumes:
|
||||
- ./config/opencloud/app-registry.yaml:/etc/opencloud/app-registry.yaml
|
||||
- ./config/opencloud/csp.yaml:/etc/opencloud/csp.yaml
|
||||
|
||||
4
go.mod
4
go.mod
@@ -7,9 +7,7 @@ require (
|
||||
github.com/CiscoM31/godata v1.0.11
|
||||
github.com/KimMachineGun/automemlimit v0.7.5
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/MicahParks/jwkset v0.8.0
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0
|
||||
github.com/MicahParks/keyfunc/v3 v3.3.11
|
||||
github.com/Nerzal/gocloak/v13 v13.9.0
|
||||
github.com/ProtonMail/go-crypto v1.1.6
|
||||
github.com/bbalet/stopwords v1.0.0
|
||||
@@ -103,7 +101,6 @@ require (
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/tus/tusd/v2 v2.9.2
|
||||
github.com/unrolled/secure v1.16.0
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wk8/go-ordered-map v1.0.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
@@ -383,6 +380,7 @@ require (
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||
github.com/trustelem/zxcvbn v1.0.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -80,12 +80,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
|
||||
github.com/MicahParks/jwkset v0.8.0 h1:jHtclI38Gibmu17XMI6+6/UB59srp58pQVxePHRK5o8=
|
||||
github.com/MicahParks/jwkset v0.8.0/go.mod h1:fVrj6TmG1aKlJEeceAz7JsXGTXEn72zP1px3us53JrA=
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
|
||||
github.com/MicahParks/keyfunc/v3 v3.3.11 h1:eA6wNltwdSRX2gtpTwZseBCC9nGeBkI9KxHtTyZbDbo=
|
||||
github.com/MicahParks/keyfunc/v3 v3.3.11/go.mod h1:y6Ed3dMgNKTcpxbaQHD8mmrYDUZWJAxteddA6OQj+ag=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
|
||||
@@ -89,6 +89,7 @@ type Config struct {
|
||||
AppProvider *appProvider.Config `yaml:"app_provider"`
|
||||
AppRegistry *appRegistry.Config `yaml:"app_registry"`
|
||||
Audit *audit.Config `yaml:"audit"`
|
||||
AuthApi *authapi.Config `yaml:"auth_api"`
|
||||
AuthApp *authapp.Config `yaml:"auth_app"`
|
||||
AuthBasic *authbasic.Config `yaml:"auth_basic"`
|
||||
AuthBearer *authbearer.Config `yaml:"auth_bearer"`
|
||||
@@ -126,5 +127,4 @@ type Config struct {
|
||||
WebDAV *webdav.Config `yaml:"webdav"`
|
||||
Webfinger *webfinger.Config `yaml:"webfinger"`
|
||||
Search *search.Config `yaml:"search"`
|
||||
AuthApi *authapi.Config `yaml:"authapi"`
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ type WsClient interface {
|
||||
}
|
||||
|
||||
type WsClientFactory interface {
|
||||
EnableNotifications(pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error)
|
||||
EnableNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
type SessionClient interface {
|
||||
GetSession(baseurl *url.URL, username string, logger *log.Logger) (SessionResponse, Error)
|
||||
GetSession(ctx context.Context, baseurl *url.URL, username string, logger *log.Logger) (SessionResponse, Error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package jmap
|
||||
|
||||
func (j *Client) EnablePushNotifications(pushState State, sessionProvider func() (*Session, error)) (WsClient, error) {
|
||||
return j.ws.EnableNotifications(pushState, sessionProvider, j)
|
||||
import "context"
|
||||
|
||||
func (j *Client) EnablePushNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error)) (WsClient, error) {
|
||||
return j.ws.EnableNotifications(ctx, pushState, sessionProvider, j)
|
||||
}
|
||||
|
||||
func (j *Client) AddWsPushListener(listener WsPushListener) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -55,8 +56,8 @@ func (j *Client) OnNotification(username string, stateChange StateChange) {
|
||||
}
|
||||
|
||||
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
|
||||
func (j *Client) FetchSession(sessionUrl *url.URL, username string, logger *log.Logger) (Session, Error) {
|
||||
wk, err := j.session.GetSession(sessionUrl, username, logger)
|
||||
func (j *Client) FetchSession(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (Session, Error) {
|
||||
wk, err := j.session.GetSession(ctx, sessionUrl, username, logger)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
|
||||
115
pkg/jmap/http.go
115
pkg/jmap/http.go
@@ -22,11 +22,10 @@ import (
|
||||
// Implementation of ApiClient, SessionClient and BlobClient that uses
|
||||
// HTTP to perform JMAP operations.
|
||||
type HttpJmapClient struct {
|
||||
client *http.Client
|
||||
masterUser string
|
||||
masterPassword string
|
||||
userAgent string
|
||||
listener HttpJmapApiClientEventListener
|
||||
client *http.Client
|
||||
userAgent string
|
||||
authenticator HttpJmapClientAuthenticator
|
||||
listener HttpJmapApiClientEventListener
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -49,12 +48,6 @@ const (
|
||||
logTypePush = "push"
|
||||
)
|
||||
|
||||
/*
|
||||
func bearer(req *http.Request, token string) {
|
||||
req.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte(token)))
|
||||
}
|
||||
*/
|
||||
|
||||
// Record JMAP HTTP execution events that may occur, e.g. using metrics.
|
||||
type HttpJmapApiClientEventListener interface {
|
||||
OnSuccessfulRequest(endpoint string, status int)
|
||||
@@ -86,18 +79,45 @@ func (l nullHttpJmapApiClientEventListener) OnFailedWsHandshakeRequestWithStatus
|
||||
|
||||
var _ HttpJmapApiClientEventListener = nullHttpJmapApiClientEventListener{}
|
||||
|
||||
type HttpJmapClientAuthenticator interface {
|
||||
Authenticate(ctx context.Context, username string, logger *log.Logger, req *http.Request) Error
|
||||
AuthenticateWS(ctx context.Context, username string, logger *log.Logger, headers http.Header) Error
|
||||
}
|
||||
|
||||
type MasterAuthHttpJmapClientAuthenticator struct {
|
||||
masterUser string
|
||||
masterPassword string
|
||||
}
|
||||
|
||||
func NewMasterAuthHttpJmapClientAuthenticator(masterUser string, masterPassword string) HttpJmapClientAuthenticator {
|
||||
return &MasterAuthHttpJmapClientAuthenticator{masterUser: masterUser, masterPassword: masterPassword}
|
||||
}
|
||||
|
||||
var _ HttpJmapClientAuthenticator = &MasterAuthHttpJmapClientAuthenticator{}
|
||||
|
||||
func (h *MasterAuthHttpJmapClientAuthenticator) Authenticate(ctx context.Context, username string, _ *log.Logger, req *http.Request) Error {
|
||||
masterUsername := username + "%" + h.masterUser
|
||||
req.SetBasicAuth(masterUsername, h.masterPassword)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MasterAuthHttpJmapClientAuthenticator) AuthenticateWS(ctx context.Context, username string, _ *log.Logger, headers http.Header) Error {
|
||||
masterUsername := username + "%" + h.masterUser
|
||||
headers.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(masterUsername+":"+h.masterPassword)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// An implementation of HttpJmapApiClientMetricsRecorder that does nothing.
|
||||
func NullHttpJmapApiClientEventListener() HttpJmapApiClientEventListener {
|
||||
return nullHttpJmapApiClientEventListener{}
|
||||
}
|
||||
|
||||
func NewHttpJmapClient(client *http.Client, masterUser string, masterPassword string, listener HttpJmapApiClientEventListener) *HttpJmapClient {
|
||||
func NewHttpJmapClient(client *http.Client, authenticator HttpJmapClientAuthenticator, listener HttpJmapApiClientEventListener) *HttpJmapClient {
|
||||
return &HttpJmapClient{
|
||||
client: client,
|
||||
masterUser: masterUser,
|
||||
masterPassword: masterPassword,
|
||||
userAgent: "OpenCloud/" + version.GetString(),
|
||||
listener: listener,
|
||||
client: client,
|
||||
authenticator: authenticator,
|
||||
userAgent: "OpenCloud/" + version.GetString(),
|
||||
listener: listener,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,17 +137,15 @@ func (e AuthenticationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) auth(username string, _ *log.Logger, req *http.Request) error {
|
||||
masterUsername := username + "%" + h.masterUser
|
||||
req.SetBasicAuth(masterUsername, h.masterPassword)
|
||||
return nil
|
||||
func (h *HttpJmapClient) auth(ctx context.Context, username string, logger *log.Logger, req *http.Request) Error {
|
||||
return h.authenticator.Authenticate(ctx, username, logger, req)
|
||||
}
|
||||
|
||||
var (
|
||||
errNilBaseUrl = errors.New("sessionUrl is nil")
|
||||
)
|
||||
|
||||
func (h *HttpJmapClient) GetSession(sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) {
|
||||
func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) {
|
||||
if sessionUrl == nil {
|
||||
logger.Error().Msg("sessionUrl is nil")
|
||||
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: errNilBaseUrl}
|
||||
@@ -147,7 +165,9 @@ func (h *HttpJmapClient) GetSession(sessionUrl *url.URL, username string, logger
|
||||
logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl)
|
||||
return SessionResponse{}, SimpleError{code: JmapErrorInvalidHttpRequest, err: err}
|
||||
}
|
||||
h.auth(username, logger, req)
|
||||
if err := h.auth(ctx, username, logger, req); err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
@@ -222,7 +242,9 @@ func (h *HttpJmapClient) Command(ctx context.Context, logger *log.Logger, sessio
|
||||
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes))
|
||||
}
|
||||
}
|
||||
h.auth(session.Username, logger, req)
|
||||
if err := h.auth(ctx, session.Username, logger, req); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -286,7 +308,9 @@ func (h *HttpJmapClient) UploadBinary(ctx context.Context, logger *log.Logger, s
|
||||
}
|
||||
}
|
||||
|
||||
h.auth(session.Username, logger, req)
|
||||
if err := h.auth(ctx, session.Username, logger, req); err != nil {
|
||||
return UploadedBlob{}, "", err
|
||||
}
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -357,7 +381,10 @@ func (h *HttpJmapClient) DownloadBinary(ctx context.Context, logger *log.Logger,
|
||||
logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).Msg(string(requestBytes))
|
||||
}
|
||||
}
|
||||
h.auth(session.Username, logger, req)
|
||||
|
||||
if err := h.auth(ctx, session.Username, logger, req); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -435,16 +462,15 @@ type WebSocketPushDisable struct {
|
||||
}
|
||||
|
||||
type HttpWsClientFactory struct {
|
||||
dialer *websocket.Dialer
|
||||
masterUser string
|
||||
masterPassword string
|
||||
logger *log.Logger
|
||||
eventListener HttpJmapApiClientEventListener
|
||||
dialer *websocket.Dialer
|
||||
authenticator HttpJmapClientAuthenticator
|
||||
logger *log.Logger
|
||||
eventListener HttpJmapApiClientEventListener
|
||||
}
|
||||
|
||||
var _ WsClientFactory = &HttpWsClientFactory{}
|
||||
|
||||
func NewHttpWsClientFactory(dialer *websocket.Dialer, masterUser string, masterPassword string, logger *log.Logger,
|
||||
func NewHttpWsClientFactory(dialer *websocket.Dialer, authenticator HttpJmapClientAuthenticator, logger *log.Logger,
|
||||
eventListener HttpJmapApiClientEventListener) (*HttpWsClientFactory, error) {
|
||||
// RFC 8887: Section 4.2:
|
||||
// Otherwise, the client MUST make an authenticated HTTP request [RFC7235] on the encrypted connection
|
||||
@@ -453,21 +479,18 @@ func NewHttpWsClientFactory(dialer *websocket.Dialer, masterUser string, masterP
|
||||
dialer.Subprotocols = []string{"jmap"}
|
||||
|
||||
return &HttpWsClientFactory{
|
||||
dialer: dialer,
|
||||
masterUser: masterUser,
|
||||
masterPassword: masterPassword,
|
||||
logger: logger,
|
||||
eventListener: eventListener,
|
||||
dialer: dialer,
|
||||
authenticator: authenticator,
|
||||
logger: logger,
|
||||
eventListener: eventListener,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) auth(username string, h http.Header) error {
|
||||
masterUsername := username + "%" + w.masterUser
|
||||
h.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(masterUsername+":"+w.masterPassword)))
|
||||
return nil
|
||||
func (w *HttpWsClientFactory) auth(ctx context.Context, username string, logger *log.Logger, h http.Header) Error {
|
||||
return w.authenticator.AuthenticateWS(ctx, username, logger, h)
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) connect(sessionProvider func() (*Session, error)) (*websocket.Conn, string, string, Error) {
|
||||
func (w *HttpWsClientFactory) connect(ctx context.Context, sessionProvider func() (*Session, error)) (*websocket.Conn, string, string, Error) {
|
||||
logger := w.logger
|
||||
|
||||
session, err := sessionProvider()
|
||||
@@ -486,10 +509,8 @@ func (w *HttpWsClientFactory) connect(sessionProvider func() (*Session, error))
|
||||
u := session.WebsocketUrl
|
||||
endpoint := session.WebsocketEndpoint
|
||||
|
||||
ctx := context.Background() // TODO WS connection context with a timeout?
|
||||
|
||||
h := http.Header{}
|
||||
w.auth(username, h)
|
||||
w.auth(ctx, username, logger, h)
|
||||
c, res, err := w.dialer.DialContext(ctx, u.String(), h)
|
||||
if err != nil {
|
||||
return nil, "", endpoint, SimpleError{code: JmapErrorFailedToEstablishWssConnection, err: err}
|
||||
@@ -582,8 +603,8 @@ func (w *HttpWsClient) readPump() {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) EnableNotifications(pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) {
|
||||
c, username, endpoint, jerr := w.connect(sessionProvider)
|
||||
func (w *HttpWsClientFactory) EnableNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) {
|
||||
c, username, endpoint, jerr := w.connect(ctx, sessionProvider)
|
||||
if jerr != nil {
|
||||
return nil, jerr
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func (s *StalwartTest) Close() error {
|
||||
}
|
||||
|
||||
func (s *StalwartTest) Session(username string) *Session {
|
||||
session, jerr := s.client.FetchSession(s.sessionUrl, username, s.logger)
|
||||
session, jerr := s.client.FetchSession(s.ctx, s.sessionUrl, username, s.logger)
|
||||
require.NoError(s.t, jerr)
|
||||
require.NotNil(s.t, session.Capabilities.Mail)
|
||||
require.NotNil(s.t, session.Capabilities.Calendars)
|
||||
@@ -328,14 +328,11 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
|
||||
|
||||
eventListener := nullHttpJmapApiClientEventListener{}
|
||||
|
||||
api := NewHttpJmapClient(
|
||||
&jh,
|
||||
masterUsername,
|
||||
masterPassword,
|
||||
eventListener,
|
||||
)
|
||||
auth := NewMasterAuthHttpJmapClientAuthenticator(masterUsername, masterPassword)
|
||||
|
||||
wscf, err := NewHttpWsClientFactory(wsd, masterUsername, masterPassword, logger, eventListener)
|
||||
api := NewHttpJmapClient(&jh, auth, eventListener)
|
||||
|
||||
wscf, err := NewHttpWsClientFactory(wsd, auth, logger, eventListener)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -455,7 +452,7 @@ func newStalwartTest(t *testing.T) (*StalwartTest, error) {
|
||||
{
|
||||
// check whether we can fetch a session for the provisioned users
|
||||
for _, user := range users {
|
||||
session, err := j.FetchSession(sessionUrl, user.name, logger)
|
||||
session, err := j.FetchSession(ctx, sessionUrl, user.name, logger)
|
||||
require.NoError(t, err, "failed to retrieve JMAP session for newly created principal '%s'", user.name)
|
||||
require.Equal(t, user.name, session.Username)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -60,6 +61,8 @@ func TestWs(t *testing.T) {
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := newStalwartTest(t)
|
||||
require.NoError(err)
|
||||
defer s.Close()
|
||||
@@ -111,7 +114,7 @@ func TestWs(t *testing.T) {
|
||||
require.Empty(changes.Updated)
|
||||
}
|
||||
|
||||
wsc, err := s.client.EnablePushNotifications(initialState, func() (*Session, error) { return session, nil })
|
||||
wsc, err := s.client.EnablePushNotifications(ctx, initialState, func() (*Session, error) { return session, nil })
|
||||
require.NoError(err)
|
||||
defer wsc.Close()
|
||||
|
||||
|
||||
329
services/auth-api/pkg/auth-api/authapi.go
Normal file
329
services/auth-api/pkg/auth-api/authapi.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package auth_api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
)
|
||||
|
||||
const defaultLeeway int64 = 5
|
||||
|
||||
type appId string
|
||||
|
||||
type AuthApi struct {
|
||||
mux *chi.Mux
|
||||
logger *log.Logger
|
||||
metrics *metrics.Metrics
|
||||
tracer oteltrace.Tracer
|
||||
parser *jwt.Parser
|
||||
keyFunc func(token *jwt.Token) (any, error)
|
||||
audiences []string
|
||||
requireSharedSecret bool
|
||||
sharedSecrets map[string]appId
|
||||
}
|
||||
|
||||
func parseSecrets(s string) (map[string]appId, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 0 {
|
||||
return map[string]appId{}, nil
|
||||
}
|
||||
result := map[string]appId{}
|
||||
for item := range strings.SplitSeq(s, ";") {
|
||||
item = strings.TrimSpace(item)
|
||||
parts := strings.Split(item, "=")
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
case 1:
|
||||
result[item] = appId("")
|
||||
case 2:
|
||||
result[parts[1]] = appId(parts[0])
|
||||
default:
|
||||
result[strings.Join(parts[1:], "=")] = appId(parts[0])
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func NewAuthApi(
|
||||
config *config.Config,
|
||||
logger *log.Logger,
|
||||
tracerProvider oteltrace.TracerProvider,
|
||||
metrics *metrics.Metrics,
|
||||
mux *chi.Mux,
|
||||
) (*AuthApi, error) {
|
||||
jwtSecret := []byte(config.TokenManager.JWTSecret)
|
||||
parser := jwt.NewParser(jwt.WithAudience("reva"), jwt.WithLeeway(time.Duration(defaultLeeway)*time.Second))
|
||||
|
||||
var tracer oteltrace.Tracer
|
||||
if tracerProvider != nil {
|
||||
tracer = tracerProvider.Tracer("instrumentation/" + config.HTTP.Namespace + "/" + config.Service.Name)
|
||||
}
|
||||
|
||||
metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
sharedSecrets, err := parseSecrets(config.Auth.SharedSecrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthApi{
|
||||
mux: mux,
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
tracer: tracer,
|
||||
parser: parser,
|
||||
keyFunc: func(token *jwt.Token) (any, error) { return jwtSecret, nil },
|
||||
audiences: config.Auth.Audiences,
|
||||
requireSharedSecret: config.Auth.RequireSharedSecret,
|
||||
sharedSecrets: sharedSecrets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthApi) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
a.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *AuthApi) Route(r chi.Router) {
|
||||
r.Get("/", a.Unauthenticated)
|
||||
r.Post("/", a.Unauthenticated)
|
||||
r.Get("/{secret}", a.Authenticated)
|
||||
r.Post("/{secret}", a.Authenticated)
|
||||
}
|
||||
|
||||
type StalwartClaims struct {
|
||||
Audience []string `json:"aud"`
|
||||
Subject string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"preferred_username"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Email string `json:"email"`
|
||||
Expires *jwt.NumericDate `json:"exp"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitzero"`
|
||||
}
|
||||
|
||||
func invalidRequest(w http.ResponseWriter) {
|
||||
w.Header().Add("WWW-Authenticate", `Bearer error="invalid_request"`)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func invalidToken(w http.ResponseWriter) {
|
||||
w.Header().Add("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (a *AuthApi) unsupportedAuth() {
|
||||
a.metrics.Attempts.WithLabelValues(
|
||||
metrics.AttemptFailureOutcome,
|
||||
metrics.UnsupportedType,
|
||||
).Inc()
|
||||
}
|
||||
func (a *AuthApi) failedAuth(duration time.Duration) {
|
||||
a.metrics.Attempts.WithLabelValues(
|
||||
metrics.AttemptFailureOutcome,
|
||||
metrics.BearerType,
|
||||
).Inc()
|
||||
a.metrics.Duration.WithLabelValues(
|
||||
metrics.AttemptFailureOutcome,
|
||||
).Observe(duration.Seconds())
|
||||
}
|
||||
func (a *AuthApi) succeededAuth(duration time.Duration) {
|
||||
a.metrics.Attempts.WithLabelValues(
|
||||
metrics.AttemptSuccessOutcome,
|
||||
metrics.BearerType,
|
||||
).Inc()
|
||||
a.metrics.Duration.WithLabelValues(
|
||||
metrics.AttemptSuccessOutcome,
|
||||
).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
func (a *AuthApi) Unauthenticated(w http.ResponseWriter, r *http.Request) {
|
||||
if a.requireSharedSecret {
|
||||
a.unsupportedAuth()
|
||||
a.logger.Warn().Str("reason", "missing-shared-secret").Msgf("authentication failure: request did not provide a required shared secret")
|
||||
invalidRequest(w)
|
||||
return
|
||||
} else {
|
||||
a.authenticate(w, r, "")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthApi) Authenticated(w http.ResponseWriter, r *http.Request) {
|
||||
secret := chi.URLParam(r, "secret")
|
||||
if app, ok := a.sharedSecrets[secret]; ok {
|
||||
a.authenticate(w, r, app)
|
||||
} else {
|
||||
a.unsupportedAuth()
|
||||
a.logger.Warn().Str("reason", "invalid-shared-secret").Msgf("authentication failure: request did not provide a valid shared secret")
|
||||
invalidRequest(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthApi) authenticate(w http.ResponseWriter, r *http.Request, app appId) {
|
||||
start := time.Now()
|
||||
logger := a.logger
|
||||
if app != "" {
|
||||
logger = log.From(logger.With().Str("app", log.SafeString(string(app))))
|
||||
}
|
||||
|
||||
var span oteltrace.Span = nil
|
||||
if a.tracer != nil {
|
||||
_, span = a.tracer.Start(r.Context(), "authenticate")
|
||||
defer span.End()
|
||||
}
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
a.unsupportedAuth()
|
||||
logger.Warn().Str("reason", "missing-authorization-header").Msgf("authentication failure: missing 'Authorization' header")
|
||||
invalidRequest(w)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
a.unsupportedAuth()
|
||||
logger.Warn().Str("reason", "authorization-header-not-bearer").Msgf("authentication failure: 'Authorization' header does not start with 'Bearer '")
|
||||
invalidRequest(w)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := auth[len("Bearer "):]
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := a.parser.ParseWithClaims(tokenStr, claims, a.keyFunc)
|
||||
//token, _, err := jwt.NewParser().ParseUnverified(tokenStr, claims)
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-parsing-failed").Msgf("authentication failure: failed to parse token")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
if !token.Valid {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-invalid").Msgf("authentication failure: the token is invalid")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := claims["user"].(map[string]any)
|
||||
if !ok {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-missing-user").Msgf("authentication failure: token has no 'user' claim")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
id, ok := user["id"].(map[string]any)
|
||||
if !ok {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-missing-id").Msgf("authentication failure: token has no 'id' attribute in the 'user' claim")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
opaqueId, ok := id["opaque_id"].(string)
|
||||
if !ok {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-missing-id-opaqueid").Msgf("authentication failure: token has no 'id/opaque_id' attribute in the 'user' claim")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
username, ok := user["username"].(string)
|
||||
if !ok {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-missing-username").Msgf("authentication failure: token has no 'username' attribute in the 'user' claim")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
displayName, ok := user["display_name"].(string)
|
||||
if !ok {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-missing-displayname").Msgf("authentication failure: token has no 'display_name' attribute in the 'user' claim")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
mail, ok := user["mail"].(string)
|
||||
if !ok {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-missing-mail").Msgf("authentication failure: token has no 'mail' attribute in the 'user' claim")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
|
||||
exp, err := token.Claims.GetExpirationTime()
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-invalid-exp").Msgf("authentication failure: token has invalid 'exp'")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
iat, err := token.Claims.GetIssuedAt()
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-invalid-iat").Msgf("authentication failure: token has invalid 'iat'")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
nbf, err := token.Claims.GetNotBefore()
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-invalid-nbf").Msgf("authentication failure: token has invalid 'nbf'")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
iss, err := token.Claims.GetIssuer()
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-invalid-iss").Msgf("authentication failure: token has invalid 'iss'")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
aud, err := claims.GetAudience()
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "token-invalid-aud").Msgf("authentication failure: token has invalid 'aud'")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
|
||||
audiences := aud
|
||||
if len(a.audiences) > 0 {
|
||||
audiences = slices.Concat(aud, a.audiences)
|
||||
}
|
||||
|
||||
rc := StalwartClaims{
|
||||
Audience: audiences,
|
||||
Subject: opaqueId,
|
||||
Name: displayName,
|
||||
Username: username,
|
||||
Email: mail,
|
||||
Expires: exp,
|
||||
IssuedAt: iat,
|
||||
NotBefore: nbf,
|
||||
Issuer: iss,
|
||||
}
|
||||
response, err := json.Marshal(rc)
|
||||
if err != nil {
|
||||
a.failedAuth(time.Since(start))
|
||||
logger.Warn().Err(err).Str("reason", "response-serialization-failure").Msgf("authentication failure: failed to serialize response")
|
||||
invalidToken(w)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug().Str("username", username).Msg("successfully authenticated token")
|
||||
a.succeededAuth(time.Since(start))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(response)
|
||||
}
|
||||
40
services/auth-api/pkg/auth-api/authapi_test.go
Normal file
40
services/auth-api/pkg/auth-api/authapi_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth_api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSecrets(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
{
|
||||
m, err := parseSecrets("")
|
||||
require.NoError(err)
|
||||
require.Empty(m)
|
||||
}
|
||||
{
|
||||
m, err := parseSecrets("app=123")
|
||||
require.NoError(err)
|
||||
require.Len(m, 1)
|
||||
require.Contains(m, "123")
|
||||
require.Equal(appId("app"), m["123"])
|
||||
}
|
||||
{
|
||||
m, err := parseSecrets("app1=123;app2=23456")
|
||||
require.NoError(err)
|
||||
require.Len(m, 2)
|
||||
require.Contains(m, "123")
|
||||
require.Equal(appId("app1"), m["123"])
|
||||
require.Contains(m, "23456")
|
||||
require.Equal(appId("app2"), m["23456"])
|
||||
}
|
||||
{
|
||||
m, err := parseSecrets("app=123=456")
|
||||
require.NoError(err)
|
||||
require.Len(m, 1)
|
||||
require.Contains(m, "123=456")
|
||||
require.Equal(appId("app"), m["123=456"])
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,10 @@ import (
|
||||
|
||||
"github.com/oklog/run"
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
"github.com/opencloud-eu/opencloud/pkg/tracing"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/parser"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/logging"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/server/debug"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/server/http"
|
||||
|
||||
@@ -28,16 +27,18 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := logging.Configure(cfg.Service.Name, cfg.Log)
|
||||
|
||||
tracerProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
gr = run.Group{}
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
m = metrics.New()
|
||||
)
|
||||
|
||||
defer cancel()
|
||||
|
||||
m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
server, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Config(cfg),
|
||||
@@ -54,11 +55,10 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
})
|
||||
|
||||
httpServer, err := http.Server(
|
||||
http.Logger(logger),
|
||||
http.Context(ctx),
|
||||
http.Config(cfg),
|
||||
http.Metrics(m),
|
||||
http.Namespace(cfg.HTTP.Namespace),
|
||||
&logger,
|
||||
ctx,
|
||||
cfg,
|
||||
tracerProvider,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().
|
||||
|
||||
7
services/auth-api/pkg/config/auth.go
Normal file
7
services/auth-api/pkg/config/auth.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
type Auth struct {
|
||||
Audiences []string `yaml:"auds" env:"AUTHAPI_AUTH_AUDS" desc:"Additional audiences to inject into the userinfo response claims" introductionVersion:"1.0.0"`
|
||||
RequireSharedSecret bool `yaml:"require_shared_secret" env:"AUTHAPI_AUTH_REQUIRE_SHARED_SECRET" desc:"Whether to require a shared secret or not" introductionVersion:"1.0.0"`
|
||||
SharedSecrets string `yaml:"shared_secrets" env:"AUTHAPI_AUTH_SHARED_SECRETS" desc:"Shared secret values" introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -17,11 +17,9 @@ type Config struct {
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Authentication AuthenticationAPI `yaml:"authentication_api"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
type AuthenticationAPI struct {
|
||||
JwkEndpoint string `yaml:"jwk_endpoint"`
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
Auth Auth `yaml:"auth"`
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ func DefaultConfig() *config.Config {
|
||||
Service: config.Service{
|
||||
Name: "auth-api",
|
||||
},
|
||||
Authentication: config.AuthenticationAPI{
|
||||
JwkEndpoint: "https://keycloak.opencloud.test/realms/openCloud/protocol/openid-connect/certs",
|
||||
Auth: config.Auth{
|
||||
Audiences: []string{"stalwart"},
|
||||
RequireSharedSecret: false,
|
||||
SharedSecrets: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -50,10 +52,16 @@ func EnsureDefaults(cfg *config.Config) {
|
||||
} else if cfg.Log == nil {
|
||||
cfg.Log = &config.Log{}
|
||||
}
|
||||
|
||||
if cfg.Commons != nil {
|
||||
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
|
||||
}
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitized the configuration
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
|
||||
occfg "github.com/opencloud-eu/opencloud/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/defaults"
|
||||
|
||||
@@ -34,6 +35,10 @@ func ParseConfig(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
// Validate can validate the configuration
|
||||
func Validate(_ *config.Config) error {
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
6
services/auth-api/pkg/config/reva.go
Normal file
6
services/auth-api/pkg/config/reva.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;AUTHAPI_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
@@ -27,10 +30,7 @@ const (
|
||||
AttemptFailureOutcome = "failure"
|
||||
)
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New(opts ...Option) *Metrics {
|
||||
options := newOptions(opts...)
|
||||
|
||||
func New(logger *log.Logger) (*Metrics, error) {
|
||||
m := &Metrics{
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
@@ -43,32 +43,35 @@ func New(opts ...Option) *Metrics {
|
||||
Subsystem: Subsystem,
|
||||
Name: "authentication_duration_seconds",
|
||||
Help: "Authentication processing time in seconds",
|
||||
}, []string{"type"}),
|
||||
}, []string{"outcome"}),
|
||||
Attempts: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "athentication_attempts_total",
|
||||
Name: "authentication_attempts_total",
|
||||
Help: "How many authentication attempts were processed",
|
||||
}, []string{"outcome"}),
|
||||
}, []string{"outcome", "type"}),
|
||||
}
|
||||
|
||||
if err := prometheus.Register(m.BuildInfo); err != nil {
|
||||
options.Logger.Error().
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("metric", "BuildInfo").
|
||||
Msg("Failed to register prometheus metric")
|
||||
return nil, err
|
||||
}
|
||||
if err := prometheus.Register(m.Duration); err != nil {
|
||||
options.Logger.Error().
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("metric", "Duration").
|
||||
Msg("Failed to register prometheus metric")
|
||||
return nil, err
|
||||
}
|
||||
if err := prometheus.Register(m.Attempts); err != nil {
|
||||
options.Logger.Error().
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("metric", "Attempts").
|
||||
Msg("Failed to register prometheus metric")
|
||||
return nil, err
|
||||
}
|
||||
return m
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ func Server(opts ...Option) (*http.Server, error) {
|
||||
WithLogger(options.Logger)
|
||||
|
||||
return debug.NewService(
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
|
||||
@@ -5,10 +5,6 @@ import (
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
@@ -20,9 +16,6 @@ type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Metrics *metrics.Metrics
|
||||
Flags []cli.Flag
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
@@ -57,13 +50,6 @@ func Config(val *config.Config) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace provides a function to set the Namespace option.
|
||||
func Namespace(val string) Option {
|
||||
return func(o *Options) {
|
||||
@@ -71,13 +57,3 @@ func Namespace(val string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to configure the trace provider
|
||||
func TraceProvider(traceProvider trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
if traceProvider != nil {
|
||||
o.TraceProvider = traceProvider
|
||||
} else {
|
||||
o.TraceProvider = noop.NewTracerProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,70 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
opencloudmiddleware "github.com/opencloud-eu/opencloud/pkg/middleware"
|
||||
"github.com/opencloud-eu/opencloud/pkg/service/http"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
svc "github.com/opencloud-eu/opencloud/services/auth-api/pkg/service/http/v0"
|
||||
"go-micro.dev/v4"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
func Server(
|
||||
logger *log.Logger,
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
traceProvider oteltrace.TracerProvider,
|
||||
) (http.Service, error) {
|
||||
service, err := http.NewService(
|
||||
http.TLSConfig(options.Config.HTTP.TLS),
|
||||
http.Logger(options.Logger),
|
||||
http.Name(options.Config.Service.Name),
|
||||
http.TLSConfig(cfg.HTTP.TLS),
|
||||
http.Logger(*logger),
|
||||
http.Name(cfg.Service.Name),
|
||||
http.Version(version.GetString()),
|
||||
http.Namespace(options.Config.HTTP.Namespace),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Context(options.Context),
|
||||
http.TraceProvider(options.TraceProvider),
|
||||
http.Namespace(cfg.HTTP.Namespace),
|
||||
http.Address(cfg.HTTP.Addr),
|
||||
http.Context(ctx),
|
||||
http.TraceProvider(traceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing http service")
|
||||
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
|
||||
}
|
||||
|
||||
met, err := metrics.New(logger)
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
handle, err := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.Config(options.Config),
|
||||
svc.Metrics(options.Metrics),
|
||||
svc.TraceProvider(options.TraceProvider),
|
||||
svc.Middleware(
|
||||
middleware.RealIP,
|
||||
middleware.RequestID,
|
||||
opencloudmiddleware.Version(
|
||||
options.Config.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
opencloudmiddleware.Logger(options.Logger),
|
||||
logger,
|
||||
met,
|
||||
traceProvider,
|
||||
cfg,
|
||||
middleware.RealIP,
|
||||
middleware.RequestID,
|
||||
opencloudmiddleware.Version(
|
||||
cfg.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
opencloudmiddleware.Logger(*logger),
|
||||
)
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
{
|
||||
handle = svc.NewInstrument(handle, options.Metrics)
|
||||
handle = svc.NewLogging(handle, options.Logger)
|
||||
//handle = svc.NewInstrument(handle, options.Metrics)
|
||||
//handle = svc.NewLogging(handle, options.Logger)
|
||||
}
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
)
|
||||
|
||||
// NewInstrument returns a service that instruments metrics.
|
||||
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
|
||||
return instrument{
|
||||
next: next,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
type instrument struct {
|
||||
next Service
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
// NewLogging returns a service that logs messages.
|
||||
func NewLogging(next Service, logger log.Logger) Service {
|
||||
return logging{
|
||||
next: next,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type logging struct {
|
||||
next Service
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
Metrics *metrics.Metrics
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware provides a function to set the middleware option.
|
||||
func Middleware(val ...func(http.Handler) http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Middleware = val
|
||||
}
|
||||
}
|
||||
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = tp
|
||||
}
|
||||
}
|
||||
|
||||
func Metrics(m *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = m
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,16 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/jwkset"
|
||||
"github.com/MicahParks/keyfunc/v3"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/riandyrn/otelchi"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/tracing"
|
||||
auth_api "github.com/opencloud-eu/opencloud/services/auth-api/pkg/auth-api"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/auth-api/pkg/metrics"
|
||||
)
|
||||
@@ -29,238 +21,33 @@ type Service interface {
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
func NewService(opts ...Option) (Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
m := chi.NewMux()
|
||||
m.Use(options.Middleware...)
|
||||
|
||||
m.Use(
|
||||
otelchi.Middleware(
|
||||
"auth-api",
|
||||
otelchi.WithChiRoutes(m),
|
||||
otelchi.WithTracerProvider(options.TraceProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
otelchi.WithTraceResponseHeaders(otelchi.TraceHeaderConfig{}),
|
||||
),
|
||||
)
|
||||
|
||||
svc, err := NewAuthenticationApi(options.Config, &options.Logger, options.Metrics, options.TraceProvider, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Post("/", svc.Authenticate)
|
||||
})
|
||||
|
||||
_ = chi.Walk(m, func(method string, route string, _ http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
type AuthenticationApi struct {
|
||||
config *config.Config
|
||||
logger *log.Logger
|
||||
metrics *metrics.Metrics
|
||||
tracer oteltrace.Tracer
|
||||
mux *chi.Mux
|
||||
refreshCtx context.Context
|
||||
jwksFunc keyfunc.Keyfunc
|
||||
}
|
||||
|
||||
func NewAuthenticationApi(
|
||||
config *config.Config,
|
||||
func NewService(
|
||||
logger *log.Logger,
|
||||
metrics *metrics.Metrics,
|
||||
tracerProvider oteltrace.TracerProvider,
|
||||
mux *chi.Mux,
|
||||
) (*AuthenticationApi, error) {
|
||||
config *config.Config,
|
||||
middlewares ...func(http.Handler) http.Handler) (Service, error) {
|
||||
m := chi.NewMux()
|
||||
|
||||
tracer := tracerProvider.Tracer("instrumentation/" + config.HTTP.Namespace + "/" + config.Service.Name)
|
||||
mwlist := []func(http.Handler) http.Handler{}
|
||||
mwlist = append(mwlist, middlewares...)
|
||||
o := otelchi.Middleware(
|
||||
"auth-api",
|
||||
otelchi.WithChiRoutes(m),
|
||||
otelchi.WithTracerProvider(tracerProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
otelchi.WithTraceResponseHeaders(otelchi.TraceHeaderConfig{}),
|
||||
)
|
||||
mwlist = append(mwlist, o)
|
||||
|
||||
var httpClient *http.Client
|
||||
{
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.ResponseHeaderTimeout = time.Duration(10) * time.Second
|
||||
tlsConfig := &tls.Config{InsecureSkipVerify: true}
|
||||
tr.TLSClientConfig = tlsConfig
|
||||
h := http.DefaultClient
|
||||
h.Transport = tr
|
||||
httpClient = h
|
||||
}
|
||||
m.Use(mwlist...)
|
||||
|
||||
refreshCtx := context.Background()
|
||||
|
||||
storage, err := jwkset.NewStorageFromHTTP(config.Authentication.JwkEndpoint, jwkset.HTTPClientStorageOptions{
|
||||
Client: httpClient,
|
||||
Ctx: refreshCtx,
|
||||
HTTPExpectedStatus: http.StatusOK,
|
||||
HTTPMethod: http.MethodGet,
|
||||
HTTPTimeout: time.Duration(10) * time.Second,
|
||||
NoErrorReturnFirstHTTPReq: true,
|
||||
RefreshInterval: time.Duration(10) * time.Minute,
|
||||
RefreshErrorHandler: func(ctx context.Context, err error) {
|
||||
logger.Error().Err(err).Ctx(ctx).Str("url", config.Authentication.JwkEndpoint).Msg("failed to refresh JWK Set from IDP")
|
||||
},
|
||||
//ValidateOptions: jwkset.JWKValidateOptions{},
|
||||
})
|
||||
authApi, err := auth_api.NewAuthApi(config, logger, tracerProvider, metrics, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jwksFunc, err := keyfunc.New(keyfunc.Options{
|
||||
Ctx: refreshCtx,
|
||||
UseWhitelist: []jwkset.USE{jwkset.UseSig},
|
||||
Storage: storage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Route(config.HTTP.Root, authApi.Route)
|
||||
|
||||
return &AuthenticationApi{
|
||||
config: config,
|
||||
mux: mux,
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
tracer: tracer,
|
||||
refreshCtx: refreshCtx,
|
||||
jwksFunc: jwksFunc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a AuthenticationApi) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
a.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type SuccessfulAuthResponse struct {
|
||||
Subject string `json:"subject"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
func (SuccessfulAuthResponse) Render(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type FailedAuthResponse struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (FailedAuthResponse) Render(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type CustomClaims struct {
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
AuthorizedParties jwt.ClaimStrings `json:"azp,omitempty"`
|
||||
SessionId string `json:"sid,omitempty"`
|
||||
AuthenticationContextClassReference string `json:"acr,omitempty"`
|
||||
Scope jwt.ClaimStrings `json:"scope,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Groups jwt.ClaimStrings `json:"groups,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
var authRegex = regexp.MustCompile(`(?i)^(Basic|Bearer)\s+(.+)$`)
|
||||
|
||||
func (a AuthenticationApi) failedAuth() {
|
||||
a.metrics.Attempts.WithLabelValues(metrics.OutcomeLabel, metrics.AttemptFailureOutcome).Inc()
|
||||
}
|
||||
func (a AuthenticationApi) succeededAuth() {
|
||||
a.metrics.Attempts.WithLabelValues(metrics.OutcomeLabel, metrics.AttemptSuccessOutcome).Inc()
|
||||
}
|
||||
|
||||
func (a AuthenticationApi) Authenticate(w http.ResponseWriter, r *http.Request) {
|
||||
_, span := a.tracer.Start(r.Context(), "authenticate")
|
||||
defer span.End()
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
a.logger.Warn().Msg("missing Authorization header")
|
||||
w.WriteHeader(http.StatusBadRequest) // authentication header is missing altogether
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Missing Authorization header"})
|
||||
a.failedAuth()
|
||||
return
|
||||
}
|
||||
matches := authRegex.FindStringSubmatch(auth)
|
||||
if matches == nil || len(matches) != 3 {
|
||||
a.logger.Warn().Msg("unsupported Authorization header")
|
||||
w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Unsupported Authorization header"})
|
||||
a.failedAuth()
|
||||
return
|
||||
}
|
||||
|
||||
if matches[1] == "Basic" {
|
||||
span.SetAttributes(attribute.String("authenticate.scheme", "basic"))
|
||||
a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.BasicType).Inc()
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
a.logger.Warn().Msg("failed to decode basic credentials")
|
||||
w.WriteHeader(http.StatusBadRequest) // failed to decode the basic credentials
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to decode basic credentials"})
|
||||
a.failedAuth()
|
||||
return
|
||||
}
|
||||
if password == "secret" {
|
||||
_ = render.Render(w, r, SuccessfulAuthResponse{Subject: username})
|
||||
a.succeededAuth()
|
||||
} else {
|
||||
a.logger.Info().Str("username", username).Msg("authentication failed")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Unauthorized credentials"})
|
||||
a.failedAuth()
|
||||
return
|
||||
}
|
||||
} else if matches[1] == "Bearer" {
|
||||
span.SetAttributes(attribute.String("authenticate.scheme", "bearer"))
|
||||
a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.BearerType).Inc()
|
||||
|
||||
claims := &CustomClaims{}
|
||||
tokenString := matches[2]
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, a.jwksFunc.Keyfunc, jwt.WithExpirationRequired(), jwt.WithLeeway(5*time.Second))
|
||||
if err != nil {
|
||||
a.logger.Warn().Err(err).Msg("failed to parse bearer token")
|
||||
w.WriteHeader(http.StatusBadRequest) // failed to parse bearer token
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to parse bearer token"})
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info().Str("type", matches[1]).Interface("header", token.Header).Interface("claims", token.Claims).Bool("valid", token.Valid).Msgf("successfully parsed token")
|
||||
|
||||
if typedClaims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
|
||||
sub := typedClaims.PreferredUsername
|
||||
if sub == "" {
|
||||
sub, err = typedClaims.GetSubject()
|
||||
if err != nil {
|
||||
a.logger.Warn().Err(err).Msg("failed to retrieve sub claim from token")
|
||||
w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to extract sub claim from bearer token"})
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = render.Render(w, r, SuccessfulAuthResponse{Subject: sub, Roles: claims.Roles})
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest) // failed to extract sub claim from bearer token
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Failed to parse bearer token"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
a.metrics.Attempts.WithLabelValues(metrics.TypeLabel, metrics.UnsupportedType).Inc()
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest) // authentication header is unsupported
|
||||
_ = render.Render(w, r, FailedAuthResponse{Reason: "Unsupported Authorization type"})
|
||||
return
|
||||
}
|
||||
return authApi, nil
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegex(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
matches := authRegex.FindStringSubmatch("Basic abc")
|
||||
require.NotNil(matches)
|
||||
require.Len(matches, 3)
|
||||
require.Equal("Basic", matches[1])
|
||||
require.Equal("abc", matches[2])
|
||||
}
|
||||
@@ -79,16 +79,21 @@ done \
|
||||
|
||||
### Compose
|
||||
|
||||
There are two options, either
|
||||
There are four options, either
|
||||
|
||||
1. running the Groupware backend with OpenLDAP and Keycloak containers, more akin to a production setup;
|
||||
2. running the Groupware backend using the built-in LDAP and OIDC services, for a minimalistic setup that uses less resources and is more likely to be found in a home lab setup.
|
||||
1. running the Groupware backend with OpenLDAP and Keycloak containers, along with master authentication between the Groupware backend and Stalwart, more akin to a production setup;
|
||||
2. running the Groupware backend with OpenLDAP and Keycloak containers, along with OIDC Bearer token authentication between the Groupware backend and Stalwart, even more akin to a production setup;
|
||||
3. running the Groupware backend using the built-in LDAP and OIDC services, along with master authentication between the Groupware backend and Stalwart, for a minimalistic setup that uses less resources and is more likely to be found in a home lab setup;
|
||||
4. running the Groupware backend using the built-in LDAP and OIDC services, along with OIDC Bearer token authentication between the Groupware backend and Stalwart.
|
||||
|
||||
> [!NOTE]
|
||||
> Note that option 2 is currently not implemented yet.
|
||||
|
||||
In either case, the Docker Compose configuration in `$OCDIR/opencloud/devtools/deployments/opencloud_full/` needs to be modified.
|
||||
|
||||
#### Production Setup
|
||||
#### Production Setup with Master Authentication
|
||||
|
||||
<a name="prod-setup"></a>
|
||||
<a name="prod-setup-master"></a>
|
||||
|
||||
```mermaid
|
||||
---
|
||||
@@ -109,7 +114,7 @@ flowchart LR
|
||||
c --> kc
|
||||
```
|
||||
|
||||
Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the following changes (make sure to check out [the shell command-line that automates all of that, below](#automate-env-setup-prod)):
|
||||
Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the following changes (make sure to check out [the shell command-line that automates all of that, below](#automate-env-setup-prod-master)):
|
||||
|
||||
* change the container image to `opencloudeu/opencloud:dev`:
|
||||
|
||||
@@ -170,7 +175,7 @@ Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the fol
|
||||
+#EXTERNALSITES=:web_extensions/externalsites.yml
|
||||
```
|
||||
|
||||
<a name="automate-env-setup-prod"></a>
|
||||
<a name="automate-env-setup-prod-master"></a>
|
||||
All those changes above can be automated with the following script:
|
||||
|
||||
```bash
|
||||
@@ -193,9 +198,10 @@ perl -pi -e '
|
||||
' .env
|
||||
```
|
||||
|
||||
#### Homelab Setup
|
||||
#### Homelab Setup with Master Authentication
|
||||
|
||||
<a name="homelab-setup"></a>
|
||||
<a name="homelab-setup-master"></a>
|
||||
|
||||
```mermaid
|
||||
---
|
||||
@@ -212,7 +218,7 @@ flowchart LR
|
||||
|
||||
```
|
||||
|
||||
Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the following changes (make sure to check out [the shell command-line that automates all of that, below](#automate-env-setup-homelab)):
|
||||
Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the following changes (make sure to check out [the shell command-line that automates all of that, below](#automate-env-setup-homelab-master)):
|
||||
|
||||
* change the container image to `opencloudeu/opencloud:dev`:
|
||||
|
||||
@@ -280,7 +286,7 @@ Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the fol
|
||||
+#EXTERNALSITES=:web_extensions/externalsites.yml
|
||||
```
|
||||
|
||||
<a name="automate-env-setup-homelab"></a>
|
||||
<a name="automate-env-setup-homelab-master"></a>
|
||||
All those changes above can be automated with the following script:
|
||||
|
||||
```bash
|
||||
@@ -308,6 +314,144 @@ perl -pi -e '
|
||||
' .env
|
||||
```
|
||||
|
||||
#### Homelab Setup with OIDC Authentication
|
||||
|
||||
<a name="homelab-setup-oidc"></a>
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: Homelab Setup
|
||||
---
|
||||
flowchart LR
|
||||
oc["`opencloud`"]
|
||||
c["client"]
|
||||
st["`stalwart`"]
|
||||
|
||||
c -- http --> oc
|
||||
oc -- jmap --> st
|
||||
st -- userinfo --> oc
|
||||
|
||||
```
|
||||
|
||||
Edit `$OCDIR/opencloud/devtools/deployments/opencloud_full/.env`, making the following changes (make sure to check out [the shell command-line that automates all of that, below](#automate-env-setup-homelab-oidc)):
|
||||
|
||||
* change the container image to `opencloudeu/opencloud:dev`:
|
||||
|
||||
```diff
|
||||
-OC_DOCKER_IMAGE=opencloudeu/opencloud-rolling
|
||||
+OC_DOCKER_IMAGE=opencloudeu/opencloud
|
||||
-OC_DOCKER_TAG=
|
||||
+OC_DOCKER_TAG=dev
|
||||
```
|
||||
|
||||
* enable the creation of demo users:
|
||||
|
||||
```diff
|
||||
-DEMO_USERS=
|
||||
+DEMO_USERS=true
|
||||
```
|
||||
|
||||
* add the `groupware` and the `auth-api` services to `START_ADDITIONAL_SERVICES`:
|
||||
|
||||
```diff
|
||||
-START_ADDITIONAL_SERVICES="notifications"
|
||||
+START_ADDITIONAL_SERVICES="notifications,groupware,auth-api"
|
||||
```
|
||||
|
||||
* enable the Stalwart container:
|
||||
|
||||
```diff
|
||||
-#STALWART=:stalwart.yml
|
||||
+STALWART=:stalwart.yml
|
||||
```
|
||||
|
||||
* change the authentication directory configuration for Stalwart to `idmoidc` in the `.env` file, using the variable `STALWART_AUTH_DIRECTORY`:
|
||||
|
||||
```diff
|
||||
# Domain of Stalwart
|
||||
# Defaults to "stalwart.opencloud.test"
|
||||
STALWART_DOMAIN=
|
||||
# LDAP configuration to use for Stalwart:
|
||||
# Can either be either
|
||||
# - idmldap: for the built-in IDP/IDM, using Master Authentication between Groupware and Stalwart, and LDAP in Stalwart
|
||||
# - idmoidc: built-in IDP/IDM, using OIDC Userinfo between Groupware and Stalwart
|
||||
# - ldap: when using KeyCloak and OpenLDAP, with Master Authentication between Groupware and Stalwart, and LDAP in Stalwart
|
||||
-STALWART_AUTH_DIRECTORY=idmldap
|
||||
+STALWART_AUTH_DIRECTORY=idmoidc
|
||||
```
|
||||
|
||||
* while not required, it is recommended to enable basic authentication support which, while less secure, allows for easier tooling when developing and testing HTTP APIs, by adding `PROXY_ENABLE_BASIC_AUTH=true` somewhere before the last line of the `.env` file:
|
||||
|
||||
```diff
|
||||
+# Enable basic authentication to facilitate HTTP API testing
|
||||
+# Do not do this in production.
|
||||
+PROXY_ENABLE_BASIC_AUTH=true
|
||||
+
|
||||
## IMPORTANT ##
|
||||
```
|
||||
|
||||
* optionally disable the Collabora container
|
||||
|
||||
```diff
|
||||
-COLLABORA=:collabora.yml
|
||||
+#COLLABORA=:collabora.yml
|
||||
```
|
||||
|
||||
* optionally disable UI containers
|
||||
|
||||
```diff
|
||||
-UNZIP=:web_extensions/unzip.yml
|
||||
-DRAWIO=:web_extensions/drawio.yml
|
||||
-JSONVIEWER=:web_extensions/jsonviewer.yml
|
||||
-PROGRESSBARS=:web_extensions/progressbars.yml
|
||||
-EXTERNALSITES=:web_extensions/externalsites.yml
|
||||
+#UNZIP=:web_extensions/unzip.yml
|
||||
+#DRAWIO=:web_extensions/drawio.yml
|
||||
+#JSONVIEWER=:web_extensions/jsonviewer.yml
|
||||
+#PROGRESSBARS=:web_extensions/progressbars.yml
|
||||
+#EXTERNALSITES=:web_extensions/externalsites.yml
|
||||
```
|
||||
|
||||
<a name="automate-env-setup-homelab-oidc"></a>
|
||||
All those changes above can be automated with the following script:
|
||||
|
||||
```bash
|
||||
cd "$OCDIR/opencloud/devtools/deployments/opencloud_full/"
|
||||
perl -pi -e '
|
||||
BEGIN{$basic_auth=0}
|
||||
s|^(OC_DOCKER_IMAGE)=.*$|$1=opencloudeu/opencloud|;
|
||||
s|^(OC_DOCKER_TAG)=.*$|$1=dev|;
|
||||
s|^(START_ADDITIONAL_SERVICES=".*(?<!groupware))"|$1,groupware"|;
|
||||
s|^(START_ADDITIONAL_SERVICES=".*(?<!auth-api))"|$1,auth-api"|;
|
||||
s,^(DEMO_USERS)=.+,$1=true,;
|
||||
s,^#(STALWART)=(.+)$,$1=$2,;
|
||||
s,^(STALWART_AUTH_DIRECTORY)=.+$,$1=idmoidc,;
|
||||
s,^#(PROXY_ENABLE_BASIC_AUTH)=(.*)$,$1=true,;
|
||||
$basic_auth=1 if /^PROXY_ENABLE_BASIC_AUTH=/;
|
||||
print "\n# Enable basic authentication to facilitate HTTP API testing\n# Do not do this in production.\nPROXY_ENABLE_BASIC_AUTH=true\n\n" if /^## IMPORTANT ##/ && !$basic_auth;
|
||||
' .env
|
||||
```
|
||||
|
||||
To disable Web UI services in case you are only interested in the backend service(s):
|
||||
|
||||
```bash
|
||||
cd "$OCDIR/opencloud/devtools/deployments/opencloud_full/"
|
||||
perl -pi -e '
|
||||
s|^([A-Z]+=:web_extensions/.*yml)$|#$1|;
|
||||
s,^(COLLABORA)=(.+)$,#$1=$2,;
|
||||
' .env
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Unfortunately, as of now, the hostname or IP address of the host that runs the Groupware backend (or the OpenCloud single binary) needs to be configured manually
|
||||
> in `devtools/deployments/opencloud_full/config/stalwart/idmoidc.toml`, specifically in the variable `directory.oidc.endpoint.url` since it highly depends on whether
|
||||
> you are running it on the host (typically from an IDE)
|
||||
> or as another container in the Docker Compose project.
|
||||
> In the former case, it also depends on the operating system.
|
||||
> It is currently hard-wired to be `http://172.17.0.1:10000/auth/...`, which only works
|
||||
> * on Linux, where `172.17.0.1` _tends_ to be the gateway host IP address, for running the OpenCloud Groupware backend on the host
|
||||
> * when the environment variable `AUTHAPI_HTTP_ADDR` is set to `0.0.0.0:10000`, allowing for HTTP access to the auth-api backend, instead of limiting it to HTTPS through the proxy, which opens a whole can of worms with making Stalwart accept self-signed certificates
|
||||
|
||||
## Building
|
||||
|
||||
Build the `opencloudeu/opencloud:dev` image first:
|
||||
@@ -358,7 +502,7 @@ To check whether the various services are running correctly:
|
||||
|
||||
#### Production Setup LDAP
|
||||
|
||||
When using the “production” setup (as depicted in section [Production Setup](#prod-setup) above), queries can be performed directly against the \
|
||||
When using the “production” setup (as depicted in section [Production Setup](#prod-setup-master) above), queries can be performed directly against the \
|
||||
OpenLDAP container (`opencloud_full-openldap-1`) since its LDAP ports are mapped onto the host (to `:389` and `:636` for LDAP and LDAPS, respectively).
|
||||
|
||||
When using the OpenLDAP container, the necessary LDAP parameters are as follows:
|
||||
@@ -474,7 +618,7 @@ docker run --network 'opencloud_full_opencloud-net' --rm -ti alpine:3 \
|
||||
### Testing Keycloak
|
||||
|
||||
> [!NOTE]
|
||||
> Only available in the [“production” setup](#prod-setup)
|
||||
> Only available in the [“production” setup](#prod-setup-master)
|
||||
|
||||
To check whether it works correctly, the following `curl` command:
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ func DefaultConfig() *config.Config {
|
||||
},
|
||||
Mail: config.Mail{
|
||||
Master: config.MailMasterAuth{
|
||||
Username: "master",
|
||||
Password: "admin",
|
||||
Username: "",
|
||||
Password: "",
|
||||
},
|
||||
BaseUrl: "https://stalwart.opencloud.test",
|
||||
Timeout: 30 * time.Second,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package groupware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -17,7 +18,7 @@ var (
|
||||
)
|
||||
|
||||
type DnsSessionUrlResolver struct {
|
||||
defaultSessionUrlSupplier func(string) (*url.URL, *GroupwareError)
|
||||
defaultSessionUrlSupplier func(context.Context, string) (*url.URL, *GroupwareError)
|
||||
defaultDomain string
|
||||
domainGreenList []string
|
||||
domainRedList []string
|
||||
@@ -26,7 +27,7 @@ type DnsSessionUrlResolver struct {
|
||||
}
|
||||
|
||||
func NewDnsSessionUrlResolver(
|
||||
defaultSessionUrlSupplier func(string) (*url.URL, *GroupwareError),
|
||||
defaultSessionUrlSupplier func(context.Context, string) (*url.URL, *GroupwareError),
|
||||
defaultDomain string,
|
||||
config *dns.ClientConfig,
|
||||
domainGreenList []string,
|
||||
@@ -71,7 +72,7 @@ func (d DnsSessionUrlResolver) isRedListed(domain string) bool {
|
||||
return !slices.Contains(d.domainRedList, domain)
|
||||
}
|
||||
|
||||
func (d DnsSessionUrlResolver) Resolve(username string) (*url.URL, *GroupwareError) {
|
||||
func (d DnsSessionUrlResolver) Resolve(ctx context.Context, username string) (*url.URL, *GroupwareError) {
|
||||
// heuristic to detect whether the username is an email address
|
||||
parts := strings.Split(username, "@")
|
||||
domain := d.defaultDomain
|
||||
@@ -80,7 +81,7 @@ func (d DnsSessionUrlResolver) Resolve(username string) (*url.URL, *GroupwareErr
|
||||
// nevertheless then?
|
||||
if d.defaultDomain == "" {
|
||||
// we don't, then let's fall back to the static session URL instead
|
||||
return d.defaultSessionUrlSupplier(username)
|
||||
return d.defaultSessionUrlSupplier(ctx, username)
|
||||
}
|
||||
} else {
|
||||
domain = parts[len(parts)-1]
|
||||
@@ -138,7 +139,7 @@ func (d DnsSessionUrlResolver) Resolve(username string) (*url.URL, *GroupwareErr
|
||||
}
|
||||
}
|
||||
|
||||
return d.defaultSessionUrlSupplier(username)
|
||||
return d.defaultSessionUrlSupplier(ctx, username)
|
||||
}
|
||||
|
||||
func (d DnsSessionUrlResolver) dnsQuery(c *dns.Client, msg *dns.Msg) (*dns.Msg, error) {
|
||||
|
||||
@@ -177,17 +177,6 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
|
||||
|
||||
sessionUrl := baseUrl.JoinPath(".well-known", "jmap")
|
||||
|
||||
masterUsername := config.Mail.Master.Username
|
||||
if masterUsername == "" {
|
||||
logger.Error().Msg("failed to parse empty Mail.Master.Username")
|
||||
return nil, GroupwareInitializationError{Message: "Mail.Master.Username is empty"}
|
||||
}
|
||||
masterPassword := config.Mail.Master.Password
|
||||
if masterPassword == "" {
|
||||
logger.Error().Msg("failed to parse empty Mail.Master.Password")
|
||||
return nil, GroupwareInitializationError{Message: "Mail.Master.Password is empty"}
|
||||
}
|
||||
|
||||
defaultEmailLimit := max(config.Mail.DefaultEmailLimit, 0)
|
||||
maxBodyValueBytes := max(config.Mail.MaxBodyValueBytes, 0)
|
||||
defaultContactLimit := max(config.Mail.DefaultContactLimit, 0)
|
||||
@@ -218,6 +207,17 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
|
||||
|
||||
var jmapClient jmap.Client
|
||||
{
|
||||
var auth jmap.HttpJmapClientAuthenticator
|
||||
{
|
||||
masterUsername := config.Mail.Master.Username
|
||||
masterPassword := config.Mail.Master.Password
|
||||
if masterUsername != "" && masterPassword != "" {
|
||||
auth = jmap.NewMasterAuthHttpJmapClientAuthenticator(masterUsername, masterPassword)
|
||||
} else {
|
||||
auth = newRevaBearerHttpJmapClientAuthenticator()
|
||||
}
|
||||
}
|
||||
|
||||
var api *jmap.HttpJmapClient
|
||||
{
|
||||
// TODO add timeouts and other meaningful configuration settings for the HTTP client
|
||||
@@ -235,8 +235,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
|
||||
|
||||
api = jmap.NewHttpJmapClient(
|
||||
&httpClient,
|
||||
masterUsername,
|
||||
masterPassword,
|
||||
auth,
|
||||
jmapMetricsAdapter,
|
||||
)
|
||||
}
|
||||
@@ -250,7 +249,7 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome
|
||||
wsDialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
wsf, err = jmap.NewHttpWsClientFactory(wsDialer, masterUsername, masterPassword, logger, jmapMetricsAdapter)
|
||||
wsf, err = jmap.NewHttpWsClientFactory(wsDialer, auth, logger, jmapMetricsAdapter)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to create websocket client")
|
||||
return nil, GroupwareInitializationError{Message: "failed to create websocket client", Err: err}
|
||||
@@ -478,7 +477,7 @@ func (g *Groupware) ServeSSE(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Provide a JMAP Session for the given user
|
||||
func (g *Groupware) session(user user, logger *log.Logger) (jmap.Session, bool, *GroupwareError, time.Time) {
|
||||
func (g *Groupware) session(ctx context.Context, user user, logger *log.Logger) (jmap.Session, bool, *GroupwareError, time.Time) {
|
||||
if user == nil {
|
||||
logger.Warn().Msg("user is nil")
|
||||
return jmap.Session{}, false, nil, time.Time{}
|
||||
@@ -490,7 +489,7 @@ func (g *Groupware) session(user user, logger *log.Logger) (jmap.Session, bool,
|
||||
}
|
||||
|
||||
// first look into the session cache
|
||||
s := g.sessionCache.Get(name)
|
||||
s := g.sessionCache.Get(ctx, name)
|
||||
if s != nil {
|
||||
if s.Success() {
|
||||
return s.Get(), true, nil, s.Until()
|
||||
@@ -587,7 +586,7 @@ func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler
|
||||
// retrieve a JMAP Session for that user
|
||||
var session jmap.Session
|
||||
{
|
||||
s, ok, gwerr, retryAfter := g.session(user, logger)
|
||||
s, ok, gwerr, retryAfter := g.session(ctx, user, logger)
|
||||
if gwerr != nil {
|
||||
g.metrics.SessionFailureCounter.Inc()
|
||||
errorId := errorId(r, ctx)
|
||||
@@ -721,7 +720,7 @@ func (g *Groupware) stream(w http.ResponseWriter, r *http.Request, handler func(
|
||||
|
||||
logger = log.From(logger.With().Str(logUserId, log.SafeString(user.GetId())))
|
||||
|
||||
session, ok, gwerr, retryAfter := g.session(user, logger)
|
||||
session, ok, gwerr, retryAfter := g.session(ctx, user, logger)
|
||||
if gwerr != nil {
|
||||
errorId := errorId(r, ctx)
|
||||
logger.Error().Str("code", gwerr.Code).Str("error", gwerr.Title).Str("detail", gwerr.Detail).Str(logErrorId, errorId).Msg("failed to determine JMAP session")
|
||||
|
||||
@@ -6,28 +6,27 @@ import (
|
||||
"net/http"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
)
|
||||
|
||||
// UsernameProvider implementation that uses Reva's enrichment of the Context
|
||||
// to retrieve the current username.
|
||||
type revaContextUserProvider struct {
|
||||
type revaContextUsernameProvider struct {
|
||||
}
|
||||
|
||||
var _ userProvider = revaContextUserProvider{}
|
||||
var _ userProvider = revaContextUsernameProvider{}
|
||||
|
||||
func newRevaContextUsernameProvider() userProvider {
|
||||
return revaContextUserProvider{}
|
||||
return revaContextUsernameProvider{}
|
||||
}
|
||||
|
||||
// var errUserNotInContext = fmt.Errorf("user not in context")
|
||||
|
||||
var (
|
||||
errUserNotInRevaContext = errors.New("failed to find user in reva context")
|
||||
)
|
||||
|
||||
func (r revaContextUserProvider) GetUser(req *http.Request, ctx context.Context, logger *log.Logger) (user, error) {
|
||||
func (r revaContextUsernameProvider) GetUser(req *http.Request, ctx context.Context, logger *log.Logger) (user, error) {
|
||||
u, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
err := errUserNotInRevaContext
|
||||
@@ -50,3 +49,66 @@ func (r revaUser) GetId() string {
|
||||
}
|
||||
|
||||
var _ user = revaUser{}
|
||||
|
||||
type RevaBearerHttpJmapClientAuthenticator struct {
|
||||
}
|
||||
|
||||
func newRevaBearerHttpJmapClientAuthenticator() jmap.HttpJmapClientAuthenticator {
|
||||
return &RevaBearerHttpJmapClientAuthenticator{}
|
||||
}
|
||||
|
||||
var _ jmap.HttpJmapClientAuthenticator = &RevaBearerHttpJmapClientAuthenticator{}
|
||||
|
||||
type RevaError struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
|
||||
var _ jmap.Error = &RevaError{}
|
||||
|
||||
func (e RevaError) Code() int {
|
||||
return e.code
|
||||
}
|
||||
func (e RevaError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
func (e RevaError) Error() string {
|
||||
if e.err != nil {
|
||||
return e.err.Error()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
revaErrorTokenMissingInRevaContext = iota + 10000
|
||||
)
|
||||
|
||||
var tokenMissingInRevaContext = RevaError{
|
||||
code: revaErrorTokenMissingInRevaContext,
|
||||
err: errors.New("Token is missing from Reva context"),
|
||||
}
|
||||
|
||||
func (h *RevaBearerHttpJmapClientAuthenticator) Authenticate(ctx context.Context, username string, logger *log.Logger, req *http.Request) jmap.Error {
|
||||
token, ok := revactx.ContextGetToken(ctx)
|
||||
if !ok {
|
||||
err := tokenMissingInRevaContext
|
||||
logger.Error().Err(err).Ctx(ctx).Msgf("could not get token: token not in reva context: %v", ctx)
|
||||
return err
|
||||
} else {
|
||||
req.Header.Add("Authorization", "Bearer "+token)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RevaBearerHttpJmapClientAuthenticator) AuthenticateWS(ctx context.Context, username string, logger *log.Logger, headers http.Header) jmap.Error {
|
||||
token, ok := revactx.ContextGetToken(ctx)
|
||||
if !ok {
|
||||
err := tokenMissingInRevaContext
|
||||
logger.Error().Err(err).Ctx(ctx).Msgf("could not get token: token not in reva context: %v", ctx)
|
||||
return err
|
||||
} else {
|
||||
headers.Add("Authorization", "Bearer "+token)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,49 +98,17 @@ func (s failedSession) Until() time.Time {
|
||||
return s.until
|
||||
}
|
||||
|
||||
// Implements the ttlcache.Loader interface, by loading JMAP Sessions for users
|
||||
// using the jmap.Client.
|
||||
type sessionCacheLoader struct {
|
||||
logger *log.Logger
|
||||
// A minimalistic contract for supplying the JMAP Session URL for a given username.
|
||||
sessionUrlProvider func(username string) (*url.URL, *GroupwareError)
|
||||
// A minimalistic contract for supplying JMAP Sessions using various input parameters.
|
||||
sessionSupplier func(sessionUrl *url.URL, username string, logger *log.Logger) (jmap.Session, jmap.Error)
|
||||
errorTtl time.Duration
|
||||
}
|
||||
|
||||
var _ ttlcache.Loader[sessionCacheKey, cachedSession] = &sessionCacheLoader{}
|
||||
|
||||
func (l *sessionCacheLoader) Load(c *ttlcache.Cache[sessionCacheKey, cachedSession], key sessionCacheKey) *ttlcache.Item[sessionCacheKey, cachedSession] {
|
||||
username := key.username()
|
||||
sessionUrl, gwerr := l.sessionUrlProvider(username)
|
||||
if gwerr != nil {
|
||||
l.logger.Warn().Str("username", username).Str("code", gwerr.Code).Msgf("failed to determine session URL for '%v'", key)
|
||||
now := time.Now()
|
||||
until := now.Add(l.errorTtl)
|
||||
return c.Set(key, failedSession{since: now, until: until, err: gwerr}, l.errorTtl)
|
||||
}
|
||||
session, jerr := l.sessionSupplier(sessionUrl, username, l.logger)
|
||||
if jerr != nil {
|
||||
l.logger.Warn().Str("username", username).Err(jerr).Msgf("failed to create session for '%v'", key)
|
||||
now := time.Now()
|
||||
until := now.Add(l.errorTtl)
|
||||
return c.Set(key, failedSession{since: now, until: until, err: groupwareErrorFromJmap(jerr)}, l.errorTtl)
|
||||
} else {
|
||||
l.logger.Debug().Str("username", username).Msgf("successfully created session for '%v'", key)
|
||||
now := time.Now()
|
||||
until := now.Add(ttlcache.DefaultTTL)
|
||||
return c.Set(key, succeededSession{since: now, until: until, session: session}, ttlcache.DefaultTTL) // use the TTL configured on the Cache
|
||||
}
|
||||
}
|
||||
|
||||
type sessionCache interface {
|
||||
Get(username string) cachedSession
|
||||
Get(ctx context.Context, username string) cachedSession
|
||||
jmap.SessionEventListener
|
||||
}
|
||||
|
||||
type ttlcacheSessionCache struct {
|
||||
sessionCache *ttlcache.Cache[sessionCacheKey, cachedSession]
|
||||
sessionUrlProvider func(ctx context.Context, username string) (*url.URL, *GroupwareError)
|
||||
sessionSupplier func(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (jmap.Session, jmap.Error)
|
||||
successTtl time.Duration
|
||||
errorTtl time.Duration
|
||||
outdatedSessionCounter prometheus.Counter
|
||||
logger *log.Logger
|
||||
}
|
||||
@@ -148,10 +116,45 @@ type ttlcacheSessionCache struct {
|
||||
var _ sessionCache = &ttlcacheSessionCache{}
|
||||
var _ jmap.SessionEventListener = &ttlcacheSessionCache{}
|
||||
|
||||
func (c *ttlcacheSessionCache) Get(username string) cachedSession {
|
||||
item := c.sessionCache.Get(toSessionCacheKey(username))
|
||||
func (l *ttlcacheSessionCache) load(c *ttlcache.Cache[sessionCacheKey, cachedSession], key sessionCacheKey, ctx context.Context) cachedSession {
|
||||
username := key.username()
|
||||
sessionUrl, gwerr := l.sessionUrlProvider(ctx, username)
|
||||
if gwerr != nil {
|
||||
l.logger.Warn().Str("username", username).Str("code", gwerr.Code).Msgf("failed to determine session URL for '%v'", key)
|
||||
now := time.Now()
|
||||
until := now.Add(l.errorTtl)
|
||||
return failedSession{since: now, until: until, err: gwerr}
|
||||
}
|
||||
session, jerr := l.sessionSupplier(ctx, sessionUrl, username, l.logger)
|
||||
if jerr != nil {
|
||||
l.logger.Warn().Str("username", username).Err(jerr).Msgf("failed to create session for '%v'", key)
|
||||
now := time.Now()
|
||||
until := now.Add(l.errorTtl)
|
||||
return failedSession{since: now, until: until, err: groupwareErrorFromJmap(jerr)}
|
||||
} else {
|
||||
l.logger.Debug().Str("username", username).Msgf("successfully created session for '%v'", key)
|
||||
now := time.Now()
|
||||
until := now.Add(l.successTtl)
|
||||
return succeededSession{since: now, until: until, session: session}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ttlcacheSessionCache) Get(ctx context.Context, username string) cachedSession {
|
||||
key := toSessionCacheKey(username)
|
||||
item, cached := c.sessionCache.GetOrSetFunc(key, func() cachedSession {
|
||||
return c.load(c.sessionCache, key, ctx) // TODO can't set the TTL on the cached item
|
||||
})
|
||||
if item != nil {
|
||||
return item.Value()
|
||||
value := item.Value()
|
||||
if !cached {
|
||||
if !value.Success() && c.errorTtl != c.successTtl {
|
||||
// not great, but will do for now:
|
||||
// - when the result is successful, the default TTL is used
|
||||
// - when the result is a failure to retrieve the session, a (most probably shorter) TTL must be used instead
|
||||
c.sessionCache.Set(key, value, c.errorTtl)
|
||||
}
|
||||
}
|
||||
return value
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
@@ -159,9 +162,9 @@ func (c *ttlcacheSessionCache) Get(username string) cachedSession {
|
||||
|
||||
type sessionCacheBuilder struct {
|
||||
logger *log.Logger
|
||||
sessionSupplier func(sessionUrl *url.URL, username string, logger *log.Logger) (jmap.Session, jmap.Error)
|
||||
defaultUrlResolver func(string) (*url.URL, *GroupwareError)
|
||||
sessionUrlResolverFactory func() (func(string) (*url.URL, *GroupwareError), *GroupwareInitializationError)
|
||||
sessionSupplier func(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (jmap.Session, jmap.Error)
|
||||
defaultUrlResolver func(context.Context, string) (*url.URL, *GroupwareError)
|
||||
sessionUrlResolverFactory func() (func(context.Context, string) (*url.URL, *GroupwareError), *GroupwareInitializationError)
|
||||
prometheusRegistry prometheus.Registerer
|
||||
m *metrics.Metrics
|
||||
sessionCacheMaxCapacity uint64
|
||||
@@ -172,14 +175,14 @@ type sessionCacheBuilder struct {
|
||||
func newSessionCacheBuilder(
|
||||
sessionUrl *url.URL,
|
||||
logger *log.Logger,
|
||||
sessionSupplier func(sessionUrl *url.URL, username string, logger *log.Logger) (jmap.Session, jmap.Error),
|
||||
sessionSupplier func(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (jmap.Session, jmap.Error),
|
||||
prometheusRegistry prometheus.Registerer,
|
||||
m *metrics.Metrics,
|
||||
sessionCacheMaxCapacity uint64,
|
||||
sessionCacheTtl time.Duration,
|
||||
sessionFailureCacheTtl time.Duration,
|
||||
) *sessionCacheBuilder {
|
||||
defaultUrlResolver := func(_ string) (*url.URL, *GroupwareError) {
|
||||
defaultUrlResolver := func(_ context.Context, _ string) (*url.URL, *GroupwareError) {
|
||||
return sessionUrl, nil
|
||||
}
|
||||
|
||||
@@ -187,7 +190,7 @@ func newSessionCacheBuilder(
|
||||
logger: logger,
|
||||
sessionSupplier: sessionSupplier,
|
||||
defaultUrlResolver: defaultUrlResolver,
|
||||
sessionUrlResolverFactory: func() (func(string) (*url.URL, *GroupwareError), *GroupwareInitializationError) {
|
||||
sessionUrlResolverFactory: func() (func(context.Context, string) (*url.URL, *GroupwareError), *GroupwareInitializationError) {
|
||||
return defaultUrlResolver, nil
|
||||
},
|
||||
prometheusRegistry: prometheusRegistry,
|
||||
@@ -206,7 +209,7 @@ func (b *sessionCacheBuilder) withDnsAutoDiscovery(
|
||||
domainGreenList []string,
|
||||
domainRedList []string,
|
||||
) *sessionCacheBuilder {
|
||||
dnsSessionUrlResolverFactory := func() (func(string) (*url.URL, *GroupwareError), *GroupwareInitializationError) {
|
||||
dnsSessionUrlResolverFactory := func() (func(context.Context, string) (*url.URL, *GroupwareError), *GroupwareInitializationError) {
|
||||
d, err := NewDnsSessionUrlResolver(
|
||||
b.defaultUrlResolver,
|
||||
defaultSessionDomain,
|
||||
@@ -234,18 +237,10 @@ func (b sessionCacheBuilder) build() (sessionCache, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionLoader := &sessionCacheLoader{
|
||||
logger: b.logger,
|
||||
sessionSupplier: b.sessionSupplier,
|
||||
errorTtl: b.sessionFailureCacheTtl,
|
||||
sessionUrlProvider: sessionUrlResolver,
|
||||
}
|
||||
|
||||
cache = ttlcache.New(
|
||||
ttlcache.WithCapacity[sessionCacheKey, cachedSession](b.sessionCacheMaxCapacity),
|
||||
ttlcache.WithTTL[sessionCacheKey, cachedSession](b.sessionCacheTtl),
|
||||
ttlcache.WithDisableTouchOnHit[sessionCacheKey, cachedSession](),
|
||||
ttlcache.WithLoader(sessionLoader),
|
||||
)
|
||||
|
||||
b.prometheusRegistry.Register(sessionCacheMetricsCollector{desc: b.m.SessionCacheDesc, supply: cache.Metrics})
|
||||
@@ -277,6 +272,10 @@ func (b sessionCacheBuilder) build() (sessionCache, error) {
|
||||
|
||||
s := &ttlcacheSessionCache{
|
||||
sessionCache: cache,
|
||||
sessionSupplier: b.sessionSupplier,
|
||||
successTtl: b.sessionCacheTtl,
|
||||
errorTtl: b.sessionFailureCacheTtl,
|
||||
sessionUrlProvider: sessionUrlResolver,
|
||||
logger: b.logger,
|
||||
outdatedSessionCounter: b.m.OutdatedSessionsCounter,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/groupware/pkg/metrics"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
@@ -21,7 +20,6 @@ type Options struct {
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Metrics *metrics.HttpMetrics
|
||||
Flags []cli.Flag
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
|
||||
2
vendor/github.com/MicahParks/jwkset/.gitignore
generated
vendored
2
vendor/github.com/MicahParks/jwkset/.gitignore
generated
vendored
@@ -1,2 +0,0 @@
|
||||
config.*json
|
||||
node_modules
|
||||
201
vendor/github.com/MicahParks/jwkset/LICENSE
generated
vendored
201
vendor/github.com/MicahParks/jwkset/LICENSE
generated
vendored
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2022 Micah Parks
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
133
vendor/github.com/MicahParks/jwkset/README.md
generated
vendored
133
vendor/github.com/MicahParks/jwkset/README.md
generated
vendored
@@ -1,133 +0,0 @@
|
||||
[](https://pkg.go.dev/github.com/MicahParks/jwkset)
|
||||
|
||||
# JWK Set (JSON Web Key Set)
|
||||
|
||||
This is a JWK Set (JSON Web Key Set) implementation written in Golang.
|
||||
|
||||
The goal of this project is to provide a complete implementation of JWK and JWK Sets within the constraints of the
|
||||
Golang standard library, without implementing any cryptographic algorithms. For example, `Ed25519` is supported, but
|
||||
`Ed448` is not, because the Go standard library does not have a high level implementation of `Ed448`.
|
||||
|
||||
If you would like to generate or validate a JWK without writing any Golang code, please visit
|
||||
the [Generate a JWK Set](#generate-a-jwk-set) section.
|
||||
|
||||
If you would like to have a JWK Set client to help verify JWTs without writing any Golang code, you can use the
|
||||
[JWK Set Client Proxy (JCP) project](https://github.com/MicahParks/jcp) perform JWK Set client operations in the
|
||||
language of your choice using an OpenAPI interface.
|
||||
|
||||
# Generate a JWK Set
|
||||
|
||||
If you would like to generate a JWK Set without writing Golang code, this project publishes utilities to generate a JWK
|
||||
Set from:
|
||||
|
||||
* PEM encoded X.509 Certificates
|
||||
* PEM encoded public keys
|
||||
* PEM encoded private keys
|
||||
|
||||
The PEM block type is used to infer which key type to decode. Reference the [Supported keys](#supported-keys) section
|
||||
for a list of supported cryptographic key types.
|
||||
|
||||
## Website
|
||||
|
||||
Visit [https://jwkset.com](https://jwkset.com) to use the web interface for this project. You can self-host this website
|
||||
by following the instructions in the `README.md` in
|
||||
the [website](https://github.com/MicahParks/jwkset/tree/master/website) directory.
|
||||
|
||||
## Command line
|
||||
|
||||
Gather your PEM encoded keys or certificates and use the `cmd/jwksetinfer` command line tool to generate a JWK Set.
|
||||
|
||||
**Install**
|
||||
|
||||
```
|
||||
go install github.com/MicahParks/jwkset/cmd/jwksetinfer@latest
|
||||
```
|
||||
|
||||
**Usage**
|
||||
|
||||
```
|
||||
jwksetinfer mykey.pem mycert.crt
|
||||
```
|
||||
|
||||
## Custom server
|
||||
|
||||
This project can be used in creating a custom JWK Set server. A good place to start is `examples/http_server/main.go`.
|
||||
|
||||
# Golang JWK Set client
|
||||
|
||||
If you are using [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) take a look
|
||||
at [`github.com/MicahParks/keyfunc/v3`](https://github.com/MicahParks/keyfunc).
|
||||
|
||||
This project can be used to create JWK Set clients. An HTTP client is provided. See a snippet of the usage
|
||||
from `examples/default_http_client/main.go` below.
|
||||
|
||||
## Create a JWK Set client from the server's HTTP URL.
|
||||
|
||||
```go
|
||||
jwks, err := jwkset.NewDefaultHTTPClient([]string{server.URL})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create client JWK set. Error: %s", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Read a key from the client.
|
||||
|
||||
```go
|
||||
jwk, err = jwks.KeyRead(ctx, myKeyID)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read key from client JWK set. Error: %s", err)
|
||||
}
|
||||
```
|
||||
|
||||
# Supported keys
|
||||
|
||||
This project supports the following key types:
|
||||
|
||||
* [Edwards-curve Digital Signature Algorithm (EdDSA)](https://en.wikipedia.org/wiki/EdDSA) (Ed25519 only)
|
||||
* Go Types: `ed25519.PrivateKey` and `ed25519.PublicKey`
|
||||
* [Elliptic-curve Diffie–Hellman (ECDH)](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman) (X25519
|
||||
only)
|
||||
* Go Types: `*ecdh.PrivateKey` and `*ecdh.PublicKey`
|
||||
* [Elliptic Curve Digital Signature Algorithm (ECDSA)](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)
|
||||
* Go Types: `*ecdsa.PrivateKey` and `*ecdsa.PublicKey`
|
||||
* [Rivest–Shamir–Adleman (RSA)](https://en.wikipedia.org/wiki/RSA_(cryptosystem))
|
||||
* Go Types: `*rsa.PrivateKey` and `*rsa.PublicKey`
|
||||
* [HMAC](https://en.wikipedia.org/wiki/HMAC), [AES Key Wrap](https://en.wikipedia.org/wiki/Key_Wrap), and other
|
||||
symmetric keys
|
||||
* Go Type: `[]byte`
|
||||
|
||||
Cryptographic keys can be added, deleted, and read from the JWK Set. A JSON representation of the JWK Set can be created
|
||||
for hosting via HTTPS. This project includes an in-memory storage implementation, but an interface is provided for more
|
||||
advanced use cases.
|
||||
|
||||
# Notes
|
||||
|
||||
This project aims to implement the relevant RFCs to the fullest extent possible using the Go standard library, but does
|
||||
not implement any cryptographic algorithms itself.
|
||||
|
||||
* RFC 8037 adds support for `Ed448`, `X448`, and `secp256k1`, but there is no Golang standard library support for these
|
||||
key types.
|
||||
* In order to be compatible with non-RFC compliant JWK Set providers, this project does not strictly enforce JWK
|
||||
parameters that are integers and have extra or missing leading padding. See the release notes
|
||||
of [`v0.5.15`](https://github.com/MicahParks/jwkset/releases/tag/v0.5.15) for details.
|
||||
* `Base64url Encoding` requires that all trailing `=` characters be removed. This project automatically strips any
|
||||
trailing `=` characters in an attempt to be compatible with improper implementations of JWK.
|
||||
* This project does not currently support JWK Set encryption using JWE. This would involve implementing the relevant JWE
|
||||
specifications. It may be implemented in the future if there is interest. Open a GitHub issue to express interest.
|
||||
|
||||
# Related projects
|
||||
|
||||
## [`github.com/MicahParks/keyfunc`](https://github.com/MicahParks/keyfunc)
|
||||
|
||||
A JWK Set client for the [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) project.
|
||||
|
||||
## [`github.com/MicahParks/jcp`](https://github.com/MicahParks/jcp)
|
||||
|
||||
A JWK Set client proxy. JCP for short. This project is a standalone service that uses keyfunc under the hood. It
|
||||
primarily exists for these use cases:
|
||||
|
||||
The language or shell a program is written in does not have an adequate JWK Set client. Validate JWTs with curl? Why
|
||||
not?
|
||||
Restrictive networking policies prevent a program from accessing the remote JWK Set directly.
|
||||
Many co-located services need to validate JWTs that were signed by a key that lives in a remote JWK Set.
|
||||
If you can integrate keyfunc directly into your program, you likely don't need JCP.
|
||||
167
vendor/github.com/MicahParks/jwkset/constants.go
generated
vendored
167
vendor/github.com/MicahParks/jwkset/constants.go
generated
vendored
@@ -1,167 +0,0 @@
|
||||
package jwkset
|
||||
|
||||
const (
|
||||
// HeaderKID is a JWT header for the key ID.
|
||||
HeaderKID = "kid"
|
||||
)
|
||||
|
||||
// These are string constants set in https://www.iana.org/assignments/jose/jose.xhtml
|
||||
// See their respective types for more information.
|
||||
const (
|
||||
AlgHS256 ALG = "HS256"
|
||||
AlgHS384 ALG = "HS384"
|
||||
AlgHS512 ALG = "HS512"
|
||||
AlgRS256 ALG = "RS256"
|
||||
AlgRS384 ALG = "RS384"
|
||||
AlgRS512 ALG = "RS512"
|
||||
AlgES256 ALG = "ES256"
|
||||
AlgES384 ALG = "ES384"
|
||||
AlgES512 ALG = "ES512"
|
||||
AlgPS256 ALG = "PS256"
|
||||
AlgPS384 ALG = "PS384"
|
||||
AlgPS512 ALG = "PS512"
|
||||
AlgNone ALG = "none"
|
||||
AlgRSA1_5 ALG = "RSA1_5"
|
||||
AlgRSAOAEP ALG = "RSA-OAEP"
|
||||
AlgRSAOAEP256 ALG = "RSA-OAEP-256"
|
||||
AlgA128KW ALG = "A128KW"
|
||||
AlgA192KW ALG = "A192KW"
|
||||
AlgA256KW ALG = "A256KW"
|
||||
AlgDir ALG = "dir"
|
||||
AlgECDHES ALG = "ECDH-ES"
|
||||
AlgECDHESA128KW ALG = "ECDH-ES+A128KW"
|
||||
AlgECDHESA192KW ALG = "ECDH-ES+A192KW"
|
||||
AlgECDHESA256KW ALG = "ECDH-ES+A256KW"
|
||||
AlgA128GCMKW ALG = "A128GCMKW"
|
||||
AlgA192GCMKW ALG = "A192GCMKW"
|
||||
AlgA256GCMKW ALG = "A256GCMKW"
|
||||
AlgPBES2HS256A128KW ALG = "PBES2-HS256+A128KW"
|
||||
AlgPBES2HS384A192KW ALG = "PBES2-HS384+A192KW"
|
||||
AlgPBES2HS512A256KW ALG = "PBES2-HS512+A256KW"
|
||||
AlgA128CBCHS256 ALG = "A128CBC-HS256"
|
||||
AlgA192CBCHS384 ALG = "A192CBC-HS384"
|
||||
AlgA256CBCHS512 ALG = "A256CBC-HS512"
|
||||
AlgA128GCM ALG = "A128GCM"
|
||||
AlgA192GCM ALG = "A192GCM"
|
||||
AlgA256GCM ALG = "A256GCM"
|
||||
AlgEdDSA ALG = "EdDSA"
|
||||
AlgRS1 ALG = "RS1" // Prohibited.
|
||||
AlgRSAOAEP384 ALG = "RSA-OAEP-384"
|
||||
AlgRSAOAEP512 ALG = "RSA-OAEP-512"
|
||||
AlgA128CBC ALG = "A128CBC" // Prohibited.
|
||||
AlgA192CBC ALG = "A192CBC" // Prohibited.
|
||||
AlgA256CBC ALG = "A256CBC" // Prohibited.
|
||||
AlgA128CTR ALG = "A128CTR" // Prohibited.
|
||||
AlgA192CTR ALG = "A192CTR" // Prohibited.
|
||||
AlgA256CTR ALG = "A256CTR" // Prohibited.
|
||||
AlgHS1 ALG = "HS1" // Prohibited.
|
||||
AlgES256K ALG = "ES256K"
|
||||
|
||||
CrvP256 CRV = "P-256"
|
||||
CrvP384 CRV = "P-384"
|
||||
CrvP521 CRV = "P-521"
|
||||
CrvEd25519 CRV = "Ed25519"
|
||||
CrvEd448 CRV = "Ed448"
|
||||
CrvX25519 CRV = "X25519"
|
||||
CrvX448 CRV = "X448"
|
||||
CrvSECP256K1 CRV = "secp256k1"
|
||||
|
||||
KeyOpsSign KEYOPS = "sign"
|
||||
KeyOpsVerify KEYOPS = "verify"
|
||||
KeyOpsEncrypt KEYOPS = "encrypt"
|
||||
KeyOpsDecrypt KEYOPS = "decrypt"
|
||||
KeyOpsWrapKey KEYOPS = "wrapKey"
|
||||
KeyOpsUnwrapKey KEYOPS = "unwrapKey"
|
||||
KeyOpsDeriveKey KEYOPS = "deriveKey"
|
||||
KeyOpsDeriveBits KEYOPS = "deriveBits"
|
||||
|
||||
KtyEC KTY = "EC"
|
||||
KtyOKP KTY = "OKP"
|
||||
KtyRSA KTY = "RSA"
|
||||
KtyOct KTY = "oct"
|
||||
|
||||
UseEnc USE = "enc"
|
||||
UseSig USE = "sig"
|
||||
)
|
||||
|
||||
// ALG is a set of "JSON Web Signature and Encryption Algorithms" types from
|
||||
// https://www.iana.org/assignments/jose/jose.xhtml as defined in
|
||||
// https://www.rfc-editor.org/rfc/rfc7518#section-7.1
|
||||
type ALG string
|
||||
|
||||
func (alg ALG) IANARegistered() bool {
|
||||
switch alg {
|
||||
case AlgHS256, AlgHS384, AlgHS512, AlgRS256, AlgRS384, AlgRS512, AlgES256, AlgES384, AlgES512, AlgPS256, AlgPS384,
|
||||
AlgPS512, AlgNone, AlgRSA1_5, AlgRSAOAEP, AlgRSAOAEP256, AlgA128KW, AlgA192KW, AlgA256KW, AlgDir, AlgECDHES,
|
||||
AlgECDHESA128KW, AlgECDHESA192KW, AlgECDHESA256KW, AlgA128GCMKW, AlgA192GCMKW, AlgA256GCMKW,
|
||||
AlgPBES2HS256A128KW, AlgPBES2HS384A192KW, AlgPBES2HS512A256KW, AlgA128CBCHS256, AlgA192CBCHS384,
|
||||
AlgA256CBCHS512, AlgA128GCM, AlgA192GCM, AlgA256GCM, AlgEdDSA, AlgRS1, AlgRSAOAEP384, AlgRSAOAEP512, AlgA128CBC,
|
||||
AlgA192CBC, AlgA256CBC, AlgA128CTR, AlgA192CTR, AlgA256CTR, AlgHS1, AlgES256K, "":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (alg ALG) String() string {
|
||||
return string(alg)
|
||||
}
|
||||
|
||||
// CRV is a set of "JSON Web Key Elliptic Curve" types from https://www.iana.org/assignments/jose/jose.xhtml as
|
||||
// mentioned in https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1
|
||||
type CRV string
|
||||
|
||||
func (crv CRV) IANARegistered() bool {
|
||||
switch crv {
|
||||
case CrvP256, CrvP384, CrvP521, CrvEd25519, CrvEd448, CrvX25519, CrvX448, CrvSECP256K1, "":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (crv CRV) String() string {
|
||||
return string(crv)
|
||||
}
|
||||
|
||||
// KEYOPS is a set of "JSON Web Key Operations" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in
|
||||
// https://www.rfc-editor.org/rfc/rfc7517#section-4.3
|
||||
type KEYOPS string
|
||||
|
||||
func (keyopts KEYOPS) IANARegistered() bool {
|
||||
switch keyopts {
|
||||
case KeyOpsSign, KeyOpsVerify, KeyOpsEncrypt, KeyOpsDecrypt, KeyOpsWrapKey, KeyOpsUnwrapKey, KeyOpsDeriveKey,
|
||||
KeyOpsDeriveBits:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (keyopts KEYOPS) String() string {
|
||||
return string(keyopts)
|
||||
}
|
||||
|
||||
// KTY is a set of "JSON Web Key Types" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in
|
||||
// https://www.rfc-editor.org/rfc/rfc7517#section-4.1
|
||||
type KTY string
|
||||
|
||||
func (kty KTY) IANARegistered() bool {
|
||||
switch kty {
|
||||
case KtyEC, KtyOKP, KtyRSA, KtyOct:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (kty KTY) String() string {
|
||||
return string(kty)
|
||||
}
|
||||
|
||||
// USE is a set of "JSON Web Key Use" types from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in
|
||||
// https://www.rfc-editor.org/rfc/rfc7517#section-4.2
|
||||
type USE string
|
||||
|
||||
func (use USE) IANARegistered() bool {
|
||||
switch use {
|
||||
case UseEnc, UseSig, "":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (use USE) String() string {
|
||||
return string(use)
|
||||
}
|
||||
276
vendor/github.com/MicahParks/jwkset/http.go
generated
vendored
276
vendor/github.com/MicahParks/jwkset/http.go
generated
vendored
@@ -1,276 +0,0 @@
|
||||
package jwkset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNewClient fails to create a new JWK Set client.
|
||||
ErrNewClient = errors.New("failed to create new JWK Set client")
|
||||
)
|
||||
|
||||
// HTTPClientOptions are options for creating a new JWK Set client.
|
||||
type HTTPClientOptions struct {
|
||||
// Given contains keys known from outside HTTP URLs.
|
||||
Given Storage
|
||||
// HTTPURLs are a mapping of HTTP URLs to JWK Set endpoints to storage implementations for the keys located at the
|
||||
// URL. If empty, HTTP will not be used.
|
||||
HTTPURLs map[string]Storage
|
||||
// PrioritizeHTTP is a flag that indicates whether keys from the HTTP URL should be prioritized over keys from the
|
||||
// given storage.
|
||||
PrioritizeHTTP bool
|
||||
// RateLimitWaitMax is the timeout for waiting for rate limiting to end.
|
||||
RateLimitWaitMax time.Duration
|
||||
// RefreshUnknownKID is non-nil to indicate that remote HTTP resources should be refreshed if a key with an unknown
|
||||
// key ID is trying to be read. This makes reading methods block until the context is over, a key with the matching
|
||||
// key ID is found in a refreshed remote resource, or all refreshes complete.
|
||||
RefreshUnknownKID *rate.Limiter
|
||||
}
|
||||
|
||||
// Client is a JWK Set client.
|
||||
type httpClient struct {
|
||||
given Storage
|
||||
httpURLs map[string]Storage
|
||||
prioritizeHTTP bool
|
||||
rateLimitWaitMax time.Duration
|
||||
refreshUnknownKID *rate.Limiter
|
||||
}
|
||||
|
||||
// NewHTTPClient creates a new JWK Set client from remote HTTP resources.
|
||||
func NewHTTPClient(options HTTPClientOptions) (Storage, error) {
|
||||
if options.Given == nil && len(options.HTTPURLs) == 0 {
|
||||
return nil, fmt.Errorf("%w: no given keys or HTTP URLs", ErrNewClient)
|
||||
}
|
||||
for u, store := range options.HTTPURLs {
|
||||
if store == nil {
|
||||
var err error
|
||||
options.HTTPURLs[u], err = NewStorageFromHTTP(u, HTTPClientStorageOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient))
|
||||
}
|
||||
}
|
||||
}
|
||||
given := options.Given
|
||||
if given == nil {
|
||||
given = NewMemoryStorage()
|
||||
}
|
||||
c := httpClient{
|
||||
given: given,
|
||||
httpURLs: options.HTTPURLs,
|
||||
prioritizeHTTP: options.PrioritizeHTTP,
|
||||
rateLimitWaitMax: options.RateLimitWaitMax,
|
||||
refreshUnknownKID: options.RefreshUnknownKID,
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewDefaultHTTPClient creates a new JWK Set client with default options from remote HTTP resources.
|
||||
//
|
||||
// The default behavior is to:
|
||||
// 1. Refresh remote HTTP resources every hour.
|
||||
// 2. Prioritize keys from remote HTTP resources over keys from the given storage.
|
||||
// 3. Refresh remote HTTP resources if a key with an unknown key ID is trying to be read, with a rate limit of 5 minutes.
|
||||
// 4. Log to slog.Default() if a refresh fails.
|
||||
func NewDefaultHTTPClient(urls []string) (Storage, error) {
|
||||
return NewDefaultHTTPClientCtx(context.Background(), urls)
|
||||
}
|
||||
|
||||
// NewDefaultHTTPClientCtx is the same as NewDefaultHTTPClient, but with a context that can end the refresh goroutine.
|
||||
func NewDefaultHTTPClientCtx(ctx context.Context, urls []string) (Storage, error) {
|
||||
clientOptions := HTTPClientOptions{
|
||||
HTTPURLs: make(map[string]Storage),
|
||||
RateLimitWaitMax: time.Minute,
|
||||
RefreshUnknownKID: rate.NewLimiter(rate.Every(5*time.Minute), 1),
|
||||
}
|
||||
for _, u := range urls {
|
||||
refreshErrorHandler := func(ctx context.Context, err error) {
|
||||
slog.Default().ErrorContext(ctx, "Failed to refresh HTTP JWK Set from remote HTTP resource.",
|
||||
"error", err,
|
||||
"url", u,
|
||||
)
|
||||
}
|
||||
options := HTTPClientStorageOptions{
|
||||
Ctx: ctx,
|
||||
NoErrorReturnFirstHTTPReq: true,
|
||||
RefreshErrorHandler: refreshErrorHandler,
|
||||
RefreshInterval: time.Hour,
|
||||
}
|
||||
c, err := NewStorageFromHTTP(u, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient))
|
||||
}
|
||||
clientOptions.HTTPURLs[u] = c
|
||||
}
|
||||
return NewHTTPClient(clientOptions)
|
||||
}
|
||||
|
||||
func (c httpClient) KeyDelete(ctx context.Context, keyID string) (ok bool, err error) {
|
||||
ok, err = c.given.KeyDelete(ctx, keyID)
|
||||
if err != nil && !errors.Is(err, ErrKeyNotFound) {
|
||||
return false, fmt.Errorf("failed to delete key with ID %q from given storage due to error: %w", keyID, err)
|
||||
}
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
for _, store := range c.httpURLs {
|
||||
ok, err = store.KeyDelete(ctx, keyID)
|
||||
if err != nil && !errors.Is(err, ErrKeyNotFound) {
|
||||
return false, fmt.Errorf("failed to delete key with ID %q from HTTP storage due to error: %w", keyID, err)
|
||||
}
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (c httpClient) KeyRead(ctx context.Context, keyID string) (jwk JWK, err error) {
|
||||
if !c.prioritizeHTTP {
|
||||
jwk, err = c.given.KeyRead(ctx, keyID)
|
||||
switch {
|
||||
case errors.Is(err, ErrKeyNotFound):
|
||||
// Do nothing.
|
||||
case err != nil:
|
||||
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err)
|
||||
default:
|
||||
return jwk, nil
|
||||
}
|
||||
}
|
||||
for _, store := range c.httpURLs {
|
||||
jwk, err = store.KeyRead(ctx, keyID)
|
||||
switch {
|
||||
case errors.Is(err, ErrKeyNotFound):
|
||||
continue
|
||||
case err != nil:
|
||||
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err)
|
||||
default:
|
||||
return jwk, nil
|
||||
}
|
||||
}
|
||||
if c.prioritizeHTTP {
|
||||
jwk, err = c.given.KeyRead(ctx, keyID)
|
||||
switch {
|
||||
case errors.Is(err, ErrKeyNotFound):
|
||||
// Do nothing.
|
||||
case err != nil:
|
||||
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err)
|
||||
default:
|
||||
return jwk, nil
|
||||
}
|
||||
}
|
||||
if c.refreshUnknownKID != nil {
|
||||
var cancel context.CancelFunc = func() {}
|
||||
if c.rateLimitWaitMax > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, c.rateLimitWaitMax)
|
||||
}
|
||||
defer cancel()
|
||||
err = c.refreshUnknownKID.Wait(ctx)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to wait for JWK Set refresh rate limiter due to error: %w", err)
|
||||
}
|
||||
for _, store := range c.httpURLs {
|
||||
s, ok := store.(httpStorage)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
err = s.refresh(ctx)
|
||||
if err != nil {
|
||||
if s.options.RefreshErrorHandler != nil {
|
||||
s.options.RefreshErrorHandler(ctx, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
jwk, err = store.KeyRead(ctx, keyID)
|
||||
switch {
|
||||
case errors.Is(err, ErrKeyNotFound):
|
||||
// Do nothing.
|
||||
case err != nil:
|
||||
return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err)
|
||||
default:
|
||||
return jwk, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return JWK{}, fmt.Errorf("%w %q", ErrKeyNotFound, keyID)
|
||||
}
|
||||
func (c httpClient) KeyReadAll(ctx context.Context) ([]JWK, error) {
|
||||
jwks, err := c.given.KeyReadAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to snapshot given keys due to error: %w", err)
|
||||
}
|
||||
for u, store := range c.httpURLs {
|
||||
j, err := store.KeyReadAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to snapshot HTTP keys from %q due to error: %w", u, err)
|
||||
}
|
||||
jwks = append(jwks, j...)
|
||||
}
|
||||
return jwks, nil
|
||||
}
|
||||
func (c httpClient) KeyWrite(ctx context.Context, jwk JWK) error {
|
||||
return c.given.KeyWrite(ctx, jwk)
|
||||
}
|
||||
|
||||
func (c httpClient) JSON(ctx context.Context) (json.RawMessage, error) {
|
||||
m, err := c.combineStorage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
|
||||
}
|
||||
return m.JSON(ctx)
|
||||
}
|
||||
func (c httpClient) JSONPublic(ctx context.Context) (json.RawMessage, error) {
|
||||
m, err := c.combineStorage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
|
||||
}
|
||||
return m.JSONPublic(ctx)
|
||||
}
|
||||
func (c httpClient) JSONPrivate(ctx context.Context) (json.RawMessage, error) {
|
||||
m, err := c.combineStorage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
|
||||
}
|
||||
return m.JSONPrivate(ctx)
|
||||
}
|
||||
func (c httpClient) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) {
|
||||
m, err := c.combineStorage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to combine storage due to error: %w", err)
|
||||
}
|
||||
return m.JSONWithOptions(ctx, marshalOptions, validationOptions)
|
||||
}
|
||||
func (c httpClient) Marshal(ctx context.Context) (JWKSMarshal, error) {
|
||||
m, err := c.combineStorage(ctx)
|
||||
if err != nil {
|
||||
return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err)
|
||||
}
|
||||
return m.Marshal(ctx)
|
||||
}
|
||||
func (c httpClient) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) {
|
||||
m, err := c.combineStorage(ctx)
|
||||
if err != nil {
|
||||
return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err)
|
||||
}
|
||||
return m.MarshalWithOptions(ctx, marshalOptions, validationOptions)
|
||||
}
|
||||
|
||||
func (c httpClient) combineStorage(ctx context.Context) (Storage, error) {
|
||||
jwks, err := c.KeyReadAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to snapshot keys due to error: %w", err)
|
||||
}
|
||||
m := NewMemoryStorage()
|
||||
for _, jwk := range jwks {
|
||||
err = m.KeyWrite(ctx, jwk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write key to memory storage due to error: %w", err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
494
vendor/github.com/MicahParks/jwkset/jwk.go
generated
vendored
494
vendor/github.com/MicahParks/jwkset/jwk.go
generated
vendored
@@ -1,494 +0,0 @@
|
||||
package jwkset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPadding indicates that there is invalid padding.
|
||||
ErrPadding = errors.New("padding error")
|
||||
)
|
||||
|
||||
// JWK represents a JSON Web Key.
|
||||
type JWK struct {
|
||||
key any
|
||||
marshal JWKMarshal
|
||||
options JWKOptions
|
||||
}
|
||||
|
||||
// JWKMarshalOptions are used to specify options for JSON marshaling a JWK.
|
||||
type JWKMarshalOptions struct {
|
||||
// Private is used to indicate that the JWK's private key material should be JSON marshaled and unmarshalled. This
|
||||
// includes symmetric and asymmetric keys. Setting this to true is the only way to marshal and unmarshal symmetric
|
||||
// keys.
|
||||
Private bool
|
||||
}
|
||||
|
||||
// JWKX509Options holds the X.509 certificate information for a JWK. This data structure is not used for JSON marshaling.
|
||||
type JWKX509Options struct {
|
||||
// X5C contains a chain of one or more PKIX certificates. The PKIX certificate containing the key value MUST be the
|
||||
// first certificate.
|
||||
X5C []*x509.Certificate // The PKIX certificate containing the key value MUST be the first certificate.
|
||||
|
||||
// X5T is calculated automatically.
|
||||
// X5TS256 is calculated automatically.
|
||||
|
||||
// X5U Is a URI that refers to a resource for an X.509 public key certificate or certificate chain.
|
||||
X5U string // https://www.rfc-editor.org/rfc/rfc7517#section-4.6
|
||||
}
|
||||
|
||||
// JWKValidateOptions are used to specify options for validating a JWK.
|
||||
type JWKValidateOptions struct {
|
||||
/*
|
||||
This package intentionally does not confirm if certificate's usage or compare that to the JWK's use parameter.
|
||||
Please open a GitHub issue if you think this should be an option.
|
||||
*/
|
||||
// CheckX509ValidTime is used to indicate that the X.509 certificate's valid time should be checked.
|
||||
CheckX509ValidTime bool
|
||||
// GetX5U is used to get and validate the X.509 certificate from the X5U URI. Use DefaultGetX5U for the default
|
||||
// behavior.
|
||||
GetX5U func(x5u *url.URL) ([]*x509.Certificate, error)
|
||||
// SkipAll is used to skip all validation.
|
||||
SkipAll bool
|
||||
// SkipKeyOps is used to skip validation of the key operations (key_ops).
|
||||
SkipKeyOps bool
|
||||
// SkipMetadata skips checking if the JWKMetadataOptions match the JWKMarshal.
|
||||
SkipMetadata bool
|
||||
// SkipUse is used to skip validation of the key use (use).
|
||||
SkipUse bool
|
||||
// SkipX5UScheme is used to skip checking if the X5U URI scheme is https.
|
||||
SkipX5UScheme bool
|
||||
// StrictPadding is used to indicate that the JWK should be validated with strict padding.
|
||||
StrictPadding bool
|
||||
}
|
||||
|
||||
// JWKMetadataOptions are direct passthroughs into the JWKMarshal.
|
||||
type JWKMetadataOptions struct {
|
||||
// ALG is the algorithm (alg).
|
||||
ALG ALG
|
||||
// KID is the key ID (kid).
|
||||
KID string
|
||||
// KEYOPS is the key operations (key_ops).
|
||||
KEYOPS []KEYOPS
|
||||
// USE is the key use (use).
|
||||
USE USE
|
||||
}
|
||||
|
||||
// JWKOptions are used to specify options for marshaling a JSON Web Key.
|
||||
type JWKOptions struct {
|
||||
Marshal JWKMarshalOptions
|
||||
Metadata JWKMetadataOptions
|
||||
Validate JWKValidateOptions
|
||||
X509 JWKX509Options
|
||||
}
|
||||
|
||||
// NewJWKFromKey uses the given key and options to create a JWK. It is possible to provide a private key with an X.509
|
||||
// certificate, which will be validated to contain the correct public key.
|
||||
func NewJWKFromKey(key any, options JWKOptions) (JWK, error) {
|
||||
marshal, err := keyMarshal(key, options)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err)
|
||||
}
|
||||
switch key.(type) {
|
||||
case ed25519.PrivateKey, ed25519.PublicKey:
|
||||
if options.Metadata.ALG == "" {
|
||||
options.Metadata.ALG = AlgEdDSA
|
||||
} else if options.Metadata.ALG != AlgEdDSA {
|
||||
return JWK{}, fmt.Errorf("%w: invalid ALG for Ed25519 key: %q", ErrOptions, options.Metadata.ALG)
|
||||
}
|
||||
}
|
||||
j := JWK{
|
||||
key: key,
|
||||
marshal: marshal,
|
||||
options: options,
|
||||
}
|
||||
err = j.Validate()
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// NewJWKFromRawJSON uses the given raw JSON to create a JWK.
|
||||
func NewJWKFromRawJSON(j json.RawMessage, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) {
|
||||
marshal := JWKMarshal{}
|
||||
err := json.Unmarshal(j, &marshal)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err)
|
||||
}
|
||||
return NewJWKFromMarshal(marshal, marshalOptions, validateOptions)
|
||||
}
|
||||
|
||||
// NewJWKFromMarshal transforms a JWKMarshal into a JWK.
|
||||
func NewJWKFromMarshal(marshal JWKMarshal, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) {
|
||||
j, err := keyUnmarshal(marshal, marshalOptions, validateOptions)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err)
|
||||
}
|
||||
err = j.Validate()
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// NewJWKFromX5C uses the X.509 X5C information in the options to create a JWK.
|
||||
func NewJWKFromX5C(options JWKOptions) (JWK, error) {
|
||||
if len(options.X509.X5C) == 0 {
|
||||
return JWK{}, fmt.Errorf("%w: no X.509 certificates provided", ErrOptions)
|
||||
}
|
||||
cert := options.X509.X5C[0]
|
||||
marshal, err := keyMarshal(cert.PublicKey, options)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err)
|
||||
}
|
||||
|
||||
if cert.PublicKeyAlgorithm == x509.Ed25519 {
|
||||
if options.Metadata.ALG != "" && options.Metadata.ALG != AlgEdDSA {
|
||||
return JWK{}, fmt.Errorf("%w: ALG in metadata does not match ALG in X.509 certificate", errors.Join(ErrOptions, ErrX509Mismatch))
|
||||
}
|
||||
options.Metadata.ALG = AlgEdDSA
|
||||
}
|
||||
|
||||
j := JWK{
|
||||
key: options.X509.X5C[0].PublicKey,
|
||||
marshal: marshal,
|
||||
options: options,
|
||||
}
|
||||
err = j.Validate()
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// NewJWKFromX5U uses the X.509 X5U information in the options to create a JWK.
|
||||
func NewJWKFromX5U(options JWKOptions) (JWK, error) {
|
||||
if options.X509.X5U == "" {
|
||||
return JWK{}, fmt.Errorf("%w: no X.509 URI provided", ErrOptions)
|
||||
}
|
||||
u, err := url.ParseRequestURI(options.X509.X5U)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrOptions, err))
|
||||
}
|
||||
if !options.Validate.SkipX5UScheme && u.Scheme != "https" {
|
||||
return JWK{}, fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrOptions))
|
||||
}
|
||||
get := options.Validate.GetX5U
|
||||
if get == nil {
|
||||
get = DefaultGetX5U
|
||||
}
|
||||
certs, err := get(u)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to get X5U URI: %w", err)
|
||||
}
|
||||
options.X509.X5C = certs
|
||||
jwk, err := NewJWKFromX5C(options)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to create JWK from fetched X5U assets: %w", err)
|
||||
}
|
||||
return jwk, nil
|
||||
}
|
||||
|
||||
// Key returns the public or private cryptographic key associated with the JWK.
|
||||
func (j JWK) Key() any {
|
||||
return j.key
|
||||
}
|
||||
|
||||
// Marshal returns Go type that can be marshalled into JSON.
|
||||
func (j JWK) Marshal() JWKMarshal {
|
||||
return j.marshal
|
||||
}
|
||||
|
||||
// X509 returns the X.509 certificate information for the JWK.
|
||||
func (j JWK) X509() JWKX509Options {
|
||||
return j.options.X509
|
||||
}
|
||||
|
||||
// Validate validates the JWK. The JWK is automatically validated when created from a function in this package.
|
||||
func (j JWK) Validate() error {
|
||||
if j.options.Validate.SkipAll {
|
||||
return nil
|
||||
}
|
||||
if !j.marshal.KTY.IANARegistered() {
|
||||
return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY)
|
||||
}
|
||||
|
||||
if !j.options.Validate.SkipUse && !j.marshal.USE.IANARegistered() {
|
||||
return fmt.Errorf("%w: invalid or unsupported key use %q", ErrJWKValidation, j.marshal.USE)
|
||||
}
|
||||
|
||||
if !j.options.Validate.SkipKeyOps {
|
||||
for _, o := range j.marshal.KEYOPS {
|
||||
if !o.IANARegistered() {
|
||||
return fmt.Errorf("%w: invalid or unsupported key_opt %q", ErrJWKValidation, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !j.options.Validate.SkipMetadata {
|
||||
if j.marshal.ALG != j.options.Metadata.ALG {
|
||||
return fmt.Errorf("%w: ALG in marshal does not match ALG in options", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
if j.marshal.KID != j.options.Metadata.KID {
|
||||
return fmt.Errorf("%w: KID in marshal does not match KID in options", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
if !slices.Equal(j.marshal.KEYOPS, j.options.Metadata.KEYOPS) {
|
||||
return fmt.Errorf("%w: KEYOPS in marshal does not match KEYOPS in options", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
if j.marshal.USE != j.options.Metadata.USE {
|
||||
return fmt.Errorf("%w: USE in marshal does not match USE in options", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
}
|
||||
|
||||
if len(j.options.X509.X5C) > 0 {
|
||||
cert := j.options.X509.X5C[0]
|
||||
i := cert.PublicKey
|
||||
switch k := j.key.(type) {
|
||||
// ECDH keys are not used to sign certificates.
|
||||
case *ecdsa.PublicKey:
|
||||
pub, ok := i.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: Golang key is type *ecdsa.Public but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i)
|
||||
}
|
||||
if !k.Equal(pub) {
|
||||
return fmt.Errorf("%w: Golang *ecdsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch))
|
||||
}
|
||||
case ed25519.PublicKey:
|
||||
pub, ok := i.(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: Golang key is type ed25519.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i)
|
||||
}
|
||||
if !bytes.Equal(k, pub) {
|
||||
return fmt.Errorf("%w: Golang ed25519.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch))
|
||||
}
|
||||
case *rsa.PublicKey:
|
||||
pub, ok := i.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: Golang key is type *rsa.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i)
|
||||
}
|
||||
if !k.Equal(pub) {
|
||||
return fmt.Errorf("%w: Golang *rsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch))
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: Golang key is type %T, which is not supported, so it cannot be compared to given X.509 certificates", errors.Join(ErrJWKValidation, ErrUnsupportedKey, ErrX509Mismatch), j.key)
|
||||
}
|
||||
if cert.PublicKeyAlgorithm == x509.Ed25519 {
|
||||
if j.marshal.ALG != AlgEdDSA {
|
||||
return fmt.Errorf("%w: ALG in marshal does not match ALG in X.509 certificate", errors.Join(ErrJWKValidation, ErrX509Mismatch))
|
||||
}
|
||||
}
|
||||
if j.options.Validate.CheckX509ValidTime {
|
||||
now := time.Now()
|
||||
if now.Before(cert.NotBefore) {
|
||||
return fmt.Errorf("%w: X.509 certificate is not yet valid", ErrJWKValidation)
|
||||
}
|
||||
if now.After(cert.NotAfter) {
|
||||
return fmt.Errorf("%w: X.509 certificate is expired", ErrJWKValidation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
marshalled, err := keyMarshal(j.key, j.options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON Web Key: %w", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
|
||||
// Remove automatically computed thumbprints if not set in given JWK.
|
||||
if j.marshal.X5T == "" {
|
||||
marshalled.X5T = ""
|
||||
}
|
||||
if j.marshal.X5TS256 == "" {
|
||||
marshalled.X5TS256 = ""
|
||||
}
|
||||
|
||||
canComputeThumbprint := len(j.marshal.X5C) > 0
|
||||
if j.marshal.X5T != marshalled.X5T && canComputeThumbprint {
|
||||
return fmt.Errorf("%w: X5T in marshal does not match X5T in marshalled", ErrJWKValidation)
|
||||
}
|
||||
if j.marshal.X5TS256 != marshalled.X5TS256 && canComputeThumbprint {
|
||||
return fmt.Errorf("%w: X5TS256 in marshal does not match X5TS256 in marshalled", ErrJWKValidation)
|
||||
}
|
||||
if j.marshal.CRV != marshalled.CRV {
|
||||
return fmt.Errorf("%w: CRV in marshal does not match CRV in marshalled", ErrJWKValidation)
|
||||
}
|
||||
switch j.marshal.KTY {
|
||||
case KtyEC:
|
||||
err = cmpBase64Int(j.marshal.X, marshalled.X, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: X in marshal does not match X in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.Y, marshalled.Y, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: Y in marshal does not match Y in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.D, marshalled.D, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: D in marshal does not match D in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
case KtyOKP:
|
||||
if j.marshal.X != marshalled.X {
|
||||
return fmt.Errorf("%w: X in marshal does not match X in marshalled", ErrJWKValidation)
|
||||
}
|
||||
if j.marshal.D != marshalled.D {
|
||||
return fmt.Errorf("%w: D in marshal does not match D in marshalled", ErrJWKValidation)
|
||||
}
|
||||
case KtyRSA:
|
||||
err = cmpBase64Int(j.marshal.D, marshalled.D, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: D in marshal does not match D in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.N, marshalled.N, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: N in marshal does not match N in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.E, marshalled.E, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: E in marshal does not match E in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.P, marshalled.P, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: P in marshal does not match P in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.Q, marshalled.Q, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: Q in marshal does not match Q in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.DP, marshalled.DP, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: DP in marshal does not match DP in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
err = cmpBase64Int(j.marshal.DQ, marshalled.DQ, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: DQ in marshal does not match DQ in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
if len(j.marshal.OTH) != len(marshalled.OTH) {
|
||||
return fmt.Errorf("%w: OTH in marshal does not match OTH in marshalled", ErrJWKValidation)
|
||||
}
|
||||
for i, o := range j.marshal.OTH {
|
||||
err = cmpBase64Int(o.R, marshalled.OTH[i].R, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i)
|
||||
}
|
||||
err = cmpBase64Int(o.D, marshalled.OTH[i].D, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i)
|
||||
}
|
||||
err = cmpBase64Int(o.T, marshalled.OTH[i].T, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i)
|
||||
}
|
||||
}
|
||||
case KtyOct:
|
||||
err = cmpBase64Int(j.marshal.K, marshalled.K, j.options.Validate.StrictPadding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: K in marshal does not match K in marshalled", errors.Join(ErrJWKValidation, err))
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY)
|
||||
}
|
||||
|
||||
// Saved for last because it may involve a network request.
|
||||
if j.marshal.X5U != "" || j.options.X509.X5U != "" {
|
||||
if j.marshal.X5U != j.options.X509.X5U {
|
||||
return fmt.Errorf("%w: X5U in marshal does not match X5U in options", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
u, err := url.ParseRequestURI(j.marshal.X5U)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err))
|
||||
}
|
||||
if !j.options.Validate.SkipX5UScheme && u.Scheme != "https" {
|
||||
return fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
if j.options.Validate.GetX5U != nil {
|
||||
certs, err := j.options.Validate.GetX5U(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err))
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
return fmt.Errorf("%w: X5U URI did not return any certificates", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
larger := certs
|
||||
smaller := j.options.X509.X5C
|
||||
if len(j.options.X509.X5C) > len(certs) {
|
||||
larger = j.options.X509.X5C
|
||||
smaller = certs
|
||||
}
|
||||
for i, c := range smaller {
|
||||
if !c.Equal(larger[i]) {
|
||||
return fmt.Errorf("%w: the X5C and X5U (remote resource) parameters are not a full or partial match", errors.Join(ErrJWKValidation, ErrOptions))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultGetX5U is the default implementation of the GetX5U field for JWKValidateOptions.
|
||||
func DefaultGetX5U(u *url.URL) ([]*x509.Certificate, error) {
|
||||
timeout := time.Minute
|
||||
ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("%w: timeout of %s reached", ErrGetX5U, timeout.String()))
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create X5U request: %w", errors.Join(ErrGetX5U, err))
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to do X5U request: %w", errors.Join(ErrGetX5U, err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: X5U request returned status code %d", ErrGetX5U, resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read X5U response body: %w", errors.Join(ErrGetX5U, err))
|
||||
}
|
||||
certs, err := LoadCertificates(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse X5U response body: %w", errors.Join(ErrGetX5U, err))
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
func cmpBase64Int(first, second string, strictPadding bool) error {
|
||||
if first == second {
|
||||
return nil
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(first)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode Base64 raw URL decode first string: %w", err)
|
||||
}
|
||||
fLen := len(b)
|
||||
f := new(big.Int).SetBytes(b)
|
||||
b, err = base64.RawURLEncoding.DecodeString(second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode Base64 raw URL decode second string: %w", err)
|
||||
}
|
||||
sLen := len(b)
|
||||
s := new(big.Int).SetBytes(b)
|
||||
if f.Cmp(s) != 0 {
|
||||
return fmt.Errorf("%w: the parsed integers do not match", ErrJWKValidation)
|
||||
}
|
||||
if strictPadding && fLen != sLen {
|
||||
return fmt.Errorf("%w: the Base64 raw URL inputs do not have matching padding", errors.Join(ErrJWKValidation, ErrPadding))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
511
vendor/github.com/MicahParks/jwkset/marshal.go
generated
vendored
511
vendor/github.com/MicahParks/jwkset/marshal.go
generated
vendored
@@ -1,511 +0,0 @@
|
||||
package jwkset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrGetX5U indicates there was an error getting the X5U remote resource.
|
||||
ErrGetX5U = errors.New("failed to get X5U via given URI")
|
||||
// ErrJWKValidation indicates that a JWK failed to validate.
|
||||
ErrJWKValidation = errors.New("failed to validate JWK")
|
||||
// ErrKeyUnmarshalParameter indicates that a JWK's attributes are invalid and cannot be unmarshaled.
|
||||
ErrKeyUnmarshalParameter = errors.New("unable to unmarshal JWK due to invalid attributes")
|
||||
// ErrOptions indicates that the given options caused an error.
|
||||
ErrOptions = errors.New("the given options caused an error")
|
||||
// ErrUnsupportedKey indicates a key is not supported.
|
||||
ErrUnsupportedKey = errors.New("unsupported key")
|
||||
// ErrX509Mismatch indicates that the X.509 certificate does not match the key.
|
||||
ErrX509Mismatch = errors.New("the X.509 certificate does not match Golang key type")
|
||||
)
|
||||
|
||||
// OtherPrimes is for RSA private keys that have more than 2 primes.
|
||||
// https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7
|
||||
type OtherPrimes struct {
|
||||
R string `json:"r,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7.1
|
||||
D string `json:"d,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7.2
|
||||
T string `json:"t,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7.3
|
||||
}
|
||||
|
||||
// JWKMarshal is used to marshal or unmarshal a JSON Web Key.
|
||||
// https://www.rfc-editor.org/rfc/rfc7517
|
||||
// https://www.rfc-editor.org/rfc/rfc7518
|
||||
// https://www.rfc-editor.org/rfc/rfc8037
|
||||
//
|
||||
// You can find the full list at https://www.iana.org/assignments/jose/jose.xhtml under "JSON Web Key Parameters".
|
||||
type JWKMarshal struct {
|
||||
KTY KTY `json:"kty,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.1
|
||||
USE USE `json:"use,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.2
|
||||
KEYOPS []KEYOPS `json:"key_ops,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.3
|
||||
ALG ALG `json:"alg,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.4 and https://www.rfc-editor.org/rfc/rfc7518#section-4.1
|
||||
KID string `json:"kid,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.5
|
||||
X5U string `json:"x5u,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.6
|
||||
X5C []string `json:"x5c,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.7
|
||||
X5T string `json:"x5t,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.8
|
||||
X5TS256 string `json:"x5t#S256,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.9
|
||||
CRV CRV `json:"crv,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2
|
||||
X string `json:"x,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.2 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2
|
||||
Y string `json:"y,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.3
|
||||
D string `json:"d,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.1 and https://www.rfc-editor.org/rfc/rfc7518#section-6.2.2.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2
|
||||
N string `json:"n,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.1
|
||||
E string `json:"e,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.2
|
||||
P string `json:"p,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.2
|
||||
Q string `json:"q,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.3
|
||||
DP string `json:"dp,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.4
|
||||
DQ string `json:"dq,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.5
|
||||
QI string `json:"qi,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.6
|
||||
OTH []OtherPrimes `json:"oth,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7
|
||||
K string `json:"k,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.4.1
|
||||
}
|
||||
|
||||
// JWKSMarshal is used to marshal or unmarshal a JSON Web Key Set.
|
||||
type JWKSMarshal struct {
|
||||
Keys []JWKMarshal `json:"keys"`
|
||||
}
|
||||
|
||||
// JWKSlice converts the JWKSMarshal to a []JWK.
|
||||
func (j JWKSMarshal) JWKSlice() ([]JWK, error) {
|
||||
slice := make([]JWK, len(j.Keys))
|
||||
for i, key := range j.Keys {
|
||||
marshalOptions := JWKMarshalOptions{
|
||||
Private: true,
|
||||
}
|
||||
jwk, err := keyUnmarshal(key, marshalOptions, JWKValidateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JWK: %w", err)
|
||||
}
|
||||
slice[i] = jwk
|
||||
}
|
||||
return slice, nil
|
||||
}
|
||||
|
||||
// ToStorage converts the JWKSMarshal to a Storage.
|
||||
func (j JWKSMarshal) ToStorage() (Storage, error) {
|
||||
m := NewMemoryStorage()
|
||||
jwks, err := j.JWKSlice()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create a slice of JWK from JWKSMarshal: %w", err)
|
||||
}
|
||||
for _, jwk := range jwks {
|
||||
err = m.KeyWrite(context.Background(), jwk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write JWK to storage: %w", err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func keyMarshal(key any, options JWKOptions) (JWKMarshal, error) {
|
||||
m := JWKMarshal{}
|
||||
m.ALG = options.Metadata.ALG
|
||||
switch key := key.(type) {
|
||||
case *ecdh.PublicKey:
|
||||
pub := key.Bytes()
|
||||
m.CRV = CrvX25519
|
||||
m.X = base64.RawURLEncoding.EncodeToString(pub)
|
||||
m.KTY = KtyOKP
|
||||
case *ecdh.PrivateKey:
|
||||
pub := key.PublicKey().Bytes()
|
||||
m.CRV = CrvX25519
|
||||
m.X = base64.RawURLEncoding.EncodeToString(pub)
|
||||
m.KTY = KtyOKP
|
||||
if options.Marshal.Private {
|
||||
priv := key.Bytes()
|
||||
m.D = base64.RawURLEncoding.EncodeToString(priv)
|
||||
}
|
||||
case *ecdsa.PrivateKey:
|
||||
pub := key.PublicKey
|
||||
m.CRV = CRV(pub.Curve.Params().Name)
|
||||
l := uint(pub.Curve.Params().BitSize / 8)
|
||||
if pub.Curve.Params().BitSize%8 != 0 {
|
||||
l++
|
||||
}
|
||||
m.X = bigIntToBase64RawURL(pub.X, l)
|
||||
m.Y = bigIntToBase64RawURL(pub.Y, l)
|
||||
m.KTY = KtyEC
|
||||
if options.Marshal.Private {
|
||||
params := key.Curve.Params()
|
||||
f, _ := params.N.Float64()
|
||||
l = uint(math.Ceil(math.Log2(f) / 8))
|
||||
m.D = bigIntToBase64RawURL(key.D, l)
|
||||
}
|
||||
case *ecdsa.PublicKey:
|
||||
l := uint(key.Curve.Params().BitSize / 8)
|
||||
if key.Curve.Params().BitSize%8 != 0 {
|
||||
l++
|
||||
}
|
||||
m.CRV = CRV(key.Curve.Params().Name)
|
||||
m.X = bigIntToBase64RawURL(key.X, l)
|
||||
m.Y = bigIntToBase64RawURL(key.Y, l)
|
||||
m.KTY = KtyEC
|
||||
case ed25519.PrivateKey:
|
||||
pub := key.Public().(ed25519.PublicKey)
|
||||
m.ALG = AlgEdDSA
|
||||
m.CRV = CrvEd25519
|
||||
m.X = base64.RawURLEncoding.EncodeToString(pub)
|
||||
m.KTY = KtyOKP
|
||||
if options.Marshal.Private {
|
||||
m.D = base64.RawURLEncoding.EncodeToString(key[:32])
|
||||
}
|
||||
case ed25519.PublicKey:
|
||||
m.ALG = AlgEdDSA
|
||||
m.CRV = CrvEd25519
|
||||
m.X = base64.RawURLEncoding.EncodeToString(key)
|
||||
m.KTY = KtyOKP
|
||||
case *rsa.PrivateKey:
|
||||
pub := key.PublicKey
|
||||
m.E = bigIntToBase64RawURL(big.NewInt(int64(pub.E)), 0)
|
||||
m.N = bigIntToBase64RawURL(pub.N, 0)
|
||||
m.KTY = KtyRSA
|
||||
if options.Marshal.Private {
|
||||
m.D = bigIntToBase64RawURL(key.D, 0)
|
||||
m.P = bigIntToBase64RawURL(key.Primes[0], 0)
|
||||
m.Q = bigIntToBase64RawURL(key.Primes[1], 0)
|
||||
m.DP = bigIntToBase64RawURL(key.Precomputed.Dp, 0)
|
||||
m.DQ = bigIntToBase64RawURL(key.Precomputed.Dq, 0)
|
||||
m.QI = bigIntToBase64RawURL(key.Precomputed.Qinv, 0)
|
||||
if len(key.Precomputed.CRTValues) > 0 {
|
||||
m.OTH = make([]OtherPrimes, len(key.Precomputed.CRTValues))
|
||||
for i := 0; i < len(key.Precomputed.CRTValues); i++ {
|
||||
m.OTH[i] = OtherPrimes{
|
||||
D: bigIntToBase64RawURL(key.Precomputed.CRTValues[i].Exp, 0),
|
||||
T: bigIntToBase64RawURL(key.Precomputed.CRTValues[i].Coeff, 0),
|
||||
R: bigIntToBase64RawURL(key.Primes[i+2], 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *rsa.PublicKey:
|
||||
m.E = bigIntToBase64RawURL(big.NewInt(int64(key.E)), 0)
|
||||
m.N = bigIntToBase64RawURL(key.N, 0)
|
||||
m.KTY = KtyRSA
|
||||
case []byte:
|
||||
if options.Marshal.Private {
|
||||
m.KTY = KtyOct
|
||||
m.K = base64.RawURLEncoding.EncodeToString(key)
|
||||
} else {
|
||||
return JWKMarshal{}, fmt.Errorf("%w: incorrect options to marshal symmetric key (oct)", ErrOptions)
|
||||
}
|
||||
default:
|
||||
return JWKMarshal{}, fmt.Errorf("%w: %T", ErrUnsupportedKey, key)
|
||||
}
|
||||
haveX5C := len(options.X509.X5C) > 0
|
||||
if haveX5C {
|
||||
for i, cert := range options.X509.X5C {
|
||||
m.X5C = append(m.X5C, base64.StdEncoding.EncodeToString(cert.Raw))
|
||||
if i == 0 {
|
||||
h1 := sha1.Sum(cert.Raw)
|
||||
m.X5T = base64.RawURLEncoding.EncodeToString(h1[:])
|
||||
h256 := sha256.Sum256(cert.Raw)
|
||||
m.X5TS256 = base64.RawURLEncoding.EncodeToString(h256[:])
|
||||
}
|
||||
}
|
||||
}
|
||||
m.KID = options.Metadata.KID
|
||||
m.KEYOPS = options.Metadata.KEYOPS
|
||||
m.USE = options.Metadata.USE
|
||||
m.X5U = options.X509.X5U
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func keyUnmarshal(marshal JWKMarshal, options JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) {
|
||||
marshalCopy := JWKMarshal{}
|
||||
var key any
|
||||
switch marshal.KTY {
|
||||
case KtyEC:
|
||||
if marshal.CRV == "" || marshal.X == "" || marshal.Y == "" {
|
||||
return JWK{}, fmt.Errorf(`%w: %s requires parameters "crv", "x", and "y"`, ErrKeyUnmarshalParameter, KtyEC)
|
||||
}
|
||||
x, err := base64urlTrailingPadding(marshal.X)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyEC, err)
|
||||
}
|
||||
y, err := base64urlTrailingPadding(marshal.Y)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "y": %w`, KtyEC, err)
|
||||
}
|
||||
publicKey := &ecdsa.PublicKey{
|
||||
X: new(big.Int).SetBytes(x),
|
||||
Y: new(big.Int).SetBytes(y),
|
||||
}
|
||||
switch marshal.CRV {
|
||||
case CrvP256:
|
||||
publicKey.Curve = elliptic.P256()
|
||||
case CrvP384:
|
||||
publicKey.Curve = elliptic.P384()
|
||||
case CrvP521:
|
||||
publicKey.Curve = elliptic.P521()
|
||||
default:
|
||||
return JWK{}, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, marshal.CRV)
|
||||
}
|
||||
marshalCopy.CRV = marshal.CRV
|
||||
marshalCopy.X = marshal.X
|
||||
marshalCopy.Y = marshal.Y
|
||||
if options.Private && marshal.D != "" {
|
||||
d, err := base64urlTrailingPadding(marshal.D)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyEC, err)
|
||||
}
|
||||
privateKey := &ecdsa.PrivateKey{
|
||||
PublicKey: *publicKey,
|
||||
D: new(big.Int).SetBytes(d),
|
||||
}
|
||||
key = privateKey
|
||||
marshalCopy.D = marshal.D
|
||||
} else {
|
||||
key = publicKey
|
||||
}
|
||||
case KtyOKP:
|
||||
if marshal.CRV == "" || marshal.X == "" {
|
||||
return JWK{}, fmt.Errorf(`%w: %s requires parameters "crv" and "x"`, ErrKeyUnmarshalParameter, KtyOKP)
|
||||
}
|
||||
public, err := base64urlTrailingPadding(marshal.X)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyOKP, err)
|
||||
}
|
||||
marshalCopy.CRV = marshal.CRV
|
||||
marshalCopy.X = marshal.X
|
||||
var private []byte
|
||||
if options.Private && marshal.D != "" {
|
||||
private, err = base64urlTrailingPadding(marshal.D)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyOKP, err)
|
||||
}
|
||||
}
|
||||
switch marshal.CRV {
|
||||
case CrvEd25519:
|
||||
if len(public) != ed25519.PublicKeySize {
|
||||
return JWK{}, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PublicKeySize)
|
||||
}
|
||||
if options.Private && marshal.D != "" {
|
||||
private = append(private, public...)
|
||||
if len(private) != ed25519.PrivateKeySize {
|
||||
return JWK{}, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PrivateKeySize)
|
||||
}
|
||||
key = ed25519.PrivateKey(private)
|
||||
marshalCopy.D = marshal.D
|
||||
} else {
|
||||
key = ed25519.PublicKey(public)
|
||||
}
|
||||
case CrvX25519:
|
||||
const x25519PublicKeySize = 32
|
||||
if len(public) != x25519PublicKeySize {
|
||||
return JWK{}, fmt.Errorf("%w: %s with curve %s public key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519, x25519PublicKeySize)
|
||||
}
|
||||
if options.Private && marshal.D != "" {
|
||||
const x25519PrivateKeySize = 32
|
||||
if len(private) != x25519PrivateKeySize {
|
||||
return JWK{}, fmt.Errorf("%w: %s with curve %s private key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519, x25519PrivateKeySize)
|
||||
}
|
||||
key, err = ecdh.X25519().NewPrivateKey(private)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to create X25519 private key: %w", err)
|
||||
}
|
||||
marshalCopy.D = marshal.D
|
||||
} else {
|
||||
key, err = ecdh.X25519().NewPublicKey(public)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to create X25519 public key: %w", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return JWK{}, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, marshal.CRV)
|
||||
}
|
||||
case KtyRSA:
|
||||
if marshal.N == "" || marshal.E == "" {
|
||||
return JWK{}, fmt.Errorf(`%w: %s requires parameters "n" and "e"`, ErrKeyUnmarshalParameter, KtyRSA)
|
||||
}
|
||||
n, err := base64urlTrailingPadding(marshal.N)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "n": %w`, KtyRSA, err)
|
||||
}
|
||||
e, err := base64urlTrailingPadding(marshal.E)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "e": %w`, KtyRSA, err)
|
||||
}
|
||||
publicKey := rsa.PublicKey{
|
||||
N: new(big.Int).SetBytes(n),
|
||||
E: int(new(big.Int).SetBytes(e).Uint64()),
|
||||
}
|
||||
marshalCopy.N = marshal.N
|
||||
marshalCopy.E = marshal.E
|
||||
if options.Private && marshal.D != "" && marshal.P != "" && marshal.Q != "" && marshal.DP != "" && marshal.DQ != "" && marshal.QI != "" { // TODO Only "d" is required, but if one of the others is present, they all must be.
|
||||
d, err := base64urlTrailingPadding(marshal.D)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err)
|
||||
}
|
||||
p, err := base64urlTrailingPadding(marshal.P)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "p": %w`, KtyRSA, err)
|
||||
}
|
||||
q, err := base64urlTrailingPadding(marshal.Q)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "q": %w`, KtyRSA, err)
|
||||
}
|
||||
dp, err := base64urlTrailingPadding(marshal.DP)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "dp": %w`, KtyRSA, err)
|
||||
}
|
||||
dq, err := base64urlTrailingPadding(marshal.DQ)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "dq": %w`, KtyRSA, err)
|
||||
}
|
||||
qi, err := base64urlTrailingPadding(marshal.QI)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "qi": %w`, KtyRSA, err)
|
||||
}
|
||||
var oth []rsa.CRTValue
|
||||
primes := []*big.Int{
|
||||
new(big.Int).SetBytes(p),
|
||||
new(big.Int).SetBytes(q),
|
||||
}
|
||||
if len(marshal.OTH) > 0 {
|
||||
oth = make([]rsa.CRTValue, len(marshal.OTH))
|
||||
for i, otherPrimes := range marshal.OTH {
|
||||
if otherPrimes.R == "" || otherPrimes.D == "" || otherPrimes.T == "" {
|
||||
return JWK{}, fmt.Errorf(`%w: %s requires parameters "r", "d", and "t" for each "oth"`, ErrKeyUnmarshalParameter, KtyRSA)
|
||||
}
|
||||
othD, err := base64urlTrailingPadding(otherPrimes.D)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err)
|
||||
}
|
||||
othT, err := base64urlTrailingPadding(otherPrimes.T)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "t": %w`, KtyRSA, err)
|
||||
}
|
||||
othR, err := base64urlTrailingPadding(otherPrimes.R)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "r": %w`, KtyRSA, err)
|
||||
}
|
||||
primes = append(primes, new(big.Int).SetBytes(othR))
|
||||
oth[i] = rsa.CRTValue{
|
||||
Exp: new(big.Int).SetBytes(othD),
|
||||
Coeff: new(big.Int).SetBytes(othT),
|
||||
R: new(big.Int).SetBytes(othR),
|
||||
}
|
||||
}
|
||||
}
|
||||
privateKey := &rsa.PrivateKey{
|
||||
PublicKey: publicKey,
|
||||
D: new(big.Int).SetBytes(d),
|
||||
Primes: primes,
|
||||
Precomputed: rsa.PrecomputedValues{
|
||||
Dp: new(big.Int).SetBytes(dp),
|
||||
Dq: new(big.Int).SetBytes(dq),
|
||||
Qinv: new(big.Int).SetBytes(qi),
|
||||
CRTValues: oth,
|
||||
},
|
||||
}
|
||||
err = privateKey.Validate()
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to validate %s key: %w`, KtyRSA, err)
|
||||
}
|
||||
key = privateKey
|
||||
marshalCopy.D = marshal.D
|
||||
marshalCopy.P = marshal.P
|
||||
marshalCopy.Q = marshal.Q
|
||||
marshalCopy.DP = marshal.DP
|
||||
marshalCopy.DQ = marshal.DQ
|
||||
marshalCopy.QI = marshal.QI
|
||||
marshalCopy.OTH = slices.Clone(marshal.OTH)
|
||||
} else {
|
||||
key = &publicKey
|
||||
}
|
||||
case KtyOct:
|
||||
if options.Private {
|
||||
if marshal.K == "" {
|
||||
return JWK{}, fmt.Errorf(`%w: %s requires parameter "k"`, ErrKeyUnmarshalParameter, KtyOct)
|
||||
}
|
||||
k, err := base64urlTrailingPadding(marshal.K)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf(`failed to decode %s key parameter "k": %w`, KtyOct, err)
|
||||
}
|
||||
key = k
|
||||
marshalCopy.K = marshal.K
|
||||
} else {
|
||||
return JWK{}, fmt.Errorf("%w: incorrect options to unmarshal symmetric key (%s)", ErrOptions, KtyOct)
|
||||
}
|
||||
default:
|
||||
return JWK{}, fmt.Errorf("%w: %s (kty)", ErrUnsupportedKey, marshal.KTY)
|
||||
}
|
||||
marshalCopy.KTY = marshal.KTY
|
||||
x5c := make([]*x509.Certificate, len(marshal.X5C))
|
||||
for i, cert := range marshal.X5C {
|
||||
raw, err := base64.StdEncoding.DecodeString(cert)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to Base64 decode X.509 certificate: %w", err)
|
||||
}
|
||||
x5c[i], err = x509.ParseCertificate(raw)
|
||||
if err != nil {
|
||||
return JWK{}, fmt.Errorf("failed to parse X.509 certificate: %w", err)
|
||||
}
|
||||
}
|
||||
jwkX509 := JWKX509Options{
|
||||
X5C: x5c,
|
||||
X5U: marshal.X5U,
|
||||
}
|
||||
marshalCopy.X5C = slices.Clone(marshal.X5C)
|
||||
marshalCopy.X5T = marshal.X5T
|
||||
marshalCopy.X5TS256 = marshal.X5TS256
|
||||
marshalCopy.X5U = marshal.X5U
|
||||
metadata := JWKMetadataOptions{
|
||||
ALG: marshal.ALG,
|
||||
KID: marshal.KID,
|
||||
KEYOPS: slices.Clone(marshal.KEYOPS),
|
||||
USE: marshal.USE,
|
||||
}
|
||||
marshalCopy.ALG = marshal.ALG
|
||||
marshalCopy.KID = marshal.KID
|
||||
marshalCopy.KEYOPS = slices.Clone(marshal.KEYOPS)
|
||||
marshalCopy.USE = marshal.USE
|
||||
opts := JWKOptions{
|
||||
Metadata: metadata,
|
||||
Marshal: options,
|
||||
Validate: validateOptions,
|
||||
X509: jwkX509,
|
||||
}
|
||||
j := JWK{
|
||||
key: key,
|
||||
marshal: marshalCopy,
|
||||
options: opts,
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// base64urlTrailingPadding removes trailing padding before decoding a string from base64url. Some non-RFC compliant
|
||||
// JWKS contain padding at the end values for base64url encoded public keys.
|
||||
//
|
||||
// Trailing padding is required to be removed from base64url encoded keys.
|
||||
// RFC 7517 defines base64url the same as RFC 7515 Section 2:
|
||||
// https://datatracker.ietf.org/doc/html/rfc7517#section-1.1
|
||||
// https://datatracker.ietf.org/doc/html/rfc7515#section-2
|
||||
func base64urlTrailingPadding(s string) ([]byte, error) {
|
||||
s = strings.TrimRight(s, "=")
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func bigIntToBase64RawURL(i *big.Int, l uint) string {
|
||||
var b []byte
|
||||
if l != 0 {
|
||||
b = make([]byte, l)
|
||||
i.FillBytes(b)
|
||||
} else {
|
||||
b = i.Bytes()
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
311
vendor/github.com/MicahParks/jwkset/storage.go
generated
vendored
311
vendor/github.com/MicahParks/jwkset/storage.go
generated
vendored
@@ -1,311 +0,0 @@
|
||||
package jwkset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrKeyNotFound is returned by a Storage implementation when a key is not found.
|
||||
ErrKeyNotFound = errors.New("key not found")
|
||||
// ErrInvalidHTTPStatusCode is returned when the HTTP status code is invalid.
|
||||
ErrInvalidHTTPStatusCode = errors.New("invalid HTTP status code")
|
||||
)
|
||||
|
||||
// Storage handles storage operations for a JWKSet.
|
||||
type Storage interface {
|
||||
// KeyDelete deletes a key from the storage. It will return ok as true if the key was present for deletion.
|
||||
KeyDelete(ctx context.Context, keyID string) (ok bool, err error)
|
||||
// KeyRead reads a key from the storage. If the key is not present, it returns ErrKeyNotFound. Any pointers returned
|
||||
// should be considered read-only.
|
||||
KeyRead(ctx context.Context, keyID string) (JWK, error)
|
||||
// KeyReadAll reads a snapshot of all keys from storage. As with ReadKey, any pointers returned should be
|
||||
// considered read-only.
|
||||
KeyReadAll(ctx context.Context) ([]JWK, error)
|
||||
// KeyWrite writes a key to the storage. If the key already exists, it will be overwritten. After writing a key,
|
||||
// any pointers written should be considered owned by the underlying storage.
|
||||
KeyWrite(ctx context.Context, jwk JWK) error
|
||||
|
||||
// JSON creates the JSON representation of the JWKSet.
|
||||
JSON(ctx context.Context) (json.RawMessage, error)
|
||||
// JSONPublic creates the JSON representation of the public keys in JWKSet.
|
||||
JSONPublic(ctx context.Context) (json.RawMessage, error)
|
||||
// JSONPrivate creates the JSON representation of the JWKSet public and private key material.
|
||||
JSONPrivate(ctx context.Context) (json.RawMessage, error)
|
||||
// JSONWithOptions creates the JSON representation of the JWKSet with the given options. These options override whatever
|
||||
// options are set on the individual JWKs.
|
||||
JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error)
|
||||
// Marshal transforms the JWK Set's current state into a Go type that can be marshaled into JSON.
|
||||
Marshal(ctx context.Context) (JWKSMarshal, error)
|
||||
// MarshalWithOptions transforms the JWK Set's current state into a Go type that can be marshaled into JSON with the
|
||||
// given options. These options override whatever options are set on the individual JWKs.
|
||||
MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error)
|
||||
}
|
||||
|
||||
var _ Storage = &MemoryJWKSet{}
|
||||
|
||||
type MemoryJWKSet struct {
|
||||
set []JWK
|
||||
mux sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryStorage creates a new in-memory Storage implementation.
|
||||
func NewMemoryStorage() *MemoryJWKSet {
|
||||
return &MemoryJWKSet{}
|
||||
}
|
||||
|
||||
func (m *MemoryJWKSet) KeyDelete(_ context.Context, keyID string) (ok bool, err error) {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
for i, jwk := range m.set {
|
||||
if jwk.Marshal().KID == keyID {
|
||||
m.set = append(m.set[:i], m.set[i+1:]...)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
func (m *MemoryJWKSet) KeyRead(_ context.Context, keyID string) (JWK, error) {
|
||||
m.mux.RLock()
|
||||
defer m.mux.RUnlock()
|
||||
for _, jwk := range m.set {
|
||||
if jwk.Marshal().KID == keyID {
|
||||
return jwk, nil
|
||||
}
|
||||
}
|
||||
return JWK{}, fmt.Errorf("%w: kid %q", ErrKeyNotFound, keyID)
|
||||
}
|
||||
func (m *MemoryJWKSet) KeyReadAll(_ context.Context) ([]JWK, error) {
|
||||
m.mux.RLock()
|
||||
defer m.mux.RUnlock()
|
||||
return slices.Clone(m.set), nil
|
||||
}
|
||||
func (m *MemoryJWKSet) KeyWrite(_ context.Context, jwk JWK) error {
|
||||
m.mux.Lock()
|
||||
defer m.mux.Unlock()
|
||||
m.set = append(m.set, jwk)
|
||||
return nil
|
||||
}
|
||||
func (m *MemoryJWKSet) JSON(ctx context.Context) (json.RawMessage, error) {
|
||||
jwks, err := m.Marshal(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal JWK Set: %w", err)
|
||||
}
|
||||
return json.Marshal(jwks)
|
||||
}
|
||||
func (m *MemoryJWKSet) JSONPublic(ctx context.Context) (json.RawMessage, error) {
|
||||
return m.JSONWithOptions(ctx, JWKMarshalOptions{}, JWKValidateOptions{})
|
||||
}
|
||||
func (m *MemoryJWKSet) JSONPrivate(ctx context.Context) (json.RawMessage, error) {
|
||||
marshalOptions := JWKMarshalOptions{
|
||||
Private: true,
|
||||
}
|
||||
return m.JSONWithOptions(ctx, marshalOptions, JWKValidateOptions{})
|
||||
}
|
||||
func (m *MemoryJWKSet) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) {
|
||||
jwks, err := m.MarshalWithOptions(ctx, marshalOptions, validationOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal JWK Set with options: %w", err)
|
||||
}
|
||||
return json.Marshal(jwks)
|
||||
}
|
||||
func (m *MemoryJWKSet) Marshal(ctx context.Context) (JWKSMarshal, error) {
|
||||
keys, err := m.KeyReadAll(ctx)
|
||||
if err != nil {
|
||||
return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err)
|
||||
}
|
||||
jwks := JWKSMarshal{}
|
||||
for _, key := range keys {
|
||||
jwks.Keys = append(jwks.Keys, key.Marshal())
|
||||
}
|
||||
return jwks, nil
|
||||
}
|
||||
func (m *MemoryJWKSet) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) {
|
||||
jwks := JWKSMarshal{}
|
||||
|
||||
keys, err := m.KeyReadAll(ctx)
|
||||
if err != nil {
|
||||
return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
options := key.options
|
||||
options.Marshal = marshalOptions
|
||||
options.Validate = validationOptions
|
||||
marshal, err := keyMarshal(key.Key(), options)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrOptions) {
|
||||
continue
|
||||
}
|
||||
return JWKSMarshal{}, fmt.Errorf("failed to marshal key: %w", err)
|
||||
}
|
||||
jwks.Keys = append(jwks.Keys, marshal)
|
||||
}
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
// HTTPClientStorageOptions are used to configure the behavior of NewStorageFromHTTP.
|
||||
type HTTPClientStorageOptions struct {
|
||||
// Client is the HTTP client to use for requests.
|
||||
//
|
||||
// This defaults to http.DefaultClient.
|
||||
Client *http.Client
|
||||
|
||||
// Ctx is used when performing HTTP requests. It is also used to end the refresh goroutine when it's no longer
|
||||
// needed.
|
||||
//
|
||||
// This defaults to context.Background().
|
||||
Ctx context.Context
|
||||
|
||||
// HTTPExpectedStatus is the expected HTTP status code for the HTTP request.
|
||||
//
|
||||
// This defaults to http.StatusOK.
|
||||
HTTPExpectedStatus int
|
||||
|
||||
// HTTPMethod is the HTTP method to use for the HTTP request.
|
||||
//
|
||||
// This defaults to http.MethodGet.
|
||||
HTTPMethod string
|
||||
|
||||
// HTTPTimeout is the timeout for the HTTP request. When the Ctx option is also provided, this value is used for a
|
||||
// child context.
|
||||
//
|
||||
// This defaults to time.Minute.
|
||||
HTTPTimeout time.Duration
|
||||
|
||||
// NoErrorReturnFirstHTTPReq will create the Storage without error if the first HTTP request fails.
|
||||
NoErrorReturnFirstHTTPReq bool
|
||||
|
||||
// RefreshErrorHandler is a function that consumes errors that happen during an HTTP refresh. This is only effectual
|
||||
// if RefreshInterval is set.
|
||||
//
|
||||
// If NoErrorReturnFirstHTTPReq is set, this function will be called when if the first HTTP request fails.
|
||||
RefreshErrorHandler func(ctx context.Context, err error)
|
||||
|
||||
// RefreshInterval is the interval at which the HTTP URL is refreshed and the JWK Set is processed. This option will
|
||||
// launch a "refresh goroutine" to refresh the remote HTTP resource at the given interval.
|
||||
//
|
||||
// Provide the Ctx option to end the goroutine when it's no longer needed.
|
||||
RefreshInterval time.Duration
|
||||
|
||||
// ValidateOptions are the options to use when validating the JWKs.
|
||||
ValidateOptions JWKValidateOptions
|
||||
}
|
||||
|
||||
type httpStorage struct {
|
||||
options HTTPClientStorageOptions
|
||||
refresh func(ctx context.Context) error
|
||||
Storage
|
||||
}
|
||||
|
||||
// NewStorageFromHTTP creates a new Storage implementation that processes a remote HTTP resource for a JWK Set. If
|
||||
// the RefreshInterval option is not set, the remote HTTP resource will be requested and processed before returning. If
|
||||
// the RefreshInterval option is set, a background goroutine will be launched to refresh the remote HTTP resource and
|
||||
// not block the return of this function.
|
||||
func NewStorageFromHTTP(remoteJWKSetURL string, options HTTPClientStorageOptions) (Storage, error) {
|
||||
if options.Client == nil {
|
||||
options.Client = http.DefaultClient
|
||||
}
|
||||
if options.Ctx == nil {
|
||||
options.Ctx = context.Background()
|
||||
}
|
||||
if options.HTTPExpectedStatus == 0 {
|
||||
options.HTTPExpectedStatus = http.StatusOK
|
||||
}
|
||||
if options.HTTPTimeout == 0 {
|
||||
options.HTTPTimeout = time.Minute
|
||||
}
|
||||
if options.HTTPMethod == "" {
|
||||
options.HTTPMethod = http.MethodGet
|
||||
}
|
||||
store := NewMemoryStorage()
|
||||
_, err := url.ParseRequestURI(remoteJWKSetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse given URL %q: %w", remoteJWKSetURL, err)
|
||||
}
|
||||
|
||||
refresh := func(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, options.HTTPMethod, remoteJWKSetURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create HTTP request for JWK Set refresh: %w", err)
|
||||
}
|
||||
resp, err := options.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform HTTP request for JWK Set refresh: %w", err)
|
||||
}
|
||||
//goland:noinspection GoUnhandledErrorResult
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != options.HTTPExpectedStatus {
|
||||
return fmt.Errorf("%w: %d", ErrInvalidHTTPStatusCode, resp.StatusCode)
|
||||
}
|
||||
var jwks JWKSMarshal
|
||||
err = json.NewDecoder(resp.Body).Decode(&jwks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode JWK Set response: %w", err)
|
||||
}
|
||||
store.mux.Lock()
|
||||
defer store.mux.Unlock()
|
||||
store.set = make([]JWK, len(jwks.Keys)) // Clear local cache in case of key revocation.
|
||||
for i, marshal := range jwks.Keys {
|
||||
marshalOptions := JWKMarshalOptions{
|
||||
Private: true,
|
||||
}
|
||||
jwk, err := NewJWKFromMarshal(marshal, marshalOptions, options.ValidateOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JWK from JWK Marshal: %w", err)
|
||||
}
|
||||
store.set[i] = jwk
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.RefreshInterval != 0 {
|
||||
go func() { // Refresh goroutine.
|
||||
ticker := time.NewTicker(options.RefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-options.Ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
ctx, cancel := context.WithTimeout(options.Ctx, options.HTTPTimeout)
|
||||
err := refresh(ctx)
|
||||
cancel()
|
||||
if err != nil && options.RefreshErrorHandler != nil {
|
||||
options.RefreshErrorHandler(ctx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
s := httpStorage{
|
||||
options: options,
|
||||
refresh: refresh,
|
||||
Storage: store,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(options.Ctx, options.HTTPTimeout)
|
||||
defer cancel()
|
||||
err = refresh(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if options.NoErrorReturnFirstHTTPReq {
|
||||
if options.RefreshErrorHandler != nil {
|
||||
options.RefreshErrorHandler(ctx, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to perform first HTTP request for JWK Set: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
125
vendor/github.com/MicahParks/jwkset/x509.go
generated
vendored
125
vendor/github.com/MicahParks/jwkset/x509.go
generated
vendored
@@ -1,125 +0,0 @@
|
||||
package jwkset
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrX509Infer is returned when the key type cannot be inferred from the PEM block type.
|
||||
ErrX509Infer = errors.New("failed to infer X509 key type")
|
||||
)
|
||||
|
||||
// LoadCertificate loads an X509 certificate from a PEM block.
|
||||
func LoadCertificate(pemBlock []byte) (*x509.Certificate, error) {
|
||||
cert, err := x509.ParseCertificate(pemBlock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificates: %w", err)
|
||||
}
|
||||
switch cert.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// LoadCertificates loads X509 certificates from raw PEM data. It can be useful in loading X5U remote resources.
|
||||
func LoadCertificates(rawPEM []byte) ([]*x509.Certificate, error) {
|
||||
b := make([]byte, 0)
|
||||
for {
|
||||
block, rest := pem.Decode(rawPEM)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
rawPEM = rest
|
||||
if block.Type == "CERTIFICATE" {
|
||||
b = append(b, block.Bytes...)
|
||||
}
|
||||
}
|
||||
certs, err := x509.ParseCertificates(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificates: %w", err)
|
||||
}
|
||||
for _, cert := range certs {
|
||||
switch cert.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey)
|
||||
}
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// LoadX509KeyInfer loads an X509 key from a PEM block.
|
||||
func LoadX509KeyInfer(pemBlock *pem.Block) (key any, err error) {
|
||||
switch pemBlock.Type {
|
||||
case "EC PRIVATE KEY":
|
||||
key, err = loadECPrivate(pemBlock)
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err = loadPKCS1Private(pemBlock)
|
||||
case "RSA PUBLIC KEY":
|
||||
key, err = loadPKCS1Public(pemBlock)
|
||||
case "PRIVATE KEY":
|
||||
key, err = loadPKCS8Private(pemBlock)
|
||||
case "PUBLIC KEY":
|
||||
key, err = loadPKIXPublic(pemBlock)
|
||||
default:
|
||||
return nil, ErrX509Infer
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load key from inferred format %q: %w", key, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
func loadECPrivate(pemBlock *pem.Block) (priv *ecdsa.PrivateKey, err error) {
|
||||
priv, err = x509.ParseECPrivateKey(pemBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse EC private key: %w", err)
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
func loadPKCS1Public(pemBlock *pem.Block) (pub *rsa.PublicKey, err error) {
|
||||
pub, err = x509.ParsePKCS1PublicKey(pemBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PKCS1 public key: %w", err)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
func loadPKCS1Private(pemBlock *pem.Block) (priv *rsa.PrivateKey, err error) {
|
||||
priv, err = x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PKCS1 private key: %w", err)
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
func loadPKCS8Private(pemBlock *pem.Block) (priv any, err error) {
|
||||
priv, err = x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PKCS8 private key: %w", err)
|
||||
}
|
||||
switch priv.(type) {
|
||||
case *ecdh.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, *rsa.PrivateKey:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, priv)
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
func loadPKIXPublic(pemBlock *pem.Block) (pub any, err error) {
|
||||
pub, err = x509.ParsePKIXPublicKey(pemBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PKIX public key: %w", err)
|
||||
}
|
||||
switch pub.(type) {
|
||||
case *ecdh.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, pub)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
15
vendor/github.com/MicahParks/jwkset/x509_gen.sh
generated
vendored
15
vendor/github.com/MicahParks/jwkset/x509_gen.sh
generated
vendored
@@ -1,15 +0,0 @@
|
||||
# OpenSSL 3.0.10 1 Aug 2023 (Library: OpenSSL 3.0.10 1 Aug 2023)
|
||||
openssl req -newkey EC -pkeyopt ec_paramgen_curve:P-521 -noenc -keyout ec521.pem -x509 -out ec521.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com"
|
||||
openssl req -newkey ED25519 -noenc -keyout ed25519.pem -x509 -out ed25519.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com"
|
||||
openssl req -newkey RSA:4096 -noenc -keyout rsa4096.pem -x509 -out rsa4096.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com"
|
||||
|
||||
openssl pkey -in ec521.pem -pubout -out ec521pub.pem
|
||||
openssl pkey -in ed25519.pem -pubout -out ed25519pub.pem
|
||||
openssl pkey -in rsa4096.pem -pubout -out rsa4096pub.pem
|
||||
|
||||
# For the "RSA PRIVATE KEY" (PKCS#1) and "EC PRIVATE KEY" (SEC1) formats, the PEM files are generated using the
|
||||
# cmd/gen_pkcs1 and cmd/gen_ec Golang programs, respectively.
|
||||
|
||||
openssl dsaparam -out dsaparam.pem 2048
|
||||
openssl gendsa -out dsa.pem dsaparam.pem
|
||||
openssl dsa -in dsa.pem -pubout -out dsa_pub.pem
|
||||
201
vendor/github.com/MicahParks/keyfunc/v3/LICENSE
generated
vendored
201
vendor/github.com/MicahParks/keyfunc/v3/LICENSE
generated
vendored
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2021 Micah Parks
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
81
vendor/github.com/MicahParks/keyfunc/v3/README.md
generated
vendored
81
vendor/github.com/MicahParks/keyfunc/v3/README.md
generated
vendored
@@ -1,81 +0,0 @@
|
||||
[](https://pkg.go.dev/github.com/MicahParks/keyfunc/v3)
|
||||
|
||||
# keyfunc
|
||||
|
||||
The purpose of this package is to provide a
|
||||
[`jwt.Keyfunc`](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Keyfunc) for the
|
||||
[github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) package using a JSON Web Key Set (JWK Set) for parsing
|
||||
and verifying JSON Web Tokens (JWTs).
|
||||
|
||||
It's common for an identity providers, particularly those
|
||||
using [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
or [OpenID Connect](https://openid.net/developers/how-connect-works/), such
|
||||
as [Keycloak](https://github.com/MicahParks/keyfunc/blob/master/examples/keycloak/main.go)
|
||||
or [Amazon Cognito (AWS)](https://github.com/MicahParks/keyfunc/blob/master/examples/aws_cognito/main.go) to expose a
|
||||
JWK Set via an HTTPS endpoint. This package has the ability to consume that JWK Set and produce a
|
||||
[`jwt.Keyfunc`](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Keyfunc). It is important that a JWK Set endpoint is
|
||||
using HTTPS to ensure the keys are from the correct trusted source.
|
||||
|
||||
## Basic usage
|
||||
|
||||
For complete examples, please see the `examples` directory.
|
||||
|
||||
```go
|
||||
import "github.com/MicahParks/keyfunc/v3"
|
||||
```
|
||||
|
||||
### Step 1: Create the `keyfunc.Keyfunc`
|
||||
|
||||
The below example is for a remote HTTP resource.
|
||||
See [`examples/json/main.go`](https://github.com/MicahParks/keyfunc/blob/master/examples/json/main.go) for a JSON
|
||||
example.
|
||||
|
||||
```go
|
||||
// Create the keyfunc.Keyfunc.
|
||||
k, err := keyfunc.NewDefaultCtx(ctx, []string{server.URL}) // Context is used to end the refresh goroutine.
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create a keyfunc.Keyfunc from the server's URL.\nError: %s", err)
|
||||
}
|
||||
```
|
||||
|
||||
When using the `keyfunc.NewDefault` function, the JWK Set will be automatically refreshed using
|
||||
[`jwkset.NewDefaultHTTPClient`](https://pkg.go.dev/github.com/MicahParks/jwkset#NewHTTPClient). This does launch a "
|
||||
refresh goroutine". If you want the ability to end this goroutine, use the `keyfunc.NewDefaultCtx` function.
|
||||
|
||||
It is also possible to create a `keyfunc.Keyfunc` from given keys like HMAC shared secrets. See `examples/hmac/main.go`.
|
||||
|
||||
### Step 2: Use the `keyfunc.Keyfunc` to parse and verify JWTs
|
||||
|
||||
```go
|
||||
// Parse the JWT.
|
||||
parsed, err := jwt.Parse(signed, k.Keyfunc)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse the JWT.\nError: %s", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Additional features
|
||||
|
||||
This project's primary purpose is to provide a [`jwt.Keyfunc`](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Keyfunc)
|
||||
implementation for JWK Sets.
|
||||
|
||||
Since version `3.X.X`, this project has become a thin wrapper
|
||||
around [github.com/MicahParks/jwkset](https://github.com/MicahParks/jwkset). Newer versions contain a superset of
|
||||
features available in versions `2.X.X` and earlier, but some of the deep customization has been moved to the `jwkset`
|
||||
project. The intention behind this is to make `keyfunc` easier to use for most use cases.
|
||||
|
||||
Access the [`jwkset.Storage`](https://pkg.go.dev/github.com/MicahParks/jwkset#Storage) from a `keyfunc.Keyfunc` via
|
||||
the `.Storage()` method. Using the [github.com/MicahParks/jwkset](https://github.com/MicahParks/jwkset) package
|
||||
provides the below features, and more:
|
||||
|
||||
* An HTTP client that automatically updates one or more remote JWK Set resources.
|
||||
* An automatic refresh of remote HTTP resources when an unknown key ID (`kid`) is encountered.
|
||||
* X.509 URIs or embedded [certificate chains](https://pkg.go.dev/crypto/x509#Certificate), when a JWK contains them.
|
||||
* Support for private asymmetric keys.
|
||||
* Specified key operations and usage.
|
||||
|
||||
## Related projects
|
||||
|
||||
### [`github.com/MicahParks/jwkset`](https://github.com/MicahParks/jwkset):
|
||||
|
||||
A JWK Set implementation. The `keyfunc` project is a wrapper around this project.
|
||||
177
vendor/github.com/MicahParks/keyfunc/v3/keyfunc.go
generated
vendored
177
vendor/github.com/MicahParks/keyfunc/v3/keyfunc.go
generated
vendored
@@ -1,177 +0,0 @@
|
||||
package keyfunc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/MicahParks/jwkset"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrKeyfunc is returned when a keyfunc error occurs.
|
||||
ErrKeyfunc = errors.New("failed keyfunc")
|
||||
)
|
||||
|
||||
// Keyfunc is meant to be used as the jwt.Keyfunc function for github.com/golang-jwt/jwt/v5. It uses
|
||||
// github.com/MicahParks/jwkset as a JWK Set storage.
|
||||
type Keyfunc interface {
|
||||
Keyfunc(token *jwt.Token) (any, error)
|
||||
KeyfuncCtx(ctx context.Context) jwt.Keyfunc
|
||||
Storage() jwkset.Storage
|
||||
}
|
||||
|
||||
// Options are used to create a new Keyfunc.
|
||||
type Options struct {
|
||||
Ctx context.Context
|
||||
Storage jwkset.Storage
|
||||
UseWhitelist []jwkset.USE
|
||||
}
|
||||
|
||||
type keyfunc struct {
|
||||
ctx context.Context
|
||||
storage jwkset.Storage
|
||||
useWhitelist []jwkset.USE
|
||||
}
|
||||
|
||||
// New creates a new Keyfunc.
|
||||
func New(options Options) (Keyfunc, error) {
|
||||
ctx := options.Ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if options.Storage == nil {
|
||||
return nil, fmt.Errorf("%w: no JWK Set storage given in options", ErrKeyfunc)
|
||||
}
|
||||
k := keyfunc{
|
||||
ctx: ctx,
|
||||
storage: options.Storage,
|
||||
useWhitelist: options.UseWhitelist,
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// NewDefault creates a new Keyfunc with a default JWK Set storage and options.
|
||||
//
|
||||
// This will launch "refresh goroutine" to automatically refresh the remote HTTP resources.
|
||||
func NewDefault(urls []string) (Keyfunc, error) {
|
||||
return NewDefaultCtx(context.Background(), urls)
|
||||
}
|
||||
|
||||
// NewDefaultCtx creates a new Keyfunc with a default JWK Set storage and options. The context is used to end the
|
||||
// "refresh goroutine".
|
||||
//
|
||||
// This will launch "refresh goroutine" to automatically refresh the remote HTTP resources.
|
||||
func NewDefaultCtx(ctx context.Context, urls []string) (Keyfunc, error) {
|
||||
client, err := jwkset.NewDefaultHTTPClientCtx(ctx, urls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := Options{
|
||||
Storage: client,
|
||||
}
|
||||
return New(options)
|
||||
}
|
||||
|
||||
// NewJWKJSON creates a new Keyfunc from raw JWK JSON.
|
||||
func NewJWKJSON(raw json.RawMessage) (Keyfunc, error) {
|
||||
marshalOptions := jwkset.JWKMarshalOptions{
|
||||
Private: true,
|
||||
}
|
||||
jwk, err := jwkset.NewJWKFromRawJSON(raw, marshalOptions, jwkset.JWKValidateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not create JWK from raw JSON", errors.Join(err, ErrKeyfunc))
|
||||
}
|
||||
store := jwkset.NewMemoryStorage()
|
||||
err = store.KeyWrite(context.Background(), jwk)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not write JWK to storage", errors.Join(err, ErrKeyfunc))
|
||||
}
|
||||
options := Options{
|
||||
Storage: store,
|
||||
}
|
||||
return New(options)
|
||||
}
|
||||
|
||||
// NewJWKSetJSON creates a new Keyfunc from raw JWK Set JSON.
|
||||
func NewJWKSetJSON(raw json.RawMessage) (Keyfunc, error) {
|
||||
var jwks jwkset.JWKSMarshal
|
||||
err := json.Unmarshal(raw, &jwks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not unmarshal raw JWK Set JSON", errors.Join(err, ErrKeyfunc))
|
||||
}
|
||||
store, err := jwks.ToStorage()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not create JWK Set storage", errors.Join(err, ErrKeyfunc))
|
||||
}
|
||||
options := Options{
|
||||
Storage: store,
|
||||
}
|
||||
return New(options)
|
||||
}
|
||||
|
||||
func (k keyfunc) KeyfuncCtx(ctx context.Context) jwt.Keyfunc {
|
||||
return func(token *jwt.Token) (any, error) {
|
||||
kidInter, ok := token.Header[jwkset.HeaderKID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: could not find kid in JWT header", ErrKeyfunc)
|
||||
}
|
||||
kid, ok := kidInter.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: could not convert kid in JWT header to string", ErrKeyfunc)
|
||||
}
|
||||
algInter, ok := token.Header["alg"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: could not find alg in JWT header", ErrKeyfunc)
|
||||
}
|
||||
alg, ok := algInter.(string)
|
||||
if !ok {
|
||||
// For test coverage purposes, this should be impossible to reach because the JWT package rejects a token
|
||||
// without an alg parameter in the header before calling jwt.Keyfunc.
|
||||
return nil, fmt.Errorf(`%w: the JWT header did not contain the "alg" parameter, which is required by RFC 7515 section 4.1.1`, ErrKeyfunc)
|
||||
}
|
||||
|
||||
jwk, err := k.storage.KeyRead(ctx, kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not read JWK from storage", errors.Join(err, ErrKeyfunc))
|
||||
}
|
||||
|
||||
if a := jwk.Marshal().ALG.String(); a != "" && a != alg {
|
||||
return nil, fmt.Errorf(`%w: JWK "alg" parameter value %q does not match token "alg" parameter value %q`, ErrKeyfunc, a, alg)
|
||||
}
|
||||
if len(k.useWhitelist) > 0 {
|
||||
found := false
|
||||
for _, u := range k.useWhitelist {
|
||||
if jwk.Marshal().USE == u {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf(`%w: JWK "use" parameter value %q is not in whitelist`, ErrKeyfunc, jwk.Marshal().USE)
|
||||
}
|
||||
}
|
||||
|
||||
type publicKeyer interface {
|
||||
Public() crypto.PublicKey
|
||||
}
|
||||
|
||||
key := jwk.Key()
|
||||
pk, ok := key.(publicKeyer)
|
||||
if ok {
|
||||
key = pk.Public()
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
func (k keyfunc) Keyfunc(token *jwt.Token) (any, error) {
|
||||
keyF := k.KeyfuncCtx(k.ctx)
|
||||
return keyF(token)
|
||||
}
|
||||
func (k keyfunc) Storage() jwkset.Storage {
|
||||
return k.storage
|
||||
}
|
||||
6
vendor/modules.txt
vendored
6
vendor/modules.txt
vendored
@@ -38,15 +38,9 @@ github.com/Masterminds/semver/v3
|
||||
# github.com/Masterminds/sprig v2.22.0+incompatible
|
||||
## explicit
|
||||
github.com/Masterminds/sprig
|
||||
# github.com/MicahParks/jwkset v0.8.0
|
||||
## explicit; go 1.21
|
||||
github.com/MicahParks/jwkset
|
||||
# github.com/MicahParks/keyfunc/v2 v2.1.0
|
||||
## explicit; go 1.18
|
||||
github.com/MicahParks/keyfunc/v2
|
||||
# github.com/MicahParks/keyfunc/v3 v3.3.11
|
||||
## explicit; go 1.21
|
||||
github.com/MicahParks/keyfunc/v3
|
||||
# github.com/Microsoft/go-winio v0.6.2
|
||||
## explicit; go 1.21
|
||||
github.com/Microsoft/go-winio
|
||||
|
||||
Reference in New Issue
Block a user