mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 21:59:05 +02:00
238 lines
7.1 KiB
Go
238 lines
7.1 KiB
Go
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/pocket-id/fosite"
|
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
idTokenType = "id-token"
|
|
)
|
|
|
|
const emailVerificationRequiredHint = "You must verify your email address before you can access this service."
|
|
|
|
type ClaimsService struct {
|
|
db *gorm.DB
|
|
customClaims CustomClaimSource
|
|
baseURL string
|
|
signer TokenSigner
|
|
appConfig appconfig.AppConfigResolver
|
|
}
|
|
|
|
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner, appConfig appconfig.AppConfigResolver) *ClaimsService {
|
|
return &ClaimsService{
|
|
db: db,
|
|
customClaims: customClaims,
|
|
baseURL: baseURL,
|
|
signer: signer,
|
|
appConfig: appConfig,
|
|
}
|
|
}
|
|
|
|
// isEmailVerificationPending reports whether the user still has to verify their email before tokens can be issued for them
|
|
func (s *ClaimsService) isEmailVerificationPending(ctx context.Context, user *model.User) (bool, error) {
|
|
if !emailverification.CanBePending(user) {
|
|
return false, nil
|
|
}
|
|
|
|
cfg, err := s.appConfig.GetConfig(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return emailverification.IsPending(cfg, user), nil
|
|
}
|
|
|
|
// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
|
|
// still allowed to obtain tokens for the client.
|
|
func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
|
|
// Grants without a resource owner (e.g. client_credentials) carry an empty subject
|
|
// and have no user to validate.
|
|
if userID == "" {
|
|
return nil
|
|
}
|
|
|
|
var user model.User
|
|
err := dbFromContext(ctx, s.db).
|
|
Preload("UserGroups").
|
|
First(&user, "id = ?", userID).
|
|
Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fosite.ErrInvalidGrant.WithHint("The user account no longer exists.")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if user.Disabled {
|
|
return fosite.ErrInvalidGrant.WithHint("The user account is disabled.")
|
|
}
|
|
|
|
// The authorization middleware already stops these users at the authorize step, but grants issued before the email became unverified, such as refresh tokens, are only checked here
|
|
pending, err := s.isEmailVerificationPending(ctx, &user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if pending {
|
|
return fosite.ErrInvalidGrant.WithHint(emailVerificationRequiredHint)
|
|
}
|
|
|
|
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
|
|
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyIDTokenClaims applies the claims of a user to the ID token claims in the session based on the requested scopes.
|
|
func (s *ClaimsService) applyIDTokenClaims(ctx context.Context, session *Session, scopes fosite.Arguments) error {
|
|
userID := session.Subject
|
|
if userID == "" {
|
|
return nil
|
|
}
|
|
|
|
claims, err := s.GetUserClaims(ctx, userID, scopes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Record the signing algorithm on the ID token header so fosite derives the at_hash/
|
|
// c_hash digest from it (e.g. RS384 -> SHA-384, ES512 -> SHA-512). Without this the
|
|
// header is empty and fosite defaults to SHA-256, producing wrong hashes whenever the
|
|
// signing key is not a 256-bit algorithm. ToMap() strips "alg" before signing, so this
|
|
// never overrides the real JWS header. The signer is always wired in production; it is
|
|
// only nil in unit tests that do not assert hash correctness.
|
|
if s.signer != nil {
|
|
alg, err := s.signer.GetKeyAlg()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
session.IDTokenHeaders().Add("alg", alg.String())
|
|
}
|
|
|
|
applyUserClaimsToIDToken(session, userID, claims)
|
|
return nil
|
|
}
|
|
|
|
func applyUserClaimsToIDToken(session *Session, userID string, claims map[string]any) {
|
|
idTokenClaims := session.IDTokenClaims()
|
|
idTokenClaims.Subject = userID
|
|
idTokenClaims.Extra = claims
|
|
idTokenClaims.Extra[common.TokenTypeClaim] = idTokenType
|
|
if session.AuthenticationMethod != "" {
|
|
idTokenClaims.AuthenticationMethodsReferences = []string{session.AuthenticationMethod}
|
|
}
|
|
}
|
|
|
|
// GetUserClaims retrieves the claims for a user based on the requested scopes. It includes standard claims
|
|
// like "sub" and "email" as well as any custom claims defined for the user or their groups.
|
|
func (s *ClaimsService) GetUserClaims(ctx context.Context, userID string, scopes []string) (map[string]any, error) {
|
|
db := dbFromContext(ctx, s.db)
|
|
|
|
var user model.User
|
|
err := db.
|
|
Preload("UserGroups").
|
|
First(&user, "id = ?", userID).
|
|
Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
claims := make(map[string]any, 10)
|
|
|
|
if slices.Contains(scopes, "profile") {
|
|
customClaims, err := s.customClaims.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, customClaim := range customClaims {
|
|
claims[customClaim.Key] = parseCustomClaimValue(customClaim.Value)
|
|
}
|
|
|
|
claims["given_name"] = user.FirstName
|
|
claims["family_name"] = user.LastName
|
|
claims["name"] = user.FullName()
|
|
claims["display_name"] = user.DisplayName
|
|
claims["preferred_username"] = user.Username
|
|
claims["picture"] = s.baseURL + "/api/users/" + user.ID + "/profile-picture.png"
|
|
}
|
|
|
|
claims["sub"] = user.ID
|
|
|
|
// Only release the email claims when the user actually has an email. Emitting
|
|
// email_verified alongside a null/absent email (OIDC Core §5.1) is malformed and can
|
|
// mislead relying parties that key trust decisions on email_verified.
|
|
if slices.Contains(scopes, "email") && user.Email != nil && *user.Email != "" {
|
|
claims["email"] = *user.Email
|
|
claims["email_verified"] = user.EmailVerified
|
|
}
|
|
|
|
if slices.Contains(scopes, "groups") {
|
|
userGroups := make([]string, len(user.UserGroups))
|
|
for i, group := range user.UserGroups {
|
|
userGroups[i] = group.Name
|
|
}
|
|
claims["groups"] = userGroups
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// parseCustomClaimValue decodes a custom claim value that is a JSON document and returns any other value as a plain string
|
|
// Integers are kept as int64 because float64 drops digits past 2^53 and is written in exponent form in signed tokens
|
|
func parseCustomClaimValue(value string) any {
|
|
decoder := json.NewDecoder(strings.NewReader(value))
|
|
decoder.UseNumber()
|
|
|
|
var parsed any
|
|
if err := decoder.Decode(&parsed); err != nil || decoder.Decode(new(any)) != io.EOF {
|
|
return value
|
|
}
|
|
|
|
normalized, err := normalizeJSONNumbers(parsed)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// normalizeJSONNumbers replaces every json.Number with an int64 when it is an integer and with a float64 otherwise
|
|
func normalizeJSONNumbers(value any) (any, error) {
|
|
switch v := value.(type) {
|
|
case json.Number:
|
|
if i, err := v.Int64(); err == nil {
|
|
return i, nil
|
|
}
|
|
return v.Float64()
|
|
case map[string]any:
|
|
for key, item := range v {
|
|
normalized, err := normalizeJSONNumbers(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v[key] = normalized
|
|
}
|
|
case []any:
|
|
for i, item := range v {
|
|
normalized, err := normalizeJSONNumbers(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v[i] = normalized
|
|
}
|
|
}
|
|
return value, nil
|
|
}
|