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