Merge branch 'develop' into feature/lightbox-sidebar-metadata

This commit is contained in:
Cathie Integra 2026-04-27 12:03:10 +02:00
commit 974d9e0e0f
15 changed files with 814 additions and 420 deletions

16
NOTICE
View file

@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with
PhotoPrism. Any information relevant to third-party vendors listed below are
collected using common, reasonable means.
Date generated: 2026-04-15
Date generated: 2026-04-27
================================================================================
@ -1150,8 +1150,8 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/go-co-op/gocron/v2
Version: v2.21.0
License: MIT (https://github.com/go-co-op/gocron/blob/v2.21.0/LICENSE)
Version: v2.21.1
License: MIT (https://github.com/go-co-op/gocron/blob/v2.21.1/LICENSE)
MIT License
@ -6627,8 +6627,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/yalue/onnxruntime_go
Version: v1.27.0
License: MIT (https://github.com/yalue/onnxruntime_go/blob/v1.27.0/LICENSE)
Version: v1.28.0
License: MIT (https://github.com/yalue/onnxruntime_go/blob/v1.28.0/LICENSE)
Copyright (c) 2023 Nathan Otterness
@ -6893,8 +6893,8 @@ License: Apache-2.0 (https://github.com/zitadel/logging/blob/v0.7.0/LICENSE)
--------------------------------------------------------------------------------
Package: github.com/zitadel/oidc/v3
Version: v3.47.3
License: Apache-2.0 (https://github.com/zitadel/oidc/blob/v3.47.3/LICENSE)
Version: v3.47.5
License: Apache-2.0 (https://github.com/zitadel/oidc/blob/v3.47.5/LICENSE)
Apache License
Version 2.0, January 2004
@ -9323,7 +9323,7 @@ node-storage-shim ISC Michael Nahkies
passive-events-support MIT Ignas Damunskis <ignas3run@gmail.com>
photoswipe MIT Dmytro Semenov (https://dimsemenov.com)
playwright Apache-2.0 Microsoft Corporation
postcss MIT Andrey Sitnik <andrey@sitnik.ru>
postcss MIT Andrey Sitnik <andrey@sitnik.es>
postcss-import MIT Maxime Thirouin
postcss-loader MIT Andrey Sitnik <andrey@sitnik.ru>
postcss-preset-env MIT-0 n/a

View file

@ -380,7 +380,7 @@ services:
## Dummy OIDC Identity Provider
dummy-oidc:
image: photoprism/dummy-oidc:251210
image: photoprism/dummy-oidc:260427
stop_grace_period: 5s
labels:
- "traefik.enable=true"

View file

@ -1,23 +1,31 @@
module caos-test-op
go 1.24.0
go 1.26.2
require (
github.com/gorilla/mux v1.8.1
github.com/zitadel/oidc v1.13.5
gopkg.in/square/go-jose.v2 v2.6.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-jose/go-jose/v4 v4.1.4
github.com/google/uuid v1.6.0
github.com/zitadel/oidc/v3 v3.47.5
)
require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/muhlemmer/httpforwarded v0.1.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/zitadel/logging v0.6.2 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/zitadel/logging v0.7.0 // indirect
github.com/zitadel/schema v1.3.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

View file

@ -1,46 +1,69 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU=
github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4=
github.com/zitadel/oidc v1.13.5 h1:7jhh68NGZitLqwLiVU9Dtwa4IraJPFF1vS+4UupO93U=
github.com/zitadel/oidc v1.13.5/go.mod h1:rHs1DhU3Sv3tnI6bQRVlFa3u0lCwtR7S21WHY+yXgPA=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ=
github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ=
github.com/zitadel/oidc/v3 v3.47.5 h1:cR2z0oqa5XZkwpXQiPCUGqKtndrjHgEXb81y3oXocK4=
github.com/zitadel/oidc/v3 v3.47.5/go.mod h1:XxFh0666HRXycyrKmono+3gY0RACpYJLgy4r/+kliKY=
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,104 +1,164 @@
package mock
// revive:disable
// Dummy storage implementation for the test OIDC provider; lint strictness is relaxed intentionally.
import (
"time"
"github.com/zitadel/oidc/pkg/oidc"
"github.com/zitadel/oidc/pkg/op"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
)
// ConfClient represents a fixed client configuration used by the dummy provider.
// defaultRedirectURIs lists the redirect URIs accepted by the dummy provider.
// These match the development origins used by PhotoPrism's local compose stack.
var defaultRedirectURIs = []string{
"https://registered.com/callback",
"http://localhost:9999/callback",
"http://localhost:5556/auth/callback",
"custom://callback",
"https://localhost:8443/test/a/instructions-example/callback",
"https://op.certification.openid.net:62064/authz_cb",
"https://op.certification.openid.net:62064/authz_post",
"http://localhost:2342/api/v1/oidc/redirect",
"https://app.localssl.dev/api/v1/oidc/redirect",
}
// ConfClient is the dummy provider's op.Client implementation. It is permissive
// by design: any client_id is accepted and treated as a public web client so
// that local development flows can authenticate without prior registration.
type ConfClient struct {
id string
applicationType op.ApplicationType
authMethod oidc.AuthMethod
accessTokenType op.AccessTokenType
responseTypes []oidc.ResponseType
grantTypes []oidc.GrantType
ID string
accessTokenType op.AccessTokenType
devMode bool
}
func (c *ConfClient) GetID() string {
return c.ID
// NewClient returns a ConfClient configured for the given client_id.
// The web/native variants mirror the original v1 dummy behavior; everything
// else falls back to a permissive user-agent client.
func NewClient(id string) *ConfClient {
c := &ConfClient{
id: id,
devMode: true,
grantTypes: []oidc.GrantType{
oidc.GrantTypeCode,
oidc.GrantTypeRefreshToken,
},
}
switch id {
case "native":
c.applicationType = op.ApplicationTypeNative
c.authMethod = oidc.AuthMethodBasic
c.accessTokenType = op.AccessTokenTypeBearer
c.responseTypes = []oidc.ResponseType{
oidc.ResponseTypeCode,
oidc.ResponseTypeIDToken,
oidc.ResponseTypeIDTokenOnly,
}
case "user_agent":
c.applicationType = op.ApplicationTypeUserAgent
c.authMethod = oidc.AuthMethodNone
c.accessTokenType = op.AccessTokenTypeJWT
c.responseTypes = []oidc.ResponseType{
oidc.ResponseTypeIDToken,
oidc.ResponseTypeIDTokenOnly,
}
default:
// Default to a permissive confidential web client so that any unknown
// client_id (such as the ones PhotoPrism generates for its backend)
// can complete the authorization-code flow with client_secret_basic
// authentication and without PKCE, matching the v1 dummy's behavior.
c.applicationType = op.ApplicationTypeWeb
c.authMethod = oidc.AuthMethodBasic
c.accessTokenType = op.AccessTokenTypeBearer
c.responseTypes = []oidc.ResponseType{oidc.ResponseTypeCode}
}
return c
}
func (c *ConfClient) RedirectURIs() []string {
return []string{
"https://registered.com/callback",
"http://localhost:9999/callback",
"http://localhost:5556/auth/callback",
"custom://callback",
"https://localhost:8443/test/a/instructions-example/callback",
"https://op.certification.openid.net:62064/authz_cb",
"https://op.certification.openid.net:62064/authz_post",
"http://localhost:2342/api/v1/oidc/redirect",
"https://app.localssl.dev/api/v1/oidc/redirect",
}
// GetID returns the client_id.
func (c *ConfClient) GetID() string {
return c.id
}
// RedirectURIs returns the registered redirect URIs.
func (c *ConfClient) RedirectURIs() []string {
return defaultRedirectURIs
}
// PostLogoutRedirectURIs returns the registered post-logout redirect URIs.
func (c *ConfClient) PostLogoutRedirectURIs() []string {
return []string{}
}
// LoginURL points the OpenID provider at the dummy's login endpoint.
func (c *ConfClient) LoginURL(id string) string {
// return "authorize/callback?id=" + id
return "login?id=" + id
return "/login?id=" + id
}
// ApplicationType returns the OAuth application type.
func (c *ConfClient) ApplicationType() op.ApplicationType {
return c.applicationType
}
// AuthMethod returns the client's auth method.
func (c *ConfClient) AuthMethod() oidc.AuthMethod {
return c.authMethod
}
// IDTokenLifetime is the issued id_token lifetime.
func (c *ConfClient) IDTokenLifetime() time.Duration {
return 60 * time.Minute
}
// AccessTokenType returns the access token format.
func (c *ConfClient) AccessTokenType() op.AccessTokenType {
return c.accessTokenType
}
// ResponseTypes returns the supported response_type values.
func (c *ConfClient) ResponseTypes() []oidc.ResponseType {
return c.responseTypes
}
// GrantTypes returns the supported grant_type values.
func (c *ConfClient) GrantTypes() []oidc.GrantType {
return c.grantTypes
}
// DevMode permits non-compliant configurations so http callbacks are accepted.
func (c *ConfClient) DevMode() bool {
return c.devMode
}
// AllowedScopes returns nil to indicate that no scope restriction applies.
func (c *ConfClient) AllowedScopes() []string {
return nil
}
// RestrictAdditionalIdTokenScopes returns the input scopes unchanged.
func (c *ConfClient) RestrictAdditionalIdTokenScopes() func(scopes []string) []string {
return func(scopes []string) []string {
return scopes
}
return func(scopes []string) []string { return scopes }
}
// RestrictAdditionalAccessTokenScopes returns the input scopes unchanged.
func (c *ConfClient) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string {
return func(scopes []string) []string {
return scopes
}
return func(scopes []string) []string { return scopes }
}
// IsScopeAllowed permits any scope so callers can extend the dummy easily.
func (c *ConfClient) IsScopeAllowed(scope string) bool {
return false
return true
}
// IDTokenUserinfoClaimsAssertion forces userinfo claims into the id_token to
// match the original v1 dummy's behavior.
func (c *ConfClient) IDTokenUserinfoClaimsAssertion() bool {
return false
return true
}
// ClockSkew returns no clock skew tolerance.
func (c *ConfClient) ClockSkew() time.Duration {
return 0
}

View file

@ -1,110 +1,172 @@
// Package mock provides an in-memory OIDC storage used by the dummy provider in development.
package mock
// revive:disable
// Dummy storage implementation for the test OIDC provider; lint strictness is relaxed intentionally.
import (
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"sync"
"time"
"gopkg.in/square/go-jose.v2"
jose "github.com/go-jose/go-jose/v4"
"github.com/google/uuid"
"github.com/zitadel/oidc/pkg/oidc"
"github.com/zitadel/oidc/pkg/op"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
)
var (
a = &AuthRequest{}
t bool
c string
state string
)
// Compile-time assertions that AuthStorage satisfies the v3 op.Storage interface.
var _ op.Storage = (*AuthStorage)(nil)
// AuthStorage keeps ephemeral keys and auth state for the dummy provider.
// AuthStorage is the in-memory storage backing the dummy OIDC provider.
// It implements the op.Storage interface required by zitadel/oidc/v3.
type AuthStorage struct {
key *rsa.PrivateKey
kid string
lock sync.Mutex
signingKey signingKey
authRequests map[string]*AuthRequest
codes map[string]string
tokens map[string]*tokenEntry
refreshTokens map[string]*refreshTokenEntry
}
// NewAuthStorage constructs the dummy storage with a fresh RSA key.
func NewAuthStorage() op.Storage {
key, err := rsa.GenerateKey(rand.Reader, 2048)
// AuthRequest captures the subset of an OIDC authorization request that the
// dummy provider needs to satisfy the op.AuthRequest interface.
type AuthRequest struct {
ID string
ClientID string
Subject string
Scopes []string
RedirectURI string
ResponseType oidc.ResponseType
ResponseMode oidc.ResponseMode
Nonce string
State string
CodeChallenge *oidc.CodeChallenge
authTime time.Time
done bool
}
type signingKey struct {
id string
algorithm jose.SignatureAlgorithm
key *rsa.PrivateKey
}
// SignatureAlgorithm returns the signing algorithm used by the key.
func (s *signingKey) SignatureAlgorithm() jose.SignatureAlgorithm { return s.algorithm }
// Key returns the private key used for signing.
func (s *signingKey) Key() any { return s.key }
// ID returns the key identifier.
func (s *signingKey) ID() string { return s.id }
type publicKey struct{ signingKey }
// ID returns the key identifier.
func (p *publicKey) ID() string { return p.id }
// Algorithm returns the JOSE algorithm of the key.
func (p *publicKey) Algorithm() jose.SignatureAlgorithm { return p.algorithm }
// Use returns the key usage; the dummy only signs.
func (p *publicKey) Use() string { return "sig" }
// Key returns the public part of the signing key.
func (p *publicKey) Key() any { return &p.key.PublicKey }
type tokenEntry struct {
id string
applicationID string
subject string
audience []string
scopes []string
expiration time.Time
refreshTokens string
}
type refreshTokenEntry struct {
id string
applicationID string
subject string
audience []string
scopes []string
authTime time.Time
amr []string
accessToken string
expiration time.Time
}
// NewAuthStorage creates an AuthStorage with a fresh RSA signing key.
func NewAuthStorage() *AuthStorage {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
b := make([]byte, 16)
if _, err = rand.Read(b); err != nil {
panic(err)
}
return &AuthStorage{
key: key,
kid: string(b),
signingKey: signingKey{
id: uuid.NewString(),
algorithm: jose.RS256,
key: key,
},
authRequests: make(map[string]*AuthRequest),
codes: make(map[string]string),
tokens: make(map[string]*tokenEntry),
refreshTokens: make(map[string]*refreshTokenEntry),
}
}
// AuthRequest is a lightweight auth request implementation for tests.
type AuthRequest struct {
ID string
ResponseType oidc.ResponseType
ResponseMode oidc.ResponseMode
RedirectURI string
Nonce string
ClientID string
CodeChallenge *oidc.CodeChallenge
// MarkRequestDone flags an auth request as authenticated so the OP can issue a code.
func (s *AuthStorage) MarkRequestDone(id string) error {
s.lock.Lock()
defer s.lock.Unlock()
req, ok := s.authRequests[id]
if !ok {
return errors.New("auth request not found")
}
req.done = true
req.authTime = time.Now().UTC()
return nil
}
func (a *AuthRequest) GetACR() string {
return ""
}
// GetID returns the auth request id.
func (a *AuthRequest) GetID() string { return a.ID }
// GetACR returns the (unset) authentication context class reference.
func (a *AuthRequest) GetACR() string { return "" }
// GetAMR returns the authentication methods that were used.
func (a *AuthRequest) GetAMR() []string {
return []string{}
}
func (a *AuthRequest) GetAudience() []string {
return []string{
a.ClientID,
if a.done {
return []string{"pwd"}
}
return nil
}
func (a *AuthRequest) GetAuthTime() time.Time {
return time.Now().UTC()
}
// GetAudience returns the audience for issued tokens.
func (a *AuthRequest) GetAudience() []string { return []string{a.ClientID} }
func (a *AuthRequest) GetClientID() string {
return a.ClientID
}
// GetAuthTime returns the time the user authenticated.
func (a *AuthRequest) GetAuthTime() time.Time { return a.authTime }
func (a *AuthRequest) GetCodeChallenge() *oidc.CodeChallenge {
fmt.Println("GetCodeChallenge: ", a.CodeChallenge.Challenge, a.CodeChallenge.Method)
return a.CodeChallenge
}
// GetClientID returns the client identifier.
func (a *AuthRequest) GetClientID() string { return a.ClientID }
func (a *AuthRequest) GetID() string {
return a.ID
}
// GetCodeChallenge returns the PKCE code challenge if any.
func (a *AuthRequest) GetCodeChallenge() *oidc.CodeChallenge { return a.CodeChallenge }
func (a *AuthRequest) GetNonce() string {
return a.Nonce
}
// GetNonce returns the request nonce.
func (a *AuthRequest) GetNonce() string { return a.Nonce }
func (a *AuthRequest) GetRedirectURI() string {
return a.RedirectURI
}
// GetRedirectURI returns the client redirect URI.
func (a *AuthRequest) GetRedirectURI() string { return a.RedirectURI }
func (a *AuthRequest) GetResponseType() oidc.ResponseType {
return a.ResponseType
}
// GetResponseType returns the requested response_type.
func (a *AuthRequest) GetResponseType() oidc.ResponseType { return a.ResponseType }
// GetResponseMode returns the response_mode (defaulting to query).
func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
if a.ResponseMode != "" {
return a.ResponseMode
@ -112,229 +174,372 @@ func (a *AuthRequest) GetResponseMode() oidc.ResponseMode {
return oidc.ResponseModeQuery
}
// GetScopes returns the scopes granted to the request.
func (a *AuthRequest) GetScopes() []string {
return []string{
"openid",
"profile",
"email",
if len(a.Scopes) > 0 {
return a.Scopes
}
return []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail}
}
func (a *AuthRequest) SetCurrentScopes(scopes []string) {}
// GetState returns the state parameter that the client provided.
func (a *AuthRequest) GetState() string { return a.State }
func (a *AuthRequest) GetState() string {
return state
}
// GetSubject returns the authenticated user's subject identifier.
func (a *AuthRequest) GetSubject() string { return a.Subject }
func (a *AuthRequest) GetSubject() string {
return "sub00000001"
}
// Done reports whether the request has completed authentication.
func (a *AuthRequest) Done() bool { return a.done }
func (a *AuthRequest) Done() bool {
return true
}
// Health implements op.Storage.
func (s *AuthStorage) Health(_ context.Context) error { return nil }
func (s *AuthStorage) Health(ctx context.Context) error {
return nil
}
// CreateAuthRequest stores a new request and returns it as op.AuthRequest.
func (s *AuthStorage) CreateAuthRequest(_ context.Context, authReq *oidc.AuthRequest, _ string) (op.AuthRequest, error) {
s.lock.Lock()
defer s.lock.Unlock()
// CreateAuthRequest stores the incoming request in memory and returns a stub AuthRequest.
func (s *AuthStorage) CreateAuthRequest(_ context.Context, authReq *oidc.AuthRequest, userId string) (op.AuthRequest, error) {
fmt.Println("Userid: ", userId)
fmt.Println("CreateAuthRequest CodeChallenge: ", authReq.CodeChallenge)
fmt.Println("CreateAuthRequest CodeChallengeMethod: ", authReq.CodeChallengeMethod)
fmt.Println("CreateAuthRequest State: ", authReq.State)
fmt.Println("CreateAuthRequest ClientID: ", authReq.ClientID)
fmt.Println("CreateAuthRequest ResponseType: ", authReq.ResponseType)
fmt.Println("CreateAuthRequest Nonce: ", authReq.Nonce)
fmt.Println("CreateAuthRequest Scopes: ", authReq.Scopes)
fmt.Println("CreateAuthRequest Display: ", authReq.Display)
fmt.Println("CreateAuthRequest LoginHint: ", authReq.LoginHint)
fmt.Println("CreateAuthRequest IDTokenHint: ", authReq.IDTokenHint)
a = &AuthRequest{
ID: "authReqUserAgentId",
id := uuid.NewString()
req := &AuthRequest{
ID: id,
ClientID: authReq.ClientID,
Subject: "sub00000001",
Scopes: authReq.Scopes,
RedirectURI: authReq.RedirectURI,
ResponseType: authReq.ResponseType,
ResponseMode: authReq.ResponseMode,
Nonce: authReq.Nonce,
RedirectURI: authReq.RedirectURI,
State: authReq.State,
}
if authReq.CodeChallenge != "" {
a.CodeChallenge = &oidc.CodeChallenge{
req.CodeChallenge = &oidc.CodeChallenge{
Challenge: authReq.CodeChallenge,
Method: authReq.CodeChallengeMethod,
}
}
state = authReq.State
t = false
return a, nil
}
func (s *AuthStorage) AuthRequestByCode(_ context.Context, code string) (op.AuthRequest, error) {
if code != c {
return nil, errors.New("invalid code")
}
return a, nil
}
func (s *AuthStorage) SaveAuthCode(_ context.Context, id, code string) error {
if a.ID != id {
return errors.New("SaveAuthCode: not found")
}
c = code
return nil
}
func (s *AuthStorage) DeleteAuthRequest(context.Context, string) error {
t = true
return nil
s.authRequests[id] = req
return req, nil
}
// AuthRequestByID looks up an auth request by id.
func (s *AuthStorage) AuthRequestByID(_ context.Context, id string) (op.AuthRequest, error) {
fmt.Println("AuthRequestByID: ", id)
if id != "authReqUserAgentId:usertoken" || t {
return nil, errors.New("AuthRequestByID: not found")
s.lock.Lock()
defer s.lock.Unlock()
req, ok := s.authRequests[id]
if !ok {
return nil, errors.New("auth request not found")
}
return a, nil
return req, nil
}
func (s *AuthStorage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) {
return "loginId", time.Now().UTC().Add(5 * time.Minute), nil
}
func (s *AuthStorage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) {
return "loginId", "refreshToken", time.Now().UTC().Add(5 * time.Minute), nil
}
func (s *AuthStorage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
if refreshToken != c {
return nil, errors.New("invalid token")
// AuthRequestByCode looks up an auth request by an issued authorization code.
func (s *AuthStorage) AuthRequestByCode(_ context.Context, code string) (op.AuthRequest, error) {
s.lock.Lock()
defer s.lock.Unlock()
id, ok := s.codes[code]
if !ok {
return nil, errors.New("code not found")
}
return a, nil
req, ok := s.authRequests[id]
if !ok {
return nil, errors.New("auth request not found")
}
return req, nil
}
// SaveAuthCode stores the code-to-request mapping.
func (s *AuthStorage) SaveAuthCode(_ context.Context, id, code string) error {
s.lock.Lock()
defer s.lock.Unlock()
s.codes[code] = id
return nil
}
// DeleteAuthRequest removes an auth request and any associated code.
func (s *AuthStorage) DeleteAuthRequest(_ context.Context, id string) error {
s.lock.Lock()
defer s.lock.Unlock()
delete(s.authRequests, id)
for code, reqID := range s.codes {
if reqID == id {
delete(s.codes, code)
}
}
return nil
}
// CreateAccessToken issues an opaque access token id.
func (s *AuthStorage) CreateAccessToken(_ context.Context, request op.TokenRequest) (string, time.Time, error) {
s.lock.Lock()
defer s.lock.Unlock()
tok := s.newAccessTokenLocked(request, "")
return tok.id, tok.expiration, nil
}
// CreateAccessAndRefreshTokens issues an access token alongside a refresh token.
func (s *AuthStorage) CreateAccessAndRefreshTokens(_ context.Context, request op.TokenRequest, currentRefreshToken string) (string, string, time.Time, error) {
s.lock.Lock()
defer s.lock.Unlock()
if currentRefreshToken != "" {
if _, ok := s.refreshTokens[currentRefreshToken]; !ok {
return "", "", time.Time{}, errors.New("invalid refresh token")
}
delete(s.refreshTokens, currentRefreshToken)
}
refreshID := uuid.NewString()
tok := s.newAccessTokenLocked(request, refreshID)
authTime := time.Now().UTC()
var amr []string
if authReq, ok := request.(*AuthRequest); ok {
authTime = authReq.authTime
amr = authReq.GetAMR()
}
s.refreshTokens[refreshID] = &refreshTokenEntry{
id: refreshID,
applicationID: tok.applicationID,
subject: tok.subject,
audience: tok.audience,
scopes: tok.scopes,
authTime: authTime,
amr: amr,
accessToken: tok.id,
expiration: time.Now().UTC().Add(5 * time.Hour),
}
return tok.id, refreshID, tok.expiration, nil
}
func (s *AuthStorage) newAccessTokenLocked(request op.TokenRequest, refreshID string) *tokenEntry {
id := uuid.NewString()
tok := &tokenEntry{
id: id,
applicationID: clientIDFromRequest(request),
subject: request.GetSubject(),
audience: request.GetAudience(),
scopes: request.GetScopes(),
expiration: time.Now().UTC().Add(5 * time.Minute),
refreshTokens: refreshID,
}
s.tokens[id] = tok
return tok
}
func clientIDFromRequest(request op.TokenRequest) string {
switch req := request.(type) {
case *AuthRequest:
return req.ClientID
case *refreshTokenRequest:
return req.applicationID
}
return ""
}
// TokenRequestByRefreshToken returns a refresh-token-backed token request.
func (s *AuthStorage) TokenRequestByRefreshToken(_ context.Context, refreshToken string) (op.RefreshTokenRequest, error) {
s.lock.Lock()
defer s.lock.Unlock()
rt, ok := s.refreshTokens[refreshToken]
if !ok {
return nil, errors.New("invalid refresh token")
}
return &refreshTokenRequest{
id: rt.id,
applicationID: rt.applicationID,
subject: rt.subject,
audience: rt.audience,
scopes: rt.scopes,
authTime: rt.authTime,
amr: rt.amr,
}, nil
}
// TerminateSession removes any tokens for a given user/client.
func (s *AuthStorage) TerminateSession(_ context.Context, userID, clientID string) error {
return nil
}
// RevokeToken is a no-op for the dummy implementation.
func (s *AuthStorage) RevokeToken(_ context.Context, tokenOrTokenID string, userID string, clientID string) *oidc.Error {
return nil
}
func (s *AuthStorage) GetSigningKey(_ context.Context, keyCh chan<- jose.SigningKey) {
// keyCh <- jose.SigningKey{Algorithm: jose.RS256, Key: s.key}
keyCh <- jose.SigningKey{Algorithm: jose.RS256, Key: &jose.JSONWebKey{Key: s.key, KeyID: s.kid}}
}
func (s *AuthStorage) GetKey(_ context.Context) (*rsa.PrivateKey, error) {
return s.key, nil
}
func (s *AuthStorage) GetKeySet(_ context.Context) (*jose.JSONWebKeySet, error) {
pubkey := s.key.Public()
wrongkey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
s.lock.Lock()
defer s.lock.Unlock()
for id, tok := range s.tokens {
if tok.subject == userID && tok.applicationID == clientID {
delete(s.tokens, id)
}
}
return &jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{
{
Key: wrongkey.Public(),
Use: "sig",
Algorithm: "RS256",
KeyID: "wrongkey0002",
}, {
Key: pubkey,
Use: "sig",
Algorithm: "RS256",
KeyID: s.kid,
},
},
}, nil
for id, rt := range s.refreshTokens {
if rt.subject == userID && rt.applicationID == clientID {
delete(s.refreshTokens, id)
}
}
return nil
}
func (s *AuthStorage) GetKeyByIDAndUserID(_ context.Context, _, _ string) (*jose.JSONWebKey, error) {
pubkey := s.key.Public()
return &jose.JSONWebKey{
Key: pubkey,
Use: "sig",
Algorithm: "RS256",
KeyID: s.kid,
}, nil
// GetRefreshTokenInfo returns the user and token id for a refresh token.
func (s *AuthStorage) GetRefreshTokenInfo(_ context.Context, _, token string) (string, string, error) {
s.lock.Lock()
defer s.lock.Unlock()
rt, ok := s.refreshTokens[token]
if !ok {
return "", "", op.ErrInvalidRefreshToken
}
return rt.subject, rt.id, nil
}
// RevokeToken removes an access or refresh token.
func (s *AuthStorage) RevokeToken(_ context.Context, tokenOrID, _, clientID string) *oidc.Error {
s.lock.Lock()
defer s.lock.Unlock()
if tok, ok := s.tokens[tokenOrID]; ok {
if clientID != "" && tok.applicationID != clientID {
return oidc.ErrInvalidClient().WithDescription("token was not issued for this client")
}
delete(s.tokens, tokenOrID)
return nil
}
if rt, ok := s.refreshTokens[tokenOrID]; ok {
if clientID != "" && rt.applicationID != clientID {
return oidc.ErrInvalidClient().WithDescription("token was not issued for this client")
}
delete(s.refreshTokens, tokenOrID)
delete(s.tokens, rt.accessToken)
}
return nil
}
// SigningKey returns the static dummy signing key.
func (s *AuthStorage) SigningKey(_ context.Context) (op.SigningKey, error) {
return &s.signingKey, nil
}
// SignatureAlgorithms reports the algorithms this dummy supports.
func (s *AuthStorage) SignatureAlgorithms(_ context.Context) ([]jose.SignatureAlgorithm, error) {
return []jose.SignatureAlgorithm{s.signingKey.algorithm}, nil
}
// KeySet returns the dummy's public JWKS.
func (s *AuthStorage) KeySet(_ context.Context) ([]op.Key, error) {
return []op.Key{&publicKey{s.signingKey}}, nil
}
// GetClientByClientID returns a permissive client for any id.
func (s *AuthStorage) GetClientByClientID(_ context.Context, id string) (op.Client, error) {
if id == "none" {
return nil, errors.New("GetClientByClientID: not found")
return nil, errors.New("client not found")
}
var appType op.ApplicationType
var authMethod oidc.AuthMethod
var accessTokenType op.AccessTokenType
var responseTypes []oidc.ResponseType
var grantTypes = []oidc.GrantType{
oidc.GrantTypeCode,
}
switch id {
case "web":
appType = op.ApplicationTypeWeb
authMethod = oidc.AuthMethodBasic
accessTokenType = op.AccessTokenTypeBearer
responseTypes = []oidc.ResponseType{oidc.ResponseTypeCode}
case "native":
appType = op.ApplicationTypeNative
authMethod = oidc.AuthMethodBasic
accessTokenType = op.AccessTokenTypeBearer
responseTypes = []oidc.ResponseType{oidc.ResponseTypeCode, oidc.ResponseTypeIDToken, oidc.ResponseTypeIDTokenOnly}
default:
appType = op.ApplicationTypeUserAgent
authMethod = oidc.AuthMethodNone
accessTokenType = op.AccessTokenTypeJWT
responseTypes = []oidc.ResponseType{oidc.ResponseTypeIDToken, oidc.ResponseTypeIDTokenOnly}
}
return &ConfClient{ID: id, applicationType: appType, authMethod: authMethod, accessTokenType: accessTokenType, responseTypes: responseTypes, grantTypes: grantTypes, devMode: true}, nil
return NewClient(id), nil
}
func (s *AuthStorage) AuthorizeClientIDSecret(_ context.Context, id string, _ string) error {
// AuthorizeClientIDSecret accepts any client/secret pair.
func (s *AuthStorage) AuthorizeClientIDSecret(_ context.Context, _, _ string) error {
return nil
}
func (s *AuthStorage) SetUserinfoFromToken(ctx context.Context, userinfo oidc.UserInfoSetter, _, _, _ string) error {
return s.SetUserinfoFromScopes(ctx, userinfo, "", "", []string{})
}
func (s *AuthStorage) SetUserinfoFromScopes(ctx context.Context, userinfo oidc.UserInfoSetter, _, _ string, _ []string) error {
userinfo.SetSubject(a.GetSubject())
// userinfo.SetAddress(oidc.NewUserInfoAddress("Test 789\nPostfach 2", "", "", "", "", ""))
userinfo.SetEmail("test@example.com", true)
userinfo.SetPhone("0791234567", true)
userinfo.SetName("Test")
userinfo.AppendClaims("private_claim", "test")
userinfo.SetNickname("testnick")
userinfo.SetPreferredUsername("prefname")
// SetUserinfoFromScopes is required by op.Storage but the dummy populates claims via SetUserinfoFromToken.
func (s *AuthStorage) SetUserinfoFromScopes(_ context.Context, info *oidc.UserInfo, _, _ string, _ []string) error {
fillUserInfo(info)
return nil
}
func (s *AuthStorage) GetPrivateClaimsFromScopes(_ context.Context, _, _ string, _ []string) (map[string]interface{}, error) {
return map[string]interface{}{"private_claim": "test"}, nil
}
func (s *AuthStorage) SetIntrospectionFromToken(ctx context.Context, introspect oidc.IntrospectionResponse, tokenID, subject, clientID string) error {
if err := s.SetUserinfoFromScopes(ctx, introspect, "", "", []string{}); err != nil {
return err
// SetUserinfoFromToken populates the userinfo response for a given token.
func (s *AuthStorage) SetUserinfoFromToken(_ context.Context, info *oidc.UserInfo, tokenID, _, _ string) error {
s.lock.Lock()
tok, ok := s.tokens[tokenID]
s.lock.Unlock()
if !ok {
return errors.New("token not found")
}
introspect.SetClientID(a.ClientID)
if tok.expiration.Before(time.Now().UTC()) {
return errors.New("token expired")
}
fillUserInfo(info)
return nil
}
func (s *AuthStorage) ValidateJWTProfileScopes(ctx context.Context, userID string, scope []string) ([]string, error) {
return scope, nil
// SetIntrospectionFromToken populates the introspection response for a given token.
func (s *AuthStorage) SetIntrospectionFromToken(_ context.Context, response *oidc.IntrospectionResponse, tokenID, _, clientID string) error {
s.lock.Lock()
tok, ok := s.tokens[tokenID]
s.lock.Unlock()
if !ok {
return errors.New("token not found")
}
if tok.expiration.Before(time.Now().UTC()) {
return errors.New("token expired")
}
for _, aud := range tok.audience {
if aud == clientID {
response.Expiration = oidc.FromTime(tok.expiration)
response.Scope = tok.scopes
response.ClientID = tok.applicationID
info := new(oidc.UserInfo)
fillUserInfo(info)
response.SetUserInfo(info)
return nil
}
}
return errors.New("token is not valid for this client")
}
// GetPrivateClaimsFromScopes returns custom claims for a token. The dummy always returns one.
func (s *AuthStorage) GetPrivateClaimsFromScopes(_ context.Context, _, _ string, _ []string) (map[string]any, error) {
return map[string]any{"private_claim": "test"}, nil
}
// GetKeyByIDAndClientID returns the dummy's public key regardless of clientID.
func (s *AuthStorage) GetKeyByIDAndClientID(_ context.Context, _, _ string) (*jose.JSONWebKey, error) {
return &jose.JSONWebKey{
KeyID: s.signingKey.id,
Algorithm: string(s.signingKey.algorithm),
Use: "sig",
Key: s.signingKey.key.Public(),
}, nil
}
// ValidateJWTProfileScopes accepts the requested scopes verbatim.
func (s *AuthStorage) ValidateJWTProfileScopes(_ context.Context, _ string, scopes []string) ([]string, error) {
return scopes, nil
}
// fillUserInfo populates the userinfo response with the dummy user identity.
func fillUserInfo(info *oidc.UserInfo) {
info.Subject = "sub00000001"
info.Email = "test@example.com"
info.EmailVerified = oidc.Bool(true)
info.Name = "Test"
info.Nickname = "testnick"
info.PreferredUsername = "prefname"
info.PhoneNumber = "0791234567"
info.PhoneNumberVerified = oidc.Bool(true)
info.AppendClaims("private_claim", "test")
}
// refreshTokenRequest implements op.RefreshTokenRequest for the dummy.
type refreshTokenRequest struct {
id string
applicationID string
subject string
audience []string
scopes []string
authTime time.Time
amr []string
}
// GetAMR returns the authentication methods recorded with the refresh token.
func (r *refreshTokenRequest) GetAMR() []string { return r.amr }
// GetAudience returns the token audience.
func (r *refreshTokenRequest) GetAudience() []string { return r.audience }
// GetAuthTime returns the original auth time.
func (r *refreshTokenRequest) GetAuthTime() time.Time { return r.authTime }
// GetClientID returns the client_id the token was issued to.
func (r *refreshTokenRequest) GetClientID() string { return r.applicationID }
// GetScopes returns the scopes that the refresh token grants.
func (r *refreshTokenRequest) GetScopes() []string { return r.scopes }
// GetSubject returns the subject identifier.
func (r *refreshTokenRequest) GetSubject() string { return r.subject }
// SetCurrentScopes mutates the active scopes when the OP narrows them.
func (r *refreshTokenRequest) SetCurrentScopes(scopes []string) { r.scopes = scopes }

View file

@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/zitadel/oidc/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/oidc"
)
func TestAuthRequestResponseModeDefault(t *testing.T) {
@ -15,13 +15,49 @@ func TestAuthRequestResponseModeDefault(t *testing.T) {
}
func TestRevokeTokenNoError(t *testing.T) {
s := &AuthStorage{}
if err := s.RevokeToken(
context.TODO(),
"token",
"user",
"client",
); err != nil {
s := NewAuthStorage()
if err := s.RevokeToken(context.Background(), "unknown-token", "user", "client"); err != nil {
t.Fatalf("expected nil error from RevokeToken, got %v", err)
}
}
func TestCreateAndFetchAuthRequest(t *testing.T) {
s := NewAuthStorage()
req, err := s.CreateAuthRequest(context.Background(), &oidc.AuthRequest{
ClientID: "web",
RedirectURI: "https://app.localssl.dev/api/v1/oidc/redirect",
ResponseType: oidc.ResponseTypeCode,
Scopes: []string{"openid", "email"},
}, "")
if err != nil {
t.Fatalf("CreateAuthRequest: %v", err)
}
if req.GetID() == "" {
t.Fatalf("expected non-empty auth request id")
}
if err := s.MarkRequestDone(req.GetID()); err != nil {
t.Fatalf("MarkRequestDone: %v", err)
}
got, err := s.AuthRequestByID(context.Background(), req.GetID())
if err != nil {
t.Fatalf("AuthRequestByID: %v", err)
}
if !got.Done() {
t.Fatalf("expected auth request to be marked done")
}
}
func TestNewClientDefaults(t *testing.T) {
c := NewClient("anything")
if c.GetID() != "anything" {
t.Fatalf("unexpected id: %q", c.GetID())
}
if !c.DevMode() {
t.Fatalf("expected dev mode for permissive dummy clients")
}
if uris := c.RedirectURIs(); len(uris) == 0 {
t.Fatalf("expected default redirect URIs")
}
}

View file

@ -12,40 +12,36 @@ import (
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/zitadel/oidc/pkg/op"
"github.com/go-chi/chi/v5"
"github.com/zitadel/oidc/v3/pkg/op"
"caos-test-op/mock"
)
const (
defaultIssuer = "http://dummy-oidc:9998"
defaultPort = "9998"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
b := make([]byte, 32)
_, _ = rand.Read(b)
if _, err := rand.Read(b); err != nil {
log.Printf("failed to seed crypto key: %v", err)
return
}
port := "9998"
config := &op.Config{
Issuer: "http://dummy-oidc:9998",
CryptoKey: sha256.Sum256(b),
CodeMethodS256: true,
}
storage := mock.NewAuthStorage()
handler, err := op.NewOpenIDProvider(ctx, config, storage)
provider, err := newProvider(defaultIssuer, storage)
if err != nil {
log.Printf("failed to create OIDC provider: %v", err)
return
}
router := handler.HttpHandler().(*mux.Router)
router.Methods("GET").Path("/login").HandlerFunc(HandleLogin)
router := chi.NewRouter()
loginHandler := newLoginHandler(storage, op.AuthCallbackURL(provider), op.NewIssuerInterceptor(provider.IssuerFromRequest))
router.Get("/login", loginHandler)
router.Mount("/", provider)
server := &http.Server{
Addr: ":" + port,
Addr: ":" + defaultPort,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
@ -62,16 +58,49 @@ func main() {
}
}
func HandleLogin(w http.ResponseWriter, r *http.Request) {
// HandleLogin mocks a login page and immediately redirects with a user token.
// newProvider builds an OpenID provider with the dummy's permissive defaults.
func newProvider(issuer string, storage op.Storage) (*op.Provider, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
cfg := &op.Config{
CryptoKey: sha256.Sum256(b),
CodeMethodS256: true,
AuthMethodPost: true,
AuthMethodPrivateKeyJWT: true,
GrantTypeRefreshToken: true,
RequestObjectSupported: true,
}
return op.NewOpenIDProvider(issuer, cfg, storage,
op.WithAllowInsecure(),
)
}
// newLoginHandler returns the dummy /login handler. It marks the auth request as
// authenticated and redirects back to the OP's auth callback so the code flow
// completes without any user interaction.
func newLoginHandler(storage *mock.AuthStorage, callback func(context.Context, string) string, issuerInterceptor *op.IssuerInterceptor) http.HandlerFunc {
return issuerInterceptor.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
HandleLogin(w, r, storage, callback)
})
}
// HandleLogin marks an in-flight auth request as authenticated and redirects to the OP callback.
// It is exported to keep the existing test surface stable.
func HandleLogin(w http.ResponseWriter, r *http.Request, storage *mock.AuthStorage, callback func(context.Context, string) string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid login request", http.StatusBadRequest)
return
}
requestId := r.Form.Get("id")
// simulate user login and retrieve a token that indicates a successfully logged-in user
userToken := requestId + ":usertoken"
http.Redirect(w, r, "/authorize/callback?id="+userToken, http.StatusFound)
id := r.Form.Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
if err := storage.MarkRequestDone(id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, callback(r.Context(), id), http.StatusFound)
}

View file

@ -1,34 +1,91 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/zitadel/oidc/v3/pkg/oidc"
"caos-test-op/mock"
)
func newTestStorage(t *testing.T) (*mock.AuthStorage, string) {
t.Helper()
storage := mock.NewAuthStorage()
req, err := storage.CreateAuthRequest(context.Background(), &oidc.AuthRequest{
ClientID: "csg6yqvykh0780f9",
RedirectURI: "https://app.localssl.dev/api/v1/oidc/redirect",
ResponseType: oidc.ResponseTypeCode,
Scopes: []string{"openid", "email", "profile"},
}, "")
if err != nil {
t.Fatalf("create auth request: %v", err)
}
return storage, req.GetID()
}
func TestHandleLoginRedirects(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/login?id=abc123", nil)
storage, id := newTestStorage(t)
callback := func(_ context.Context, requestID string) string {
return "/callback?id=" + requestID
}
req := httptest.NewRequest(http.MethodGet, "/login?id="+id, nil)
w := httptest.NewRecorder()
HandleLogin(w, req)
HandleLogin(w, req, storage, callback)
resp := w.Result()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, resp.StatusCode)
}
location := resp.Header.Get("Location")
if location != "/authorize/callback?id=abc123:usertoken" {
if location := resp.Header.Get("Location"); location != "/callback?id="+id {
t.Fatalf("unexpected redirect location: %s", location)
}
}
func TestHandleLoginBadRequest(t *testing.T) {
func TestHandleLoginUnknownID(t *testing.T) {
storage := mock.NewAuthStorage()
callback := func(_ context.Context, requestID string) string {
return "/callback?id=" + requestID
}
req := httptest.NewRequest(http.MethodGet, "/login?id=not-a-real-id", nil)
w := httptest.NewRecorder()
HandleLogin(w, req, storage, callback)
if w.Result().StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for unknown id, got %d", w.Result().StatusCode)
}
}
func TestHandleLoginMissingID(t *testing.T) {
storage := mock.NewAuthStorage()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
HandleLogin(w, req, storage, func(context.Context, string) string { return "/callback" })
if w.Result().StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for missing id, got %d", w.Result().StatusCode)
}
}
func TestHandleLoginParseError(t *testing.T) {
storage := mock.NewAuthStorage()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("%zz"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
HandleLogin(w, req)
HandleLogin(w, req, storage, func(context.Context, string) string { return "/callback" })
if w.Result().StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request, got %d", w.Result().StatusCode)

View file

@ -59,7 +59,7 @@ node-storage-shim ISC Michael Nahkies
passive-events-support MIT Ignas Damunskis <ignas3run@gmail.com>
photoswipe MIT Dmytro Semenov (https://dimsemenov.com)
playwright Apache-2.0 Microsoft Corporation
postcss MIT Andrey Sitnik <andrey@sitnik.ru>
postcss MIT Andrey Sitnik <andrey@sitnik.es>
postcss-import MIT Maxime Thirouin
postcss-loader MIT Andrey Sitnik <andrey@sitnik.ru>
postcss-preset-env MIT-0 n/a

View file

@ -139,9 +139,7 @@
"yarn": "please use npm"
},
"overrides": {
"@ctrl/tinycolor": "^4.1.3",
"axios": "1.14.0",
"color-convert": "2.0.1",
"color-name": "1.1.4"
"minimatch@~3.0": "^3.1.3",
"serialize-javascript": "^7.0.5"
}
}

4
go.mod
View file

@ -96,7 +96,7 @@ require (
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
@ -194,3 +194,5 @@ require (
)
go 1.25.0
toolchain go1.26.2

4
go.sum
View file

@ -20,8 +20,8 @@ filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q
github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/IGLOU-EU/go-wildcard v1.0.3 h1:r8T46+8/9V1STciXJomTWRpPEv4nGJATDbJkdU0Nou0=

67
package-lock.json generated
View file

@ -13329,18 +13329,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/postcss-url/node_modules/minimatch": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz",
"integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@ -13496,15 +13484,6 @@
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@ -13903,26 +13882,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@ -14183,12 +14142,12 @@
}
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/set-function-length": {
@ -17709,6 +17668,22 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -2,7 +2,8 @@
"name": "photoprism",
"private": true,
"overrides": {
"axios": "1.14.0"
"minimatch@~3.0": "^3.1.3",
"serialize-javascript": "^7.0.5"
},
"workspaces": [
"frontend"