diff --git a/frontend/src/lib/hooks/screen-params.ts b/frontend/src/lib/hooks/screen-params.ts index 6a459733..854c64fa 100644 --- a/frontend/src/lib/hooks/screen-params.ts +++ b/frontend/src/lib/hooks/screen-params.ts @@ -7,6 +7,7 @@ type ScreenParams = { oidc_scope?: string; oidc_name?: string; oidc_prompt?: "none" | "login"; + logout_reason?: "oidc"; }; const zodScreenParams = z.object({ @@ -16,6 +17,7 @@ const zodScreenParams = z.object({ oidc_scope: z.string().optional(), oidc_name: z.string().optional(), oidc_prompt: z.enum(["none", "login"]).optional(), + logout_reason: z.enum(["oidc"]).optional(), }); export function useScreenParams(params: URLSearchParams): ScreenParams { diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 8396b225..8aa53a7c 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -30,6 +30,8 @@ "logoutSuccessTitle": "Logged out", "logoutSuccessSubtitle": "You have been logged out", "logoutTitle": "Logout", + "logoutOidcTitle": "Confirm logout", + "logoutOidcSubtitle": "We couldn't verify where to redirect you after logout. Are you sure you want to log out?", "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", "notFoundTitle": "Page not found", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 8396b225..8aa53a7c 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -30,6 +30,8 @@ "logoutSuccessTitle": "Logged out", "logoutSuccessSubtitle": "You have been logged out", "logoutTitle": "Logout", + "logoutOidcTitle": "Confirm logout", + "logoutOidcSubtitle": "We couldn't verify where to redirect you after logout. Are you sure you want to log out?", "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", "notFoundTitle": "Page not found", diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 78ef0555..bfe37a59 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -9,35 +9,61 @@ import { import { useUserContext } from "@/context/user-context"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { Navigate } from "react-router"; +import { Navigate, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; -import { type UseMutationResult } from "@tanstack/react-query"; -import { type AxiosResponse } from "axios"; -import { useLocation } from "react-router"; import { searchParamsFromObject, useScreenParams, } from "@/lib/hooks/screen-params"; export const LogoutPage = () => { - const { auth, oauth, tailscale } = useUserContext(); + const { auth } = useUserContext(); const { t } = useTranslation(); const { search } = useLocation(); + const navigate = useNavigate(); + + const { logout_reason, ...loginParams } = useScreenParams( + new URLSearchParams(search), + ); + const params = searchParamsFromObject(loginParams).toString(); + const loginUrl = `/login${params ? `?${params}` : ""}`; + const { mutate, isPending, isSuccess } = useLogout(loginUrl); + + if (!auth.authenticated) { + return ; + } + + if (logout_reason === "oidc") { + return ( + navigate("/", { replace: true })} + > + {t("logoutOidcSubtitle")} + + ); + } + return ( + + + + ); +}; + +function useLogout(loginUrl: string) { + const { t } = useTranslation(); const redirectTimer = useRef(null); - const searchParams = new URLSearchParams(search); - const screenParams = useScreenParams(searchParams); - const compiledParams = (() => { - const params = searchParamsFromObject(screenParams).toString(); - if (params.length > 0) { - return `?${params}`; - } - return ""; - })(); - const logoutMutation = useMutation({ + const mutation = useMutation({ mutationFn: () => axios.post("/api/user/logout"), mutationKey: ["logout"], onSuccess: () => { @@ -46,7 +72,7 @@ export const LogoutPage = () => { }); redirectTimer.current = window.setTimeout(() => { - window.location.replace(`/login${compiledParams}`); + window.location.replace(loginUrl); }, 500); }, onError: () => { @@ -58,99 +84,78 @@ export const LogoutPage = () => { useEffect(() => { return () => { - if (redirectTimer.current) { - clearTimeout(redirectTimer.current); + if (redirectTimer.current !== null) { + window.clearTimeout(redirectTimer.current); } }; - }, [redirectTimer]); - - if (!auth.authenticated) { - return ; - } - - if (oauth.active) { - return ( - - , - }} - values={{ - username: auth.email, - provider: oauth.displayName, - }} - shouldUnescape={true} - /> - - ); - } - - if (auth.providerId === "tailscale") { - return ( - - , - }} - values={{ - deviceName: tailscale.nodeName, - }} - shouldUnescape={true} - /> - - ); - } + }, []); - return ( - - , - }} - values={{ - username: auth.username, - }} - shouldUnescape={true} - /> - - ); -}; + return mutation; +} -interface LogoutLayoutProps { - children: React.ReactNode; - logoutMutation: UseMutationResult< - //eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-empty-object-type - AxiosResponse, - Error, - void, - unknown - >; +interface LogoutCardProps { + title: string; + children: ReactNode; + isPending?: boolean; + onLogout?: () => void; + onCancel?: () => void; } -function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) { +function LogoutCard({ + title, + children, + isPending = false, + onLogout, + onCancel, +}: LogoutCardProps) { const { t } = useTranslation(); + return ( - + - {t("logoutTitle")} + {title} {children} - - - + {onLogout && ( + + + {onCancel && ( + + )} + + )} ); } + +function LogoutDescription() { + const { auth, oauth, tailscale } = useUserContext(); + const { t } = useTranslation(); + const subtitle = oauth.active + ? "logoutOauthSubtitle" + : auth.providerId === "tailscale" + ? "logoutTailscaleSubtitle" + : "logoutUsernameSubtitle"; + + return ( + }} + values={{ + username: oauth.active ? auth.email : auth.username, + provider: oauth.displayName, + deviceName: tailscale.nodeName, + }} + shouldUnescape={true} + /> + ); +} diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 580b5874..0c61a043 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -37,6 +37,7 @@ type OIDCController struct { log *logger.Logger oidc *service.OIDCService runtime *model.RuntimeConfig + auth *service.AuthService } type AuthorizeCallback struct { @@ -54,6 +55,13 @@ type TokenRequest struct { CodeVerifier string `form:"code_verifier" url:"code_verifier"` } +type EndSessionRequest struct { + IDTokenHint string `form:"id_token_hint" url:"id_token_hint"` + ClientID string `form:"client_id" url:"client_id"` + PostLogoutRedirectURI string `form:"post_logout_redirect_uri" url:"post_logout_redirect_uri"` + State string `form:"state" url:"state"` +} + type CallbackError struct { Error string `url:"error"` ErrorDescription string `url:"error_description"` @@ -95,6 +103,7 @@ type OIDCControllerInput struct { Log *logger.Logger OIDCService *service.OIDCService RuntimeConfig *model.RuntimeConfig + AuthService *service.AuthService RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` MainRouter *gin.RouterGroup `name:"mainRouterGroup"` } @@ -104,6 +113,7 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController { log: i.Log, oidc: i.OIDCService, runtime: i.RuntimeConfig, + auth: i.AuthService, } i.MainRouter.POST("/authorize", controller.authorize) @@ -112,9 +122,11 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController { oidcGroup := i.RouterGroup.Group("/oidc") oidcGroup.POST("/authorize-complete", controller.authorizeComplete) oidcGroup.GET("/skip-consent", controller.skipConsent) - oidcGroup.POST("/token", controller.Token) - oidcGroup.GET("/userinfo", controller.Userinfo) - oidcGroup.POST("/userinfo", controller.Userinfo) + oidcGroup.POST("/token", controller.token) + oidcGroup.GET("/userinfo", controller.userinfo) + oidcGroup.POST("/userinfo", controller.userinfo) + oidcGroup.GET("/end-session", controller.endSession) + oidcGroup.POST("/end-session", controller.endSession) return controller } @@ -242,8 +254,7 @@ func (controller *OIDCController) authorize(c *gin.Context) { } if userContext.Authenticated { - authTime := time.Unix(userContext.AuthTime, 0) - if authTime.Add(time.Duration(maxAge) * time.Second).Before(time.Now()) { + if userContext.AuthTime.Add(time.Duration(maxAge) * time.Second).Before(time.Now()) { values.OIDCPrompt = service.OIDCPromptLogin } } @@ -489,7 +500,7 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) { }) } -func (controller *OIDCController) Token(c *gin.Context) { +func (controller *OIDCController) token(c *gin.Context) { if controller.oidc == nil { controller.log.App.Warn().Msg("Received OIDC request but OIDC server is not configured") c.JSON(500, gin.H{ @@ -662,7 +673,7 @@ func (controller *OIDCController) Token(c *gin.Context) { c.JSON(200, tokenResponse) } -func (controller *OIDCController) Userinfo(c *gin.Context) { +func (controller *OIDCController) userinfo(c *gin.Context) { if controller.oidc == nil { controller.log.App.Warn().Msg("Received OIDC userinfo request but OIDC server is not configured") c.JSON(500, gin.H{ @@ -759,6 +770,139 @@ func (controller *OIDCController) Userinfo(c *gin.Context) { c.JSON(200, controller.oidc.CompileUserinfo(userinfo, entry.Scope)) } +func (controller *OIDCController) endSession(c *gin.Context) { + if controller.oidc == nil { + controller.log.App.Warn().Msg("Received OIDC userinfo request but OIDC server is not configured") + controller.humanError(c, "OIDC server is not configured") + return + } + + var req EndSessionRequest + + err := c.Bind(&req) + if err != nil { + controller.log.App.Warn().Err(err).Msg("Failed to bind token request") + controller.humanError(c, "Invalid request") + return + } + + var idToken *service.ClaimSet + + if req.IDTokenHint != "" { + claims, err := controller.oidc.DecodeIDToken(req.IDTokenHint) + + if err != nil { + controller.log.App.Warn().Err(err).Msg("Failed to decode ID token hint") + controller.humanError(c, "Invalid ID token") + return + } + + idToken = claims + } + + clientId := req.ClientID + + if idToken != nil { + aud, err := idToken.GetAudience() + + // will never be not nil but just in case + if err != nil { + controller.log.App.Warn().Err(err).Msg("Failed to get audience from ID token") + controller.humanError(c, "Invalid ID token") + return + } + + if req.ClientID != "" && !slices.Contains(aud, req.ClientID) { + controller.log.App.Warn().Str("client_id", req.ClientID).Msg("Client ID does not match audience") + controller.humanError(c, "Client ID does not match audience") + return + } + + if clientId == "" && len(aud) > 0 { + // We are only dealing with Tinyauth ID tokens which we know contain only one value + clientId = aud[0] + } + } + + var client *model.OIDCClientConfig + + if clientId != "" { + cli, ok := controller.oidc.GetClient(clientId) + + if !ok { + controller.log.App.Warn().Str("clientId", clientId).Msg("Client not found") + controller.humanError(c, "Client not found") + return + } + + client = &cli + } + + if req.PostLogoutRedirectURI != "" { + if client == nil { + if req.ClientID == "" { + controller.log.App.Warn().Msg("Client ID not provided in logout request for post logout redirect URI") + } else { + controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found for post logout redirect URI") + } + c.Redirect(http.StatusFound, fmt.Sprintf("%s/logout?logout_reason=oidc", controller.oidc.GetIssuer())) + return + } + + if !slices.Contains(client.TrustedPostLogoutRedirectURIs, req.PostLogoutRedirectURI) { + controller.log.App.Warn().Str("client_id", client.ClientID).Str("post_logout_redirect_uri", req.PostLogoutRedirectURI).Msg("Post logout redirect URI not trusted") + c.Redirect(http.StatusFound, fmt.Sprintf("%s/logout?logout_reason=oidc", controller.oidc.GetIssuer())) + return + } + + u, err := url.Parse(req.PostLogoutRedirectURI) + + if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to parse logout redirect URI") + controller.humanError(c, "Invalid post logout redirect URI") + return + } + + uuid, err := c.Cookie(controller.runtime.SessionCookieName) + + if err == nil { + cookie, err := controller.auth.DeleteSession(c, uuid) + + if err != nil { + controller.log.App.Error().Err(err).Msg("Failed to delete session") + controller.humanError(c, "Failed to end session") + return + } + + http.SetCookie(c.Writer, cookie) + } + + ctx, err := new(model.UserContext).NewFromGin(c) + + if err == nil { + controller.log.AuditLogout(ctx.GetUsername(), ctx.GetProviderID(), c.ClientIP()) + } else { + controller.log.App.Warn().Err(err).Msg("Failed to get user context during logout, logging audit with unknown user") + controller.log.AuditLogout("unknown", "unknown", c.ClientIP()) + } + + if req.State != "" { + q := u.Query() + q.Set("state", req.State) + u.RawQuery = q.Encode() + } + + c.Redirect(http.StatusFound, u.String()) + return + } + + c.Redirect(http.StatusFound, fmt.Sprintf("%s/logout?logout_reason=oidc", controller.oidc.GetIssuer())) +} + +func (controller *OIDCController) humanError(c *gin.Context, reason string) { + c.Redirect(http.StatusFound, fmt.Sprintf("%s/error?error=%s", controller.oidc.GetIssuer(), url.QueryEscape(reason))) +} + func (controller *OIDCController) authorizeError(c *gin.Context, params authorizeErrorParams) { controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error") diff --git a/internal/controller/well_known_controller.go b/internal/controller/well_known_controller.go index a32c3a06..b1c04726 100644 --- a/internal/controller/well_known_controller.go +++ b/internal/controller/well_known_controller.go @@ -29,6 +29,7 @@ type OpenIDConnectConfiguration struct { AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserinfoEndpoint string `json:"userinfo_endpoint"` + EndSessionEndpoint string `json:"end_session_endpoint"` JwksUri string `json:"jwks_uri"` ScopesSupported []string `json:"scopes_supported"` ResponseTypesSupported []string `json:"response_types_supported"` @@ -81,6 +82,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context 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), + EndSessionEndpoint: fmt.Sprintf("%s/api/oidc/end-session", issuer), ScopesSupported: service.SupportedScopes, ResponseTypesSupported: service.SupportedResponseTypes, GrantTypesSupported: service.SupportedGrantTypes, diff --git a/internal/model/config.go b/internal/model/config.go index ce2f5051..6c9a918e 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -285,13 +285,14 @@ type OAuthServiceClaimsMap struct { } type OIDCClientConfig struct { - ID string `description:"OIDC client ID." yaml:"-" tinyauth:"-"` - ClientID string `description:"OIDC client ID." yaml:"clientId,omitempty"` - ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"` - ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"` - TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"` - Trusted bool `description:"Skip the consent screen for this trusted OIDC client." yaml:"trusted,omitempty"` - Name string `description:"Client name in UI." yaml:"name,omitempty"` + ID string `description:"OIDC client ID." yaml:"-" tinyauth:"-"` + ClientID string `description:"OIDC client ID." yaml:"clientId,omitempty"` + ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"` + ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"` + TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"` + TrustedPostLogoutRedirectURIs []string `description:"List of trusted post logout redirect URIs." yaml:"trustedPostLogoutRedirectUris,omitempty"` + Trusted bool `description:"Skip the consent screen for this trusted OIDC client." yaml:"trusted,omitempty"` + Name string `description:"Client name in UI." yaml:"name,omitempty"` } type ACLsConfig struct { diff --git a/internal/model/context.go b/internal/model/context.go index 03d76941..95114d80 100644 --- a/internal/model/context.go +++ b/internal/model/context.go @@ -3,6 +3,7 @@ package model import ( "errors" "strings" + "time" "github.com/gin-gonic/gin" "github.com/tinyauthapp/tinyauth/internal/repository" @@ -25,7 +26,7 @@ const ( type UserContext struct { Authenticated bool Provider ProviderType - AuthTime int64 + AuthTime time.Time Local *LocalContext OAuth *OAuthContext LDAP *LDAPContext @@ -111,7 +112,7 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) { func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext, error) { *c = UserContext{ Authenticated: !session.TotpPending, - AuthTime: session.CreatedAt, + AuthTime: time.Unix(session.CreatedAt, 0).UTC(), } switch session.Provider { diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index 44709071..67036891 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -72,29 +72,25 @@ var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)} // instead of calling the userinfo endpoint, so we include them in the ID token as well // for better compatibility with existing apps type ClaimSet struct { - Iss string `json:"iss"` - Aud string `json:"aud"` - Sub string `json:"sub"` - Iat int64 `json:"iat"` - Exp int64 `json:"exp"` - AuthTime int64 `json:"auth_time,omitempty"` - Name string `json:"name,omitempty"` - GivenName string `json:"given_name,omitempty"` - FamilyName string `json:"family_name,omitempty"` - MiddleName string `json:"middle_name,omitempty"` - Nickname string `json:"nickname,omitempty"` - Profile string `json:"profile,omitempty"` - Picture string `json:"picture,omitempty"` - Website string `json:"website,omitempty"` - Gender string `json:"gender,omitempty"` - Birthdate string `json:"birthdate,omitempty"` - Zoneinfo string `json:"zoneinfo,omitempty"` - Locale string `json:"locale,omitempty"` - Email string `json:"email,omitempty"` - EmailVerified bool `json:"email_verified,omitempty"` - PreferredUsername string `json:"preferred_username,omitempty"` - Groups []string `json:"groups,omitempty"` - Nonce string `json:"nonce,omitempty"` + jwt.RegisteredClaims + AuthTime *jwt.NumericDate `json:"auth_time,omitempty"` + Name string `json:"name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + Nickname string `json:"nickname,omitempty"` + Profile string `json:"profile,omitempty"` + Picture string `json:"picture,omitempty"` + Website string `json:"website,omitempty"` + Gender string `json:"gender,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Zoneinfo string `json:"zoneinfo,omitempty"` + Locale string `json:"locale,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Groups jwt.ClaimStrings `json:"groups,omitempty"` + Nonce string `json:"nonce,omitempty"` } // We use this struct as both a response struct and a struct to store userinfo @@ -153,7 +149,7 @@ type AuthorizeCodeEntry struct { Nonce string CodeChallenge string Userinfo UserinfoResponse - AuthTime int64 + AuthTime time.Time } type UsedCodeEntry struct { @@ -549,9 +545,9 @@ func (service *OIDCService) GetCodeEntry(codeHash string, clientId string) (*Aut return &entry, true } -func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user UserinfoResponse, scope string, nonce string, authTime *int64) (string, error) { - createdAt := time.Now().Unix() - expiresAt := time.Now().Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second).Unix() +func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, authTime *time.Time, sub, nonce string) (string, error) { + createdAt := time.Now().UTC() + expiresAt := createdAt.Add(time.Duration(service.config.Auth.SessionExpiry) * time.Second) hasher := sha256.New() @@ -578,24 +574,19 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user return "", err } - userInfo := service.CompileUserinfo(user, scope) - claims := ClaimSet{ - Iss: service.issuer, - Aud: client.ClientID, - Sub: user.Sub, - Iat: createdAt, - Exp: expiresAt, - Name: userInfo.Name, - Email: userInfo.Email, - EmailVerified: userInfo.EmailVerified, - PreferredUsername: userInfo.PreferredUsername, - Groups: userInfo.Groups, - Nonce: nonce, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: service.issuer, + Audience: jwt.ClaimStrings{client.ClientID}, + Subject: sub, + IssuedAt: jwt.NewNumericDate(createdAt), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + Nonce: nonce, } if authTime != nil { - claims.AuthTime = *authTime + claims.AuthTime = jwt.NewNumericDate(*authTime) } payload, err := json.Marshal(claims) @@ -619,8 +610,8 @@ func (service *OIDCService) generateIDToken(client model.OIDCClientConfig, user return token, nil } -func (service *OIDCService) GenerateAccessToken(ctx context.Context, client model.OIDCClientConfig, codeEntry AuthorizeCodeEntry, authTime int64) (*TokenResponse, error) { - idToken, err := service.generateIDToken(client, codeEntry.Userinfo, codeEntry.Scope, codeEntry.Nonce, &authTime) +func (service *OIDCService) GenerateAccessToken(ctx context.Context, client model.OIDCClientConfig, codeEntry AuthorizeCodeEntry, authTime time.Time) (*TokenResponse, error) { + idToken, err := service.generateIDToken(client, &authTime, codeEntry.Userinfo.Sub, codeEntry.Nonce) if err != nil { return nil, err @@ -689,20 +680,10 @@ func (service *OIDCService) RefreshAccessToken(ctx context.Context, refreshToken return nil, ErrInvalidClient } - // we need to unmarshal the userinfo from the database to include it in the new ID token, - // since the ID token includes user claims for better compatibility with existing apps - var userInfo UserinfoResponse - - err = json.Unmarshal([]byte(entry.UserinfoJson), &userInfo) - - if err != nil { - return nil, err - } - // TODO: store auth time in the database so we can include it in the new ID token, for now we omit it idToken, err := service.generateIDToken(model.OIDCClientConfig{ ClientID: entry.ClientID, - }, userInfo, entry.Scope, entry.Nonce, nil) + }, nil, entry.Sub, entry.Nonce) if err != nil { return nil, err @@ -956,6 +937,29 @@ func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) { service.caches.authorize.Delete(ticket) } +func (service *OIDCService) DecodeIDToken(tokenStr string) (*ClaimSet, error) { + var claims ClaimSet + + parser := jwt.NewParser( + jwt.WithExpirationRequired(), + jwt.WithValidMethods([]string{"RS256"}), + ) + + token, err := parser.ParseWithClaims(tokenStr, &claims, func(token *jwt.Token) (any, error) { + return service.publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse id token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid id token") + } + + return &claims, nil +} + // DecodeAuthorizeJWT TODO: support signed request objects in the future func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) { var claims jwt.MapClaims