mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-03-05 14:22:05 +00:00
Compare commits
6 Commits
328064946b
...
feat/oidc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
627fd05d71 | ||
|
|
fb705eaf07 | ||
|
|
673f556fb3 | ||
|
|
01e491c3be | ||
|
|
63fcc654f0 | ||
|
|
a8f57e584e |
4
Makefile
4
Makefile
@@ -61,11 +61,11 @@ test:
|
||||
|
||||
# Development
|
||||
develop:
|
||||
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans
|
||||
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
|
||||
|
||||
# Development - Infisical
|
||||
develop-infisical:
|
||||
infisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans
|
||||
infisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
|
||||
|
||||
# Production
|
||||
prod:
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
||||
"authorizeSuccessTitle": "Authorized",
|
||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
||||
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
|
||||
"openidScopeName": "OpenID Connect",
|
||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
||||
"emailScopeName": "Email",
|
||||
@@ -75,5 +77,5 @@
|
||||
"profileScopeName": "Profile",
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access the groups in which you are a member."
|
||||
"groupsScopeDescription": "Allows the app to access your group information."
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
|
||||
"authorizeSuccessTitle": "Authorized",
|
||||
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
|
||||
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
|
||||
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
|
||||
"openidScopeName": "OpenID Connect",
|
||||
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
|
||||
"emailScopeName": "Email",
|
||||
@@ -75,5 +77,5 @@
|
||||
"profileScopeName": "Profile",
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access the groups in which you are a member."
|
||||
"groupsScopeDescription": "Allows the app to access your group information."
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
CardFooter,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { getOidcClientInfoScehma } from "@/schemas/oidc-schemas";
|
||||
import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
@@ -73,13 +73,13 @@ export const AuthorizePage = () => {
|
||||
isOidc,
|
||||
compiled: compiledOIDCParams,
|
||||
} = useOIDCParams(searchParams);
|
||||
const scopes = props.scope.split(" ");
|
||||
const scopes = props.scope ? props.scope.split(" ").filter(Boolean) : [];
|
||||
|
||||
const getClientInfo = useQuery({
|
||||
queryKey: ["client", props.client_id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/oidc/clients/${props.client_id}`);
|
||||
const data = await getOidcClientInfoScehma.parseAsync(await res.json());
|
||||
const data = await getOidcClientInfoSchema.parseAsync(await res.json());
|
||||
return data;
|
||||
},
|
||||
enabled: isOidc,
|
||||
@@ -109,19 +109,19 @@ export const AuthorizePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
|
||||
}
|
||||
|
||||
if (missingParams.length > 0) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/error?error=${encodeURIComponent(`Missing parameters: ${missingParams.join(", ")}`)}`}
|
||||
to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: missingParams.join(", ") }))}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
|
||||
}
|
||||
|
||||
if (getClientInfo.isLoading) {
|
||||
return (
|
||||
<Card className="min-w-xs sm:min-w-sm">
|
||||
@@ -138,7 +138,7 @@ export const AuthorizePage = () => {
|
||||
if (getClientInfo.isError) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/error?error=${encodeURIComponent(`Failed to load client information`)}`}
|
||||
to={`/error?error=${encodeURIComponent(t("authorizeErrorClientInfo"))}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,9 @@ export const LoginPage = () => {
|
||||
mutationKey: ["login"],
|
||||
onSuccess: (data) => {
|
||||
if (data.data.totpPending) {
|
||||
window.location.replace(`/totp?${compiledOIDCParams}`);
|
||||
window.location.replace(
|
||||
`/totp?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,6 +151,10 @@ export const LoginPage = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoggedIn && isOidc) {
|
||||
return <Navigate to={`/authorize?${compiledOIDCParams}`} replace />;
|
||||
}
|
||||
|
||||
if (isLoggedIn && props.redirect_uri !== "") {
|
||||
return (
|
||||
<Navigate
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const getOidcClientInfoScehma = z.object({
|
||||
export const getOidcClientInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/resources/, ""),
|
||||
},
|
||||
"/.well-known": {
|
||||
target: "http://tinyauth-backend:3000/.well-known",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/\.well-known/, ""),
|
||||
},
|
||||
},
|
||||
allowedHosts: true,
|
||||
},
|
||||
|
||||
1
go.mod
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
@@ -113,5 +113,9 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
|
||||
|
||||
healthController.SetupRoutes()
|
||||
|
||||
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)
|
||||
|
||||
wellknownController.SetupRoutes()
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -233,14 +233,14 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrCodeNotFound) {
|
||||
tlog.App.Warn().Str("code", req.Code).Msg("Code not found")
|
||||
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().Str("code", req.Code).Msg("Code expired")
|
||||
tlog.App.Warn().Msg("Code expired")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_grant",
|
||||
})
|
||||
@@ -273,7 +273,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
|
||||
tokenResponse = tokenRes
|
||||
case "refresh_token":
|
||||
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken)
|
||||
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, rclientId)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenExpired) {
|
||||
@@ -284,6 +284,14 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
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",
|
||||
|
||||
@@ -176,6 +176,8 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/tinyauth/internal/assets"
|
||||
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -39,11 +40,10 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
tlog.App.Debug().Str("path", path).Msg("path")
|
||||
|
||||
switch strings.SplitN(path, "/", 2)[0] {
|
||||
case "api":
|
||||
c.Next()
|
||||
return
|
||||
case "resources":
|
||||
case "api", "resources", ".well-known":
|
||||
c.Next()
|
||||
return
|
||||
default:
|
||||
|
||||
@@ -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 (
|
||||
@@ -38,8 +37,17 @@ var (
|
||||
ErrCodeNotFound = errors.New("code_not_found")
|
||||
ErrTokenNotFound = errors.New("token_not_found")
|
||||
ErrTokenExpired = errors.New("token_expired")
|
||||
ErrInvalidClient = errors.New("invalid_client")
|
||||
)
|
||||
|
||||
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"`
|
||||
@@ -205,7 +213,7 @@ func (service *OIDCService) Init() error {
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetIssuer() string {
|
||||
return service.config.Issuer
|
||||
return service.issuer
|
||||
}
|
||||
|
||||
func (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {
|
||||
@@ -333,7 +341,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 +363,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
|
||||
@@ -396,7 +425,7 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OI
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken string) (TokenResponse, error) {
|
||||
func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken string, reqClientId string) (TokenResponse, error) {
|
||||
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
|
||||
|
||||
if err != nil {
|
||||
@@ -410,6 +439,11 @@ func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken stri
|
||||
return TokenResponse{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
// Ensure the client ID in the request matches the client ID in the token
|
||||
if entry.ClientID != reqClientId {
|
||||
return TokenResponse{}, ErrInvalidClient
|
||||
}
|
||||
|
||||
idToken, err := service.generateIDToken(config.OIDCClientConfig{
|
||||
ClientID: entry.ClientID,
|
||||
}, entry.Sub)
|
||||
@@ -595,3 +629,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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user