feat: jwk endpoint

This commit is contained in:
Stavros
2026-01-29 15:44:26 +02:00
parent a8f57e584e
commit 63fcc654f0
7 changed files with 84 additions and 22 deletions

View File

@@ -27,7 +27,7 @@ export default defineConfig({
"/.well-known": {
target: "http://tinyauth-backend:3000/.well-known",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/.well-known/, ""),
rewrite: (path) => path.replace(/^\/\.well-known/, ""),
},
},
allowedHosts: true,

1
go.mod
View File

@@ -61,6 +61,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect

2
go.sum
View File

@@ -103,6 +103,8 @@ github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=

View File

@@ -113,9 +113,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
healthController.SetupRoutes()
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{
OpenIDConnectIssuer: app.services.oidcService.GetIssuer(),
}, engine)
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)
wellknownController.SetupRoutes()

View File

@@ -150,7 +150,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
}
// We also need a snapshot of the user that authorized this (skip if no openid scope)
if slices.Contains(strings.Split(req.Scope, " "), "openid") {
if slices.Contains(strings.Fields(req.Scope), "openid") {
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
if err != nil {

View File

@@ -2,6 +2,7 @@ package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/service"
@@ -23,33 +24,35 @@ type OpenIDConnectConfiguration struct {
ServiceDocumentation string `json:"service_documentation"`
}
type WellKnownControllerConfig struct {
OpenIDConnectIssuer string
}
type WellKnownControllerConfig struct{}
type WellKnownController struct {
config WellKnownControllerConfig
engine *gin.Engine
oidc *service.OIDCService
}
func NewWellKnownController(config WellKnownControllerConfig, engine *gin.Engine) *WellKnownController {
func NewWellKnownController(config WellKnownControllerConfig, oidc *service.OIDCService, engine *gin.Engine) *WellKnownController {
return &WellKnownController{
config: config,
oidc: oidc,
engine: engine,
}
}
func (controller *WellKnownController) SetupRoutes() {
controller.engine.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
controller.engine.GET("/.well-known/jwks.json", controller.JWKS)
}
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {
issuer := controller.oidc.GetIssuer()
c.JSON(200, OpenIDConnectConfiguration{
Issuer: controller.config.OpenIDConnectIssuer,
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", controller.config.OpenIDConnectIssuer),
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", controller.config.OpenIDConnectIssuer),
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", controller.config.OpenIDConnectIssuer),
JwksUri: fmt.Sprintf("%s/api/oidc/jwks", controller.config.OpenIDConnectIssuer),
Issuer: issuer,
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", issuer),
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", issuer),
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", issuer),
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", issuer),
ScopesSupported: service.SupportedScopes,
ResponseTypesSupported: service.SupportedResponseTypes,
GrantTypesSupported: service.SupportedGrantTypes,
@@ -60,3 +63,23 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
ServiceDocumentation: "https://tinyauth.app/docs/reference/openid",
})
}
func (controller *WellKnownController) JWKS(c *gin.Context) {
jwks, err := controller.oidc.GetJWK()
if err != nil {
c.JSON(500, gin.H{
"status": "500",
"message": "failed to get JWK",
})
return
}
c.Header("content-type", "application/json")
c.Writer.WriteString(`{"keys":[`)
c.Writer.Write(jwks)
c.Writer.WriteString(`]}`)
c.Status(http.StatusOK)
}

View File

@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
@@ -17,14 +18,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-jose/go-jose/v4"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"golang.org/x/exp/slices"
// Should probably switch to another package but for now this works
"golang.org/x/oauth2/jws"
)
var (
@@ -40,6 +39,14 @@ var (
ErrTokenExpired = errors.New("token_expired")
)
type ClaimSet struct {
Iss string `json:"iss"`
Aud string `json:"aud"`
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
type UserinfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
@@ -333,7 +340,21 @@ func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, sub
createdAt := time.Now().Unix()
expiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
claims := jws.ClaimSet{
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.RS256,
Key: service.privateKey,
}, &jose.SignerOptions{
ExtraHeaders: map[jose.HeaderKey]any{
"typ": "jwt",
"jku": fmt.Sprintf("%s/.well-known/jwks.json", service.issuer),
},
})
if err != nil {
return "", err
}
claims := ClaimSet{
Iss: service.issuer,
Aud: client.ClientID,
Sub: sub,
@@ -341,12 +362,19 @@ func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, sub
Exp: expiresAt,
}
header := jws.Header{
Algorithm: "RS256",
Typ: "JWT",
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
token, err := jws.Encode(&header, &claims, service.privateKey)
object, err := signer.Sign(payload)
if err != nil {
return "", err
}
token, err := object.CompactSerialize()
if err != nil {
return "", err
@@ -595,3 +623,13 @@ func (service *OIDCService) Cleanup() {
}
}
}
func (service *OIDCService) GetJWK() ([]byte, error) {
jwk := jose.JSONWebKey{
Key: service.privateKey,
Algorithm: string(jose.RS256),
Use: "sig",
}
return jwk.Public().MarshalJSON()
}