Compare commits

...

2 Commits

Author SHA1 Message Date
Stavros
71bc3966bc feat: adapt frontend to oidc flow 2026-01-24 15:52:22 +02:00
Stavros
c817e353f6 refactor: implement oidc following tinyauth patterns 2026-01-24 14:31:03 +02:00
15 changed files with 760 additions and 345 deletions

View File

@@ -54,6 +54,10 @@ func NewTinyauthCmdConfiguration() *config.Config {
},
},
},
OIDC: config.OIDCConfig{
PrivateKeyPath: "./tinyauth_oidc_key",
PublicKeyPath: "./tinyauth_oidc_key.pub",
},
Experimental: config.ExperimentalConfig{
ConfigFile: "",
},

View File

@@ -159,6 +159,10 @@ code {
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;
}
pre {
@apply bg-accent border border-border rounded-md p-2;
}
.lead {
@apply text-xl text-muted-foreground;
}

View File

@@ -0,0 +1,53 @@
export type OIDCValues = {
scope: string;
response_type: string;
client_id: string;
redirect_uri: string;
state: string;
};
interface IuseOIDCParams {
values: OIDCValues;
compiled: string;
isOidc: boolean;
missingParams: string[];
}
const optionalParams: string[] = ["state"];
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
let compiled: string = "";
let isOidc = false;
const missingParams: string[] = [];
const values: OIDCValues = {
scope: params.get("scope") ?? "",
response_type: params.get("response_type") ?? "",
client_id: params.get("client_id") ?? "",
redirect_uri: params.get("redirect_uri") ?? "",
state: params.get("state") ?? "",
};
for (const key of Object.keys(values)) {
if (!values[key as keyof OIDCValues]) {
if (!optionalParams.includes(key)) {
missingParams.push(key);
}
}
}
if (missingParams.length === 0) {
isOidc = true;
}
if (isOidc) {
compiled = new URLSearchParams(values).toString();
}
return {
values,
compiled,
isOidc,
missingParams,
};
}

View File

@@ -13,16 +13,7 @@ import { getOidcClientInfoScehma } from "@/schemas/oidc-schemas";
import { Button } from "@/components/ui/button";
import axios from "axios";
import { toast } from "sonner";
type AuthorizePageProps = {
scope: string;
responseType: string;
clientId: string;
redirectUri: string;
state: string;
};
const optionalAuthorizeProps = ["state"];
import { useOIDCParams } from "@/lib/hooks/oidc";
export const AuthorizePage = () => {
const { isLoggedIn } = useUserContext();
@@ -30,20 +21,16 @@ export const AuthorizePage = () => {
const navigate = useNavigate();
const searchParams = new URLSearchParams(search);
// If there is a better way to do this, please do let me know
const props: AuthorizePageProps = {
scope: searchParams.get("scope") || "",
responseType: searchParams.get("response_type") || "",
clientId: searchParams.get("client_id") || "",
redirectUri: searchParams.get("redirect_uri") || "",
state: searchParams.get("state") || "",
};
const {
values: props,
missingParams,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const getClientInfo = useQuery({
queryKey: ["client", props.clientId],
queryKey: ["client", props.client_id],
queryFn: async () => {
const res = await fetch(`/api/oidc/clients/${props.clientId}`);
const res = await fetch(`/api/oidc/clients/${props.client_id}`);
const data = await getOidcClientInfoScehma.parseAsync(await res.json());
return data;
},
@@ -53,20 +40,18 @@ export const AuthorizePage = () => {
mutationFn: () => {
return axios.post("/api/oidc/authorize", {
scope: props.scope,
response_type: props.responseType,
client_id: props.clientId,
redirect_uri: props.redirectUri,
response_type: props.response_type,
client_id: props.client_id,
redirect_uri: props.redirect_uri,
state: props.state,
});
},
mutationKey: ["authorize", props.clientId],
mutationKey: ["authorize", props.client_id],
onSuccess: (data) => {
toast.info("Authorized", {
description: "You will be soon redirected to your application",
});
window.location.replace(
`${data.data.redirect_uri}?code=${encodeURIComponent(data.data.code)}&state=${encodeURIComponent(data.data.state)}`,
);
window.location.replace(data.data.redirect_uri);
},
onError: (error) => {
window.location.replace(
@@ -76,19 +61,17 @@ export const AuthorizePage = () => {
});
if (!isLoggedIn) {
// TODO: Pass the params to the login page, so user can login -> authorize
return <Navigate to="/login" replace />;
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
}
Object.keys(props).forEach((key) => {
if (
!props[key as keyof AuthorizePageProps] &&
!optionalAuthorizeProps.includes(key)
) {
// TODO: Add reason for error
return <Navigate to="/error" replace />;
}
});
if (missingParams.length > 0) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(`Missing parameters: ${missingParams.join(", ")}`)}`}
replace
/>
);
}
if (getClientInfo.isLoading) {
return (
@@ -104,8 +87,12 @@ export const AuthorizePage = () => {
}
if (getClientInfo.isError) {
// TODO: Add reason for error
return <Navigate to="/error" replace />;
return (
<Navigate
to={`/error?error=${encodeURIComponent(`Failed to load client information`)}`}
replace
/>
);
}
return (

View File

@@ -80,7 +80,7 @@ export const ContinuePage = () => {
clearTimeout(auto);
clearTimeout(reveal);
};
}, []);
});
if (!isLoggedIn) {
return (

View File

@@ -5,15 +5,30 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
export const ErrorPage = () => {
const { t } = useTranslation();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const error = searchParams.get("error") ?? "";
return (
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
<CardDescription>{t("errorSubtitle")}</CardDescription>
<CardDescription className="flex flex-col gap-1.5">
{error ? (
<>
<p>The following error occured while processing your request:</p>
<pre>{error}</pre>
</>
) : (
<>
<p>{t("errorSubtitle")}</p>
</>
)}
</CardDescription>
</CardHeader>
</Card>
);

View File

@@ -18,6 +18,7 @@ import { OAuthButton } from "@/components/ui/oauth-button";
import { SeperatorWithChildren } from "@/components/ui/separator";
import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { LoginSchema } from "@/schemas/login-schema";
import { useMutation } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
@@ -47,7 +48,11 @@ export const LoginPage = () => {
const redirectButtonTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri");
const {
values: props,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap",
@@ -60,7 +65,7 @@ export const LoginPage = () => {
const oauthMutation = useMutation({
mutationFn: (provider: string) =>
axios.get(
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
),
mutationKey: ["oauth"],
onSuccess: (data) => {
@@ -85,9 +90,7 @@ export const LoginPage = () => {
mutationKey: ["login"],
onSuccess: (data) => {
if (data.data.totpPending) {
window.location.replace(
`/totp?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
);
window.location.replace(`/totp?${compiledOIDCParams}`);
return;
}
@@ -96,8 +99,12 @@ export const LoginPage = () => {
});
redirectTimer.current = window.setTimeout(() => {
if (isOidc) {
window.location.replace(`/authorize?${compiledOIDCParams}`);
return;
}
window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
);
}, 500);
},
@@ -115,7 +122,7 @@ export const LoginPage = () => {
if (
providers.find((provider) => provider.id === oauthAutoRedirect) &&
!isLoggedIn &&
redirectUri
props.redirect_uri !== ""
) {
// Not sure of a better way to do this
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -125,7 +132,13 @@ export const LoginPage = () => {
setShowRedirectButton(true);
}, 5000);
}
}, []);
}, [
providers,
isLoggedIn,
props.redirect_uri,
oauthAutoRedirect,
oauthMutation,
]);
useEffect(
() => () => {
@@ -136,10 +149,10 @@ export const LoginPage = () => {
[],
);
if (isLoggedIn && redirectUri) {
if (isLoggedIn && props.redirect_uri !== "") {
return (
<Navigate
to={`/continue?redirect_uri=${encodeURIComponent(redirectUri)}`}
to={`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`}
replace
/>
);

View File

@@ -55,7 +55,7 @@ export const LogoutPage = () => {
<CardHeader>
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
<CardDescription>
{provider !== "username" ? (
{provider !== "local" && provider !== "ldap" ? (
<Trans
i18nKey="logoutOauthSubtitle"
t={t}

View File

@@ -16,6 +16,7 @@ import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router";
import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
export const TotpPage = () => {
const { totpPending } = useUserContext();
@@ -26,7 +27,11 @@ export const TotpPage = () => {
const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri");
const {
values: props,
isOidc,
compiled: compiledOIDCParams,
} = useOIDCParams(searchParams);
const totpMutation = useMutation({
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
@@ -37,9 +42,14 @@ export const TotpPage = () => {
});
redirectTimer.current = window.setTimeout(() => {
window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
);
if (isOidc) {
window.location.replace(`/authorize?${compiledOIDCParams}`);
return;
} else {
window.location.replace(
`/continue?redirect_uri=${encodeURIComponent(props.redirect_uri)}`,
);
}
}, 500);
},
onError: () => {

View File

@@ -176,7 +176,7 @@ func (app *BootstrapApp) Setup() error {
app.context.configuredProviders = configuredProviders
// Setup router
router, err := app.setupRouter(queries)
router, err := app.setupRouter()
if err != nil {
return fmt.Errorf("failed to setup routes: %w", err)

View File

@@ -7,14 +7,13 @@ import (
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/middleware"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/gin-gonic/gin"
)
var DEV_MODES = []string{"main", "test", "development"}
func (app *BootstrapApp) setupRouter(queries *repository.Queries) (*gin.Engine, error) {
func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
if !slices.Contains(DEV_MODES, config.Version) {
gin.SetMode(gin.ReleaseMode)
}
@@ -87,10 +86,7 @@ func (app *BootstrapApp) setupRouter(queries *repository.Queries) (*gin.Engine,
oauthController.SetupRoutes()
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{
Clients: app.context.oidcClients,
AppURL: app.config.AppURL,
}, apiRouter, queries)
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
oidcController.SetupRoutes()

View File

@@ -12,6 +12,7 @@ type Services struct {
dockerService *service.DockerService
ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
}
func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) {
@@ -88,5 +89,20 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
services.oauthBrokerService = oauthBrokerService
oidcService := service.NewOIDCService(service.OIDCServiceConfig{
Clients: app.config.OIDC.Clients,
PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
PublicKeyPath: app.config.OIDC.PublicKeyPath,
Issuer: app.config.AppURL,
}, queries)
err = oidcService.Init()
if err != nil {
return Services{}, err
}
services.oidcService = oidcService
return services, nil
}

View File

@@ -62,7 +62,9 @@ type OAuthConfig struct {
}
type OIDCConfig struct {
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"`
PrivateKeyPath string `description:"Path to the private key file." yaml:"privateKeyPath"`
PublicKeyPath string `description:"Path to the public key file." yaml:"publicKeyPath"`
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"`
}
type UIConfig struct {
@@ -136,7 +138,7 @@ type OIDCClientConfig struct {
ClientID string `description:"OIDC client ID." yaml:"clientId"`
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
TrustedRedirectURLs []string `description:"List of trusted redirect URLs." yaml:"trustedRedirectUrls"`
TrustedRedirectURIs []string `description:"List of trusted redirect URLs." yaml:"trustedRedirectUrls"`
Name string `description:"Client name in UI." yaml:"name"`
}

View File

@@ -1,43 +1,31 @@
package controller
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
)
var (
SupportedResponseTypes = []string{"code"}
SupportedScopes = []string{"openid", "profile", "email", "groups"}
SupportedGrantTypes = []string{"authorization_code"}
)
type OIDCControllerConfig struct {
Clients []config.OIDCClientConfig
AppURL string
}
type OIDCControllerConfig struct{}
type OIDCController struct {
config OIDCControllerConfig
router *gin.RouterGroup
queries *repository.Queries
config OIDCControllerConfig
router *gin.RouterGroup
oidc *service.OIDCService
}
type AuthorizeRequest struct {
Scope string `json:"scope" binding:"required"`
ResponseType string `json:"response_type" binding:"required"`
ClientID string `json:"client_id" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
State string `json:"state" binding:"required"`
type AuthorizeCallback struct {
Code string `url:"code"`
State string `url:"state"`
}
type TokenRequest struct {
@@ -52,11 +40,19 @@ type CallbackError struct {
State string `url:"state"`
}
func NewOIDCController(config OIDCControllerConfig, router *gin.RouterGroup, queries *repository.Queries) *OIDCController {
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,
router: router,
queries: queries,
config: config,
oidc: oidcService,
router: router,
}
}
@@ -68,10 +64,6 @@ func (controller *OIDCController) SetupRoutes() {
oidcGroup.GET("/userinfo", controller.Userinfo)
}
type ClientRequest struct {
ClientID string `uri:"id" binding:"required"`
}
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
var req ClientRequest
@@ -85,17 +77,9 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
return
}
var client *config.OIDCClientConfig
client, ok := controller.oidc.GetClient(req.ClientID)
// Inefficient yeah, but it will be good until we have thousands of clients
for _, clientCfg := range controller.config.Clients {
if clientCfg.ClientID == req.ClientID {
client = &clientCfg
break
}
}
if client == nil {
if !ok {
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
c.JSON(404, gin.H{
"status": 404,
@@ -106,206 +90,111 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
c.JSON(200, gin.H{
"status": 200,
"client": &client.ClientID,
"name": &client.Name,
"client": client.ClientID,
"name": client.Name,
})
}
func (controller *OIDCController) Authorize(c *gin.Context) {
// Check if we are logged in
userContext, err := utils.GetContext(c)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to get user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
return
}
// OIDC stuff
var req AuthorizeRequest
var req service.AuthorizeRequest
err = c.BindJSON(&req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
controller.authorizeError(c, err, "Failed to bind JSON", "The client provided an invalid authorization request", "", "", "")
return
}
// TODO: All these errors should redirect to the error page with an explanation
_, ok := controller.oidc.GetClient(req.ClientID)
// Validate client ID
var client *config.OIDCClientConfig
for _, clientCfg := range controller.config.Clients {
if clientCfg.ClientID == req.ClientID {
client = &clientCfg
break
}
}
if client == nil {
tlog.App.Warn().Str("client_id", req.ClientID).Msg("Client not found")
c.JSON(404, gin.H{
"status": 404,
"message": "Client not found",
})
if !ok {
controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "")
return
}
// Validate redirect URI
if !slices.Contains(client.TrustedRedirectURLs, req.RedirectURI) {
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI not trusted")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
err = controller.oidc.ValidateAuthorizeParams(req)
// Validate scopes
reqScopes := strings.Split(req.Scope, " ")
keptScopes := make([]string, 0)
if len(reqScopes) == 0 || strings.TrimSpace(req.Scope) == "" {
queries, err := query.Values(CallbackError{
Error: "invalid_request",
ErrorDescription: "Missing scope parameter",
State: req.State,
})
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to build query")
c.Redirect(302, fmt.Sprintf("%s/error", controller.config.AppURL))
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
}
c.Redirect(302, fmt.Sprintf("%s/callback?%s", req.RedirectURI, queries.Encode()))
controller.authorizeError(c, err, "Redirect URI not trusted", "The provided redirect URI is not trusted", "", "", "")
return
}
for _, scope := range reqScopes {
if slices.Contains(SupportedScopes, scope) {
keptScopes = append(keptScopes, scope)
continue
}
tlog.App.Warn().Str("scope", scope).Msg("Scope not supported, ignoring")
}
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username which remains stable, but if username changes then sub changes too.
sub := utils.GenerateUUID(userContext.Username)
code := rand.Text()
// Generate a code and a sub
code, err := utils.GetRandomString(32)
err = controller.oidc.StoreCode(c, sub, code, req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to generate random string")
c.Redirect(302, fmt.Sprintf("%s/error", controller.config.AppURL))
return
}
sub, err := utils.GetRandomInt(10)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to generate random integer")
c.Redirect(302, fmt.Sprintf("%s/error", controller.config.AppURL))
return
}
expiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()
// Insert the code into the database
_, err = controller.queries.CreateOidcCode(c, repository.CreateOidcCodeParams{
Code: code,
Sub: strconv.Itoa(int(sub)),
Scope: strings.Join(keptScopes, ","),
RedirectURI: req.RedirectURI,
ClientID: client.ClientID,
ExpiresAt: expiresAt,
})
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to insert code into database")
c.Redirect(302, fmt.Sprintf("%s/error", controller.config.AppURL))
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
userInfoParams := repository.CreateOidcUserInfoParams{
Sub: strconv.Itoa(int(sub)),
Name: userContext.Name,
Email: userContext.Email,
PreferredUsername: userContext.Username,
UpdatedAt: time.Now().Unix(),
}
if userContext.Provider == "ldap" {
userInfoParams.Groups = userContext.LdapGroups
}
if userContext.OAuth && len(userContext.OAuthGroups) > 0 {
userInfoParams.Groups = userContext.OAuthGroups
}
_, err = controller.queries.CreateOidcUserInfo(c, userInfoParams)
err = controller.oidc.StoreUserinfo(c, sub, userContext, req)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to insert user info into database")
c.Redirect(302, fmt.Sprintf("%s/error", controller.config.AppURL))
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
}
// Return code and done
c.JSON(200, gin.H{
"status": 200,
"message": "Authorized",
"code": code,
"state": req.State,
"redirect_uri": req.RedirectURI,
"redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
})
}
func (controller *OIDCController) Token(c *gin.Context) {
// Get basic auth
clientId, clientSecret, ok := c.Request.BasicAuth()
rclientId, rclientSecret, ok := c.Request.BasicAuth()
if !ok {
tlog.App.Error().Msg("Missing token verifier")
tlog.App.Error().Msg("Missing authorization header")
c.JSON(400, gin.H{
"error": "invalid_request",
})
return
}
// Ensure client exists
var client *config.OIDCClientConfig
client, ok := controller.oidc.GetClient(rclientId)
for _, clientCfg := range controller.config.Clients {
if clientCfg.ClientID == clientId {
client = &clientCfg
break
}
}
if client == nil {
tlog.App.Warn().Str("client_id", clientId).Msg("Client not found")
if !ok {
tlog.App.Warn().Str("client_id", rclientId).Msg("Client not found")
c.JSON(400, gin.H{
"error": "invalid_request",
"error": "access_denied",
})
return
}
if client.ClientSecret != clientSecret {
tlog.App.Warn().Str("client_id", clientId).Msg("Invalid client secret")
if client.ClientSecret != rclientSecret {
tlog.App.Warn().Str("client_id", rclientId).Msg("Invalid client secret")
c.JSON(400, gin.H{
"error": "invalid_client",
"error": "access_denied",
})
return
}
// Get token
var req TokenRequest
err := c.Bind(&req)
@@ -317,93 +206,73 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
// Validate grant type
if !slices.Contains(SupportedGrantTypes, req.GrantType) {
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": "unsupported_grant_type",
"error": err.Error(),
})
return
}
// Find pending code entry
entry, err := controller.queries.GetOidcCode(c, req.Code)
entry, err := controller.oidc.GetCodeEntry(c, req.Code)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to find code in database")
if errors.Is(err, service.ErrCodeExpired) {
tlog.App.Warn().Str("code", req.Code).Msg("Code expired")
c.JSON(400, gin.H{
"error": "access_denied",
})
return
}
if errors.Is(err, service.ErrCodeNotFound) {
tlog.App.Warn().Str("code", req.Code).Msg("Code not found")
c.JSON(400, gin.H{
"error": "access_denied",
})
return
}
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
c.JSON(400, gin.H{
"error": "invalid_request",
"error": "server_error",
})
return
}
// Ensure redirect URIs match
if entry.RedirectURI != req.RedirectURI {
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
c.JSON(400, gin.H{
"error": "invalid_request",
"error": "invalid_request_uri",
})
return
}
// Generate access token
genToken, err := utils.GetRandomString(29)
accessToken, 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": "invalid_request",
"error": "server_error",
})
return
}
// Add tinyauth prefix
token := fmt.Sprintf("ta-%s", genToken)
// TODO: either add a refresh token or customize token expiry
expiresAt := time.Now().Add(time.Duration(3600) * time.Second).Unix()
// Create token entry
_, err = controller.queries.CreateOidcToken(c, repository.CreateOidcTokenParams{
Sub: entry.Sub,
AccessToken: token,
Scope: entry.Scope,
ClientID: client.ClientID,
ExpiresAt: expiresAt,
})
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to create token in database")
c.JSON(400, gin.H{
"error": "invalid_request",
})
return
}
// Delete code entry
err = controller.queries.DeleteOidcCode(c, entry.Code)
err = controller.oidc.DeleteCodeEntry(c, entry.Code)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to delete code in database")
c.JSON(400, gin.H{
"error": "invalid_request",
"error": "server_error",
})
return
}
// Respond with token
c.JSON(200, gin.H{
"access_token": token,
"token_type": "bearer",
"expires_in": 3600,
})
c.JSON(200, accessToken)
}
func (controller *OIDCController) Userinfo(c *gin.Context) {
// Get bearer
authorizationHeader := c.GetHeader("Authorization")
authorization := c.GetHeader("Authorization")
tokenType, token, ok := strings.Cut(authorizationHeader, " ")
tokenType, token, ok := strings.Cut(authorization, " ")
if !ok {
tlog.App.Warn().Msg("OIDC userinfo accessed without authorization header")
@@ -421,53 +290,36 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
return
}
// Get token entry
entry, err := controller.queries.GetOidcToken(c, token)
entry, err := controller.oidc.GetAccessToken(c, 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_request",
})
return
}
tlog.App.Err(err).Msg("Failed to get token entry")
c.JSON(401, gin.H{
"error": "invalid_request",
"error": "server_error",
})
return
}
// Get scopes
scopes := strings.Split(entry.Scope, ",")
// Check if token is expired
if time.Now().Unix() > entry.ExpiresAt {
tlog.App.Warn().Msg("OIDC userinfo accessed with expired token")
err = controller.queries.DeleteOidcToken(c, entry.AccessToken)
if err != nil {
tlog.App.Err(err).Msg("Failed to delete expired token")
}
err = controller.queries.DeleteOidcUserInfo(c, entry.Sub)
if err != nil {
tlog.App.Err(err).Msg("Failed to delete oidc user info")
}
c.JSON(401, gin.H{
"error": "invalid_request",
})
return
}
// Get user info
user, err := controller.queries.GetOidcUserInfo(c, entry.Sub)
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": "invalid_request",
"error": "server_error",
})
return
}
// If we don't have the openid scope, return an error
if !slices.Contains(scopes, "openid") {
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_request",
@@ -475,27 +327,52 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
return
}
// Let's build the response
res := map[string]any{
"sub": user.Sub,
"updated_at": user.UpdatedAt,
}
// If we have the profile scope, add the profile stuff
if slices.Contains(scopes, "profile") {
res["name"] = user.Name
res["preferred_username"] = user.PreferredUsername
}
// If we have the email scope, add the email stuff
if slices.Contains(scopes, "email") {
res["email"] = user.Email
}
// If we have the groups scope, add the groups stuff
if slices.Contains(scopes, "groups") {
res["groups"] = user.Groups
}
c.JSON(200, res)
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()),
})
}

View File

@@ -0,0 +1,438 @@
package service
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"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 (
SupportedScopes = []string{"openid", "profile", "email", "groups"}
SupportedResponseTypes = []string{"code"}
SupportedGrantTypes = []string{"authorization_code"}
)
var (
ErrCodeExpired = errors.New("code_expired")
ErrCodeNotFound = errors.New("code_not_found")
ErrTokenNotFound = errors.New("token_not_found")
ErrTokenExpired = errors.New("token_expired")
)
type UserinfoResponse struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
UpdatedAt int64 `json:"updated_at"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
IDToken string `json:"id_token"`
Scope string `json:"scope"`
}
type AuthorizeRequest struct {
Scope string `json:"scope" binding:"required"`
ResponseType string `json:"response_type" binding:"required"`
ClientID string `json:"client_id" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
State string `json:"state" binding:"required"`
}
type OIDCServiceConfig struct {
Clients map[string]config.OIDCClientConfig
PrivateKeyPath string
PublicKeyPath string
Issuer string
}
type OIDCService struct {
config OIDCServiceConfig
queries *repository.Queries
clients map[string]config.OIDCClientConfig
privateKey *rsa.PrivateKey
publicKey crypto.PublicKey
issuer string
}
func NewOIDCService(config OIDCServiceConfig, queries *repository.Queries) *OIDCService {
return &OIDCService{
config: config,
queries: queries,
}
}
// TODO: A cleanup routine is needed to clean up expired tokens/code/userinfo
func (service *OIDCService) Init() error {
// Ensure issuer is https
uissuer, err := url.Parse(service.config.Issuer)
if err != nil {
return err
}
if uissuer.Scheme != "https" {
return errors.New("issuer must be https")
}
service.issuer = fmt.Sprintf("%s://%s", uissuer.Scheme, uissuer.Host)
// Create/load private and public keys
if strings.TrimSpace(service.config.PrivateKeyPath) == "" ||
strings.TrimSpace(service.config.PublicKeyPath) == "" {
return errors.New("private key path and public key path are required")
}
var privateKey *rsa.PrivateKey
fprivateKey, err := os.ReadFile(service.config.PrivateKeyPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if errors.Is(err, os.ErrNotExist) {
privateKey, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
der := x509.MarshalPKCS1PrivateKey(privateKey)
encoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: der,
})
err = os.WriteFile(service.config.PrivateKeyPath, encoded, 0600)
if err != nil {
return err
}
service.privateKey = privateKey
} else {
block, _ := pem.Decode(fprivateKey)
privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return err
}
service.privateKey = privateKey
}
fpublicKey, err := os.ReadFile(service.config.PublicKeyPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if errors.Is(err, os.ErrNotExist) {
publicKey := service.privateKey.Public()
der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey))
encoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: der,
})
err = os.WriteFile(service.config.PublicKeyPath, encoded, 0644)
if err != nil {
return err
}
service.publicKey = publicKey
} else {
block, _ := pem.Decode(fpublicKey)
publicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
return err
}
service.publicKey = publicKey
}
// We will reorganize the client into a map with the client ID as the key
service.clients = make(map[string]config.OIDCClientConfig)
for id, client := range service.config.Clients {
client.ID = id
service.clients[client.ClientID] = client
}
// Load the client secrets from files if they exist
for id, client := range service.clients {
secret := utils.GetSecret(client.ClientSecret, client.ClientSecretFile)
if secret != "" {
client.ClientSecret = secret
}
client.ClientSecretFile = ""
service.clients[id] = client
}
return nil
}
func (service *OIDCService) GetIssuer() string {
return service.config.Issuer
}
func (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {
client, ok := service.clients[id]
return client, ok
}
func (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error {
// Validate client ID
client, ok := service.GetClient(req.ClientID)
if !ok {
return errors.New("access_denied")
}
// Scopes
scopes := strings.Split(req.Scope, " ")
if len(scopes) == 0 || strings.TrimSpace(req.Scope) == "" {
return errors.New("invalid_scope")
}
for _, scope := range scopes {
if strings.TrimSpace(scope) == "" {
return errors.New("invalid_scope")
}
if !slices.Contains(SupportedScopes, scope) {
tlog.App.Warn().Str("scope", scope).Msg("Unsupported OIDC scope, will be ignored")
}
}
// Response type
if !slices.Contains(SupportedResponseTypes, req.ResponseType) {
return errors.New("unsupported_response_type")
}
// Redirect URI
if !slices.Contains(client.TrustedRedirectURIs, req.RedirectURI) {
return errors.New("invalid_request_uri")
}
return nil
}
func (service *OIDCService) filterScopes(scopes []string) []string {
return utils.Filter(scopes, func(scope string) bool {
return slices.Contains(SupportedScopes, scope)
})
}
func (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, req AuthorizeRequest) error {
// Fixed 10 minutes
expiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()
// Insert the code into the database
_, err := service.queries.CreateOidcCode(c, repository.CreateOidcCodeParams{
Sub: sub,
Code: code,
// Here it's safe to split and trust the output since, we validated the scopes before
Scope: strings.Join(service.filterScopes(strings.Split(req.Scope, " ")), ","),
RedirectURI: req.RedirectURI,
ClientID: req.ClientID,
ExpiresAt: expiresAt,
})
return err
}
func (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {
userInfoParams := repository.CreateOidcUserInfoParams{
Sub: sub,
Name: userContext.Name,
Email: userContext.Email,
PreferredUsername: userContext.Username,
UpdatedAt: time.Now().Unix(),
}
// Tinyauth will pass through the groups it got from an LDAP or an OIDC server
if userContext.Provider == "ldap" {
userInfoParams.Groups = userContext.LdapGroups
}
if userContext.OAuth && len(userContext.OAuthGroups) > 0 {
userInfoParams.Groups = userContext.OAuthGroups
}
_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)
return err
}
func (service *OIDCService) ValidateGrantType(grantType string) error {
if !slices.Contains(SupportedGrantTypes, grantType) {
return errors.New("unsupported_response_type")
}
return nil
}
func (service *OIDCService) GetCodeEntry(c *gin.Context, code string) (repository.OidcCode, error) {
oidcCode, err := service.queries.GetOidcCode(c, code)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return repository.OidcCode{}, ErrCodeNotFound
}
return repository.OidcCode{}, err
}
if time.Now().Unix() > oidcCode.ExpiresAt {
err = service.queries.DeleteOidcCode(c, code)
if err != nil {
return repository.OidcCode{}, err
}
err = service.DeleteUserinfo(c, oidcCode.Sub)
if err != nil {
return repository.OidcCode{}, err
}
return repository.OidcCode{}, ErrCodeExpired
}
return oidcCode, nil
}
func (service *OIDCService) generateIDToken(client config.OIDCClientConfig, sub string) (string, error) {
createdAt := time.Now().Unix()
// TODO: This should probably be user-configured if refresh logic does not exist
expiresAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
claims := jws.ClaimSet{
Iss: service.issuer,
Aud: client.ClientID,
Sub: sub,
Iat: createdAt,
Exp: expiresAt,
}
header := jws.Header{
Algorithm: "RS256",
Typ: "JWT",
}
token, err := jws.Encode(&header, &claims, service.privateKey)
if err != nil {
return "", err
}
return token, nil
}
func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OIDCClientConfig, sub string, scope string) (TokenResponse, error) {
idToken, err := service.generateIDToken(client, sub)
if err != nil {
return TokenResponse{}, err
}
accessToken := rand.Text()
expiresAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
tokenResponse := TokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: int64(time.Hour.Seconds()),
IDToken: idToken,
Scope: strings.ReplaceAll(scope, ",", " "),
}
_, err = service.queries.CreateOidcToken(c, repository.CreateOidcTokenParams{
Sub: sub,
AccessToken: accessToken,
Scope: scope,
ExpiresAt: expiresAt,
})
if err != nil {
return TokenResponse{}, err
}
return tokenResponse, nil
}
func (service *OIDCService) DeleteCodeEntry(c *gin.Context, code string) error {
return service.queries.DeleteOidcCode(c, code)
}
func (service *OIDCService) DeleteUserinfo(c *gin.Context, sub string) error {
return service.queries.DeleteOidcUserInfo(c, sub)
}
func (service *OIDCService) DeleteToken(c *gin.Context, token string) error {
return service.queries.DeleteOidcToken(c, token)
}
func (service *OIDCService) GetAccessToken(c *gin.Context, token string) (repository.OidcToken, error) {
entry, err := service.queries.GetOidcToken(c, token)
if err != nil {
if err == sql.ErrNoRows {
return repository.OidcToken{}, ErrTokenNotFound
}
return repository.OidcToken{}, err
}
if entry.ExpiresAt < time.Now().Unix() {
err := service.DeleteToken(c, token)
if err != nil {
return repository.OidcToken{}, err
}
err = service.DeleteUserinfo(c, entry.Sub)
if err != nil {
return repository.OidcToken{}, err
}
return repository.OidcToken{}, ErrTokenExpired
}
return entry, nil
}
func (service *OIDCService) GetUserinfo(c *gin.Context, sub string) (repository.OidcUserinfo, error) {
return service.queries.GetOidcUserInfo(c, sub)
}
func (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope string) UserinfoResponse {
scopes := strings.Split(scope, ",") // split by comma since it's a db entry
userInfo := UserinfoResponse{
Sub: user.Sub,
UpdatedAt: user.UpdatedAt,
}
if slices.Contains(scopes, "profile") {
userInfo.Name = user.Name
userInfo.PreferredUsername = user.PreferredUsername
}
if slices.Contains(scopes, "email") {
userInfo.Email = user.Email
}
if slices.Contains(scopes, "groups") {
userInfo.Groups = strings.Split(user.Groups, ",")
}
return userInfo
}