mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-02-24 18:01:57 +00:00
feat: oidc (#605)
* chore: add oidc base config * wip: authorize page * feat: implement basic oidc functionality * refactor: implement oidc following tinyauth patterns * feat: adapt frontend to oidc flow * fix: review comments * fix: oidc review comments * feat: refresh token grant type support * feat: cleanup expired oidc sessions * feat: frontend i18n * fix: fix typo in error screen * tests: add basic testing * fix: more review comments * refactor: rework oidc error messages * feat: openid discovery endpoint * feat: jwk endpoint * i18n: fix typo * fix: more rabbit nitpicks * fix: final review comments * i18n: authorize page error messages
This commit is contained in:
@@ -13,7 +13,7 @@ import (
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
var controllerCfg = controller.ContextControllerConfig{
|
||||
var contextControllerCfg = controller.ContextControllerConfig{
|
||||
Providers: []controller.Provider{
|
||||
{
|
||||
Name: "Local",
|
||||
@@ -35,7 +35,7 @@ var controllerCfg = controller.ContextControllerConfig{
|
||||
DisableUIWarnings: false,
|
||||
}
|
||||
|
||||
var userContext = config.UserContext{
|
||||
var contextCtrlTestContext = config.UserContext{
|
||||
Username: "testuser",
|
||||
Name: "testuser",
|
||||
Email: "test@example.com",
|
||||
@@ -65,7 +65,7 @@ func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httpt
|
||||
|
||||
group := router.Group("/api")
|
||||
|
||||
ctrl := controller.NewContextController(controllerCfg, group)
|
||||
ctrl := controller.NewContextController(contextControllerCfg, group)
|
||||
ctrl.SetupRoutes()
|
||||
|
||||
return router, recorder
|
||||
@@ -75,14 +75,14 @@ func TestAppContextHandler(t *testing.T) {
|
||||
expectedRes := controller.AppContextResponse{
|
||||
Status: 200,
|
||||
Message: "Success",
|
||||
Providers: controllerCfg.Providers,
|
||||
Title: controllerCfg.Title,
|
||||
AppURL: controllerCfg.AppURL,
|
||||
CookieDomain: controllerCfg.CookieDomain,
|
||||
ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage,
|
||||
BackgroundImage: controllerCfg.BackgroundImage,
|
||||
OAuthAutoRedirect: controllerCfg.OAuthAutoRedirect,
|
||||
DisableUIWarnings: controllerCfg.DisableUIWarnings,
|
||||
Providers: contextControllerCfg.Providers,
|
||||
Title: contextControllerCfg.Title,
|
||||
AppURL: contextControllerCfg.AppURL,
|
||||
CookieDomain: contextControllerCfg.CookieDomain,
|
||||
ForgotPasswordMessage: contextControllerCfg.ForgotPasswordMessage,
|
||||
BackgroundImage: contextControllerCfg.BackgroundImage,
|
||||
OAuthAutoRedirect: contextControllerCfg.OAuthAutoRedirect,
|
||||
DisableUIWarnings: contextControllerCfg.DisableUIWarnings,
|
||||
}
|
||||
|
||||
router, recorder := setupContextController(nil)
|
||||
@@ -103,20 +103,20 @@ func TestUserContextHandler(t *testing.T) {
|
||||
expectedRes := controller.UserContextResponse{
|
||||
Status: 200,
|
||||
Message: "Success",
|
||||
IsLoggedIn: userContext.IsLoggedIn,
|
||||
Username: userContext.Username,
|
||||
Name: userContext.Name,
|
||||
Email: userContext.Email,
|
||||
Provider: userContext.Provider,
|
||||
OAuth: userContext.OAuth,
|
||||
TotpPending: userContext.TotpPending,
|
||||
OAuthName: userContext.OAuthName,
|
||||
IsLoggedIn: contextCtrlTestContext.IsLoggedIn,
|
||||
Username: contextCtrlTestContext.Username,
|
||||
Name: contextCtrlTestContext.Name,
|
||||
Email: contextCtrlTestContext.Email,
|
||||
Provider: contextCtrlTestContext.Provider,
|
||||
OAuth: contextCtrlTestContext.OAuth,
|
||||
TotpPending: contextCtrlTestContext.TotpPending,
|
||||
OAuthName: contextCtrlTestContext.OAuthName,
|
||||
}
|
||||
|
||||
// Test with context
|
||||
router, recorder := setupContextController(&[]gin.HandlerFunc{
|
||||
func(c *gin.Context) {
|
||||
c.Set("context", &userContext)
|
||||
c.Set("context", &contextCtrlTestContext)
|
||||
c.Next()
|
||||
},
|
||||
})
|
||||
|
||||
414
internal/controller/oidc_controller.go
Normal file
414
internal/controller/oidc_controller.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||
)
|
||||
|
||||
type OIDCControllerConfig struct{}
|
||||
|
||||
type OIDCController struct {
|
||||
config OIDCControllerConfig
|
||||
router *gin.RouterGroup
|
||||
oidc *service.OIDCService
|
||||
}
|
||||
|
||||
type AuthorizeCallback struct {
|
||||
Code string `url:"code"`
|
||||
State string `url:"state"`
|
||||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
GrantType string `form:"grant_type" binding:"required" url:"grant_type"`
|
||||
Code string `form:"code" url:"code"`
|
||||
RedirectURI string `form:"redirect_uri" url:"redirect_uri"`
|
||||
RefreshToken string `form:"refresh_token" url:"refresh_token"`
|
||||
}
|
||||
|
||||
type CallbackError struct {
|
||||
Error string `url:"error"`
|
||||
ErrorDescription string `url:"error_description"`
|
||||
State string `url:"state"`
|
||||
}
|
||||
|
||||
type ErrorScreen struct {
|
||||
Error string `url:"error"`
|
||||
}
|
||||
|
||||
type ClientRequest struct {
|
||||
ClientID string `uri:"id" binding:"required"`
|
||||
}
|
||||
|
||||
func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
|
||||
return &OIDCController{
|
||||
config: config,
|
||||
oidc: oidcService,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
func (controller *OIDCController) SetupRoutes() {
|
||||
oidcGroup := controller.router.Group("/oidc")
|
||||
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
|
||||
oidcGroup.POST("/authorize", controller.Authorize)
|
||||
oidcGroup.POST("/token", controller.Token)
|
||||
oidcGroup.GET("/userinfo", controller.Userinfo)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
var req ClientRequest
|
||||
|
||||
err := c.BindUri(&req)
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind URI")
|
||||
c.JSON(400, gin.H{
|
||||
"status": 400,
|
||||
"message": "Bad Request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||
|
||||
if !ok {
|
||||
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
|
||||
c.JSON(404, gin.H{
|
||||
"status": 404,
|
||||
"message": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"client": client.ClientID,
|
||||
"name": client.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
userContext, err := utils.GetContext(c)
|
||||
|
||||
if err != nil {
|
||||
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
var req service.AuthorizeRequest
|
||||
|
||||
err = c.BindJSON(&req)
|
||||
if err != nil {
|
||||
controller.authorizeError(c, err, "Failed to bind JSON", "The client provided an invalid authorization request", "", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||
|
||||
if !ok {
|
||||
controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.ValidateAuthorizeParams(req)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to validate authorize params")
|
||||
if err.Error() != "invalid_request_uri" {
|
||||
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
|
||||
return
|
||||
}
|
||||
controller.authorizeError(c, err, "Redirect URI not trusted", "The provided redirect URI is not trusted", "", "", "")
|
||||
return
|
||||
}
|
||||
|
||||
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
|
||||
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.Username, client.ID))
|
||||
code := rand.Text()
|
||||
|
||||
// Before storing the code, delete old session
|
||||
err = controller.oidc.DeleteOldSession(c, sub)
|
||||
if err != nil {
|
||||
controller.authorizeError(c, err, "Failed to delete old sessions", "Failed to delete old sessions", req.RedirectURI, "server_error", req.State)
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.StoreCode(c, sub, code, req)
|
||||
|
||||
if err != nil {
|
||||
controller.authorizeError(c, err, "Failed to store code", "Failed to store code", req.RedirectURI, "server_error", req.State)
|
||||
return
|
||||
}
|
||||
|
||||
// We also need a snapshot of the user that authorized this (skip if no openid scope)
|
||||
if slices.Contains(strings.Fields(req.Scope), "openid") {
|
||||
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
|
||||
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
queries, err := query.Values(AuthorizeCallback{
|
||||
Code: code,
|
||||
State: req.State,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
controller.authorizeError(c, err, "Failed to build query", "Failed to build query", req.RedirectURI, "server_error", req.State)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Token(c *gin.Context) {
|
||||
var req TokenRequest
|
||||
|
||||
err := c.Bind(&req)
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to bind token request")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.ValidateGrantType(req.GrantType)
|
||||
if err != nil {
|
||||
tlog.App.Warn().Str("grant_type", req.GrantType).Msg("Unsupported grant type")
|
||||
c.JSON(400, gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rclientId, rclientSecret, ok := c.Request.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
tlog.App.Error().Msg("Missing authorization header")
|
||||
c.Header("www-authenticate", "basic")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := controller.oidc.GetClient(rclientId)
|
||||
|
||||
if !ok {
|
||||
tlog.App.Warn().Str("client_id", rclientId).Msg("Client not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if client.ClientSecret != rclientSecret {
|
||||
tlog.App.Warn().Str("client_id", rclientId).Msg("Invalid client secret")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var tokenResponse service.TokenResponse
|
||||
|
||||
switch req.GrantType {
|
||||
case "authorization_code":
|
||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrCodeNotFound) {
|
||||
tlog.App.Warn().Msg("Code not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeExpired) {
|
||||
tlog.App.Warn().Msg("Code expired")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if entry.RedirectURI != req.RedirectURI {
|
||||
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry.Sub, entry.Scope)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenResponse = tokenRes
|
||||
case "refresh_token":
|
||||
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, rclientId)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenExpired) {
|
||||
tlog.App.Error().Err(err).Msg("Refresh token expired")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, service.ErrInvalidClient) {
|
||||
tlog.App.Error().Err(err).Msg("Invalid client")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenResponse = tokenRes
|
||||
}
|
||||
|
||||
c.JSON(200, tokenResponse)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
authorization := c.GetHeader("Authorization")
|
||||
|
||||
tokenType, token, ok := strings.Cut(authorization, " ")
|
||||
|
||||
if !ok {
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed without authorization header")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.ToLower(tokenType) != "bearer" {
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token type")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
|
||||
|
||||
if err != nil {
|
||||
if err == service.ErrTokenNotFound {
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Err(err).Msg("Failed to get token entry")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If we don't have the openid scope, return an error
|
||||
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed without openid scope")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_scope",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Err(err).Msg("Failed to get user entry")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, controller.oidc.CompileUserinfo(user, entry.Scope))
|
||||
}
|
||||
|
||||
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
|
||||
tlog.App.Error().Err(err).Msg(reason)
|
||||
|
||||
if callback != "" {
|
||||
errorQueries := CallbackError{
|
||||
Error: callbackError,
|
||||
}
|
||||
|
||||
if reasonUser != "" {
|
||||
errorQueries.ErrorDescription = reasonUser
|
||||
}
|
||||
|
||||
if state != "" {
|
||||
errorQueries.State = state
|
||||
}
|
||||
|
||||
queries, err := query.Values(errorQueries)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", callback, queries.Encode()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
errorQueries := ErrorScreen{
|
||||
Error: reasonUser,
|
||||
}
|
||||
|
||||
queries, err := query.Values(errorQueries)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s/error?%s", controller.oidc.GetIssuer(), queries.Encode()),
|
||||
})
|
||||
}
|
||||
281
internal/controller/oidc_controller_test.go
Normal file
281
internal/controller/oidc_controller_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-querystring/query"
|
||||
"github.com/steveiliop56/tinyauth/internal/bootstrap"
|
||||
"github.com/steveiliop56/tinyauth/internal/config"
|
||||
"github.com/steveiliop56/tinyauth/internal/controller"
|
||||
"github.com/steveiliop56/tinyauth/internal/repository"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||
"gotest.tools/v3/assert"
|
||||
)
|
||||
|
||||
var oidcServiceConfig = service.OIDCServiceConfig{
|
||||
Clients: map[string]config.OIDCClientConfig{
|
||||
"client1": {
|
||||
ClientID: "some-client-id",
|
||||
ClientSecret: "some-client-secret",
|
||||
ClientSecretFile: "",
|
||||
TrustedRedirectURIs: []string{
|
||||
"https://example.com/oauth/callback",
|
||||
},
|
||||
Name: "Client 1",
|
||||
},
|
||||
},
|
||||
PrivateKeyPath: "/tmp/tinyauth_oidc_key",
|
||||
PublicKeyPath: "/tmp/tinyauth_oidc_key.pub",
|
||||
Issuer: "https://example.com",
|
||||
SessionExpiry: 3600,
|
||||
}
|
||||
|
||||
var oidcCtrlTestContext = config.UserContext{
|
||||
Username: "test",
|
||||
Name: "Test",
|
||||
Email: "test@example.com",
|
||||
IsLoggedIn: true,
|
||||
IsBasicAuth: false,
|
||||
OAuth: false,
|
||||
Provider: "ldap", // ldap in order to test the groups
|
||||
TotpPending: false,
|
||||
OAuthGroups: "",
|
||||
TotpEnabled: false,
|
||||
OAuthName: "",
|
||||
OAuthSub: "",
|
||||
LdapGroups: "test1,test2",
|
||||
}
|
||||
|
||||
// Test is not amazing, but it will confirm the OIDC server works
|
||||
func TestOIDCController(t *testing.T) {
|
||||
tlog.NewSimpleLogger().Init()
|
||||
|
||||
// Create an app instance
|
||||
app := bootstrap.NewBootstrapApp(config.Config{})
|
||||
|
||||
// Get db
|
||||
db, err := app.SetupDatabase("/tmp/tinyauth.db")
|
||||
assert.NilError(t, err)
|
||||
|
||||
// Create queries
|
||||
queries := repository.New(db)
|
||||
|
||||
// Create a new OIDC Servicee
|
||||
oidcService := service.NewOIDCService(oidcServiceConfig, queries)
|
||||
err = oidcService.Init()
|
||||
assert.NilError(t, err)
|
||||
|
||||
// Create test router
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.Default()
|
||||
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("context", &oidcCtrlTestContext)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
group := router.Group("/api")
|
||||
|
||||
// Register oidc controller
|
||||
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, oidcService, group)
|
||||
oidcController.SetupRoutes()
|
||||
|
||||
// Get redirect URL test
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
marshalled, err := json.Marshal(service.AuthorizeRequest{
|
||||
Scope: "openid profile email groups",
|
||||
ResponseType: "code",
|
||||
ClientID: "some-client-id",
|
||||
RedirectURI: "https://example.com/oauth/callback",
|
||||
State: "some-state",
|
||||
})
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req, err := http.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(marshalled)))
|
||||
assert.NilError(t, err)
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
resJson := map[string]any{}
|
||||
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
|
||||
assert.NilError(t, err)
|
||||
|
||||
redirect_uri, ok := resJson["redirect_uri"].(string)
|
||||
assert.Assert(t, ok)
|
||||
|
||||
u, err := url.Parse(redirect_uri)
|
||||
assert.NilError(t, err)
|
||||
|
||||
m, err := url.ParseQuery(u.RawQuery)
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, m["state"][0], "some-state")
|
||||
|
||||
code := m["code"][0]
|
||||
|
||||
// Exchange code for token
|
||||
recorder = httptest.NewRecorder()
|
||||
|
||||
params, err := query.Values(controller.TokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
RedirectURI: "https://example.com/oauth/callback",
|
||||
})
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth("some-client-id", "some-client-secret")
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
resJson = map[string]any{}
|
||||
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
|
||||
assert.NilError(t, err)
|
||||
|
||||
accessToken, ok := resJson["access_token"].(string)
|
||||
assert.Assert(t, ok)
|
||||
|
||||
_, ok = resJson["id_token"].(string)
|
||||
assert.Assert(t, ok)
|
||||
|
||||
refreshToken, ok := resJson["refresh_token"].(string)
|
||||
assert.Assert(t, ok)
|
||||
|
||||
expires_in, ok := resJson["expires_in"].(float64)
|
||||
assert.Assert(t, ok)
|
||||
assert.Equal(t, expires_in, float64(oidcServiceConfig.SessionExpiry))
|
||||
|
||||
// Ensure code is expired
|
||||
recorder = httptest.NewRecorder()
|
||||
|
||||
params, err = query.Values(controller.TokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
RedirectURI: "https://example.com/oauth/callback",
|
||||
})
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth("some-client-id", "some-client-secret")
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
|
||||
// Test userinfo
|
||||
recorder = httptest.NewRecorder()
|
||||
|
||||
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
|
||||
assert.NilError(t, err)
|
||||
|
||||
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
resJson = map[string]any{}
|
||||
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
|
||||
assert.NilError(t, err)
|
||||
|
||||
_, ok = resJson["sub"].(string)
|
||||
assert.Assert(t, ok)
|
||||
|
||||
name, ok := resJson["name"].(string)
|
||||
assert.Assert(t, ok)
|
||||
assert.Equal(t, name, oidcCtrlTestContext.Name)
|
||||
|
||||
email, ok := resJson["email"].(string)
|
||||
assert.Assert(t, ok)
|
||||
assert.Equal(t, email, oidcCtrlTestContext.Email)
|
||||
|
||||
preferred_username, ok := resJson["preferred_username"].(string)
|
||||
assert.Assert(t, ok)
|
||||
assert.Equal(t, preferred_username, oidcCtrlTestContext.Username)
|
||||
|
||||
// Not sure why this is failing, will look into it later
|
||||
igroups, ok := resJson["groups"].([]any)
|
||||
assert.Assert(t, ok)
|
||||
|
||||
groups := make([]string, len(igroups))
|
||||
for i, group := range igroups {
|
||||
groups[i], ok = group.(string)
|
||||
assert.Assert(t, ok)
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, strings.Split(oidcCtrlTestContext.LdapGroups, ","), groups)
|
||||
|
||||
// Test refresh token
|
||||
recorder = httptest.NewRecorder()
|
||||
|
||||
params, err = query.Values(controller.TokenRequest{
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: refreshToken,
|
||||
})
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req, err = http.NewRequest("POST", "/api/oidc/token", strings.NewReader(params.Encode()))
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth("some-client-id", "some-client-secret")
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
resJson = map[string]any{}
|
||||
|
||||
err = json.Unmarshal(recorder.Body.Bytes(), &resJson)
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
newToken, ok := resJson["access_token"].(string)
|
||||
assert.Assert(t, ok)
|
||||
assert.Assert(t, newToken != accessToken)
|
||||
|
||||
// Ensure old token is invalid
|
||||
recorder = httptest.NewRecorder()
|
||||
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
|
||||
// Test new token
|
||||
recorder = httptest.NewRecorder()
|
||||
req, err = http.NewRequest("GET", "/api/oidc/userinfo", nil)
|
||||
|
||||
assert.NilError(t, err)
|
||||
|
||||
req.Header.Set("authorization", fmt.Sprintf("Bearer %s", newToken))
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
85
internal/controller/well_known_controller.go
Normal file
85
internal/controller/well_known_controller.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/steveiliop56/tinyauth/internal/service"
|
||||
)
|
||||
|
||||
type OpenIDConnectConfiguration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksUri string `json:"jwks_uri"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
ServiceDocumentation string `json:"service_documentation"`
|
||||
}
|
||||
|
||||
type WellKnownControllerConfig struct{}
|
||||
|
||||
type WellKnownController struct {
|
||||
config WellKnownControllerConfig
|
||||
engine *gin.Engine
|
||||
oidc *service.OIDCService
|
||||
}
|
||||
|
||||
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: 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,
|
||||
SubjectTypesSupported: []string{"pairwise"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
|
||||
ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "groups"},
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user