Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 130 additions & 4 deletions cmd/op/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,11 +527,44 @@ func claimsForScopes(user *users.User, scopeStr string) map[string]any {
}
}

// "pid" / "pid_1_8" / "pid_1_5" scopes → Person Identification Data.
//
// These three have been advertised in scopes_supported all along with no
// handler behind them, so a request naming one fell through to the
// backwards-compat block below and got four claims - given_name,
// family_name, name, email - with no birthdate, nationality, place of
// birth or issuing authority. That is not enough to build a PID from, so
// the issuer rejects the credential request outright.
//
// ARF 1.5 and ARF 1.8 describe the same person with different claim NAMES,
// and apigw uses the OIDC claims verbatim as the credential's document
// data - a claim under the other version's name simply isn't present to
// disclose, and a verifier's DCQL query for it won't match. So the two get
// separate branches rather than one shared set:
//
// ARF 1.8 ARF 1.5
// place_of_birth birth_place
// nationalities [] nationality (single)
// age_equal_or_over age_over_NN (flat booleans)
//
// "pid" (the unversioned scope) follows ARF 1.8, the current version.
// A request naming several of these is not meaningful - they are three
// spellings of one credential - so the most specific one wins rather than
// letting one overwrite the other's differently-named claims.
switch {
case has("pid_1_5"):
addPIDClaims(claims, user, false)
case has("pid_1_8"), has("pid"):
addPIDClaims(claims, user, true)
}

// If no recognized scopes matched, return all claims (backwards compat).
// "ehic" counts as recognized: without it here, an ehic-only request (which
// is what apigw sends - Scopes: []string{session.CredentialType}) would
// fall through and overwrite the EHIC claims above with profile ones.
if !has("profile") && !has("email") && !has("organisation") && !has("ehic") {
// Every scope handled above must be listed here: otherwise a request for
// exactly that scope - which is what apigw sends, one credential type at a
// time - matches nothing, falls through, and overwrites the claims that
// handler just set with the generic profile ones.
if !has("profile") && !has("email") && !has("organisation") && !has("ehic") &&
!has("pid") && !has("pid_1_5") && !has("pid_1_8") {
claims["given_name"] = user.GivenName
claims["family_name"] = user.FamilyName
claims["name"] = user.Name
Expand All @@ -541,6 +574,99 @@ func claimsForScopes(user *users.User, scopeStr string) map[string]any {
return claims
}

// addPIDClaims writes the Person Identification Data claims for one ARF
// version into claims. arf18 selects the ARF 1.8 vocabulary; false selects
// ARF 1.5. See the call site for why the two can't share one claim set.
//
// Only non-empty values are written, so a user carrying no PID data yields no
// PID claims rather than empty ones (same rule the ehic branch follows) - an
// empty string is a value the issuer would faithfully put in the credential.
func addPIDClaims(claims map[string]any, user *users.User, arf18 bool) {
if user.GivenName != "" {
claims["given_name"] = user.GivenName
}
if user.FamilyName != "" {
claims["family_name"] = user.FamilyName
}
if user.Birthdate != "" {
claims["birthdate"] = user.Birthdate
}
if user.IssuingAuthority != "" {
claims["issuing_authority"] = user.IssuingAuthority
}
if user.IssuingCountry != "" {
claims["issuing_country"] = user.IssuingCountry
}

if arf18 {
if user.PlaceOfBirth != "" {
claims["place_of_birth"] = user.PlaceOfBirth
}
if len(user.Nationalities) > 0 {
claims["nationalities"] = user.Nationalities
}
} else {
if user.PlaceOfBirth != "" {
claims["birth_place"] = user.PlaceOfBirth
}
// ARF 1.5 carries a single nationality, not a list.
if len(user.Nationalities) > 0 {
claims["nationality"] = user.Nationalities[0]
}
}

// Age attributes are derived from birthdate rather than stored per user,
// so they can't go stale as the fixture data ages. Without them a verifier
// asking for age_over_18 (ARF 1.5) or age_equal_or_over.18 (ARF 1.8) finds
// nothing to match - DCQL requires every requested claim to be present.
age, born, ok := ageInYears(user.Birthdate, time.Now())
if !ok {
return
}
claims["age_in_years"] = age
claims["age_birth_year"] = born.Year()

thresholds := []int{14, 16, 18, 21, 65}
if arf18 {
over := make(map[string]bool, len(thresholds))
for _, t := range thresholds {
over[fmt.Sprintf("%d", t)] = age >= t
}
claims["age_equal_or_over"] = over
return
}
for _, t := range thresholds {
claims[fmt.Sprintf("age_over_%d", t)] = age >= t
}
}

// pidBirthdateLayout is the full-date form RFC 7519 / OpenID Connect use for
// the birthdate claim, and the form users.yaml is written in.
const pidBirthdateLayout = "2006-01-02"

// ageInYears returns the user's completed years of age at now, along with the
// parsed birthdate. ok is false if birthdate is empty, not a full date, or in
// the future - in which case no age attribute can be derived and none should
// be emitted.
//
// The birthday check compares month and day rather than day-of-year: in a leap
// year every date after February shifts by one day-of-year, which would report
// the wrong age for a whole day around each such birthday.
func ageInYears(birthdate string, now time.Time) (int, time.Time, bool) {
born, err := time.Parse(pidBirthdateLayout, birthdate)
if err != nil {
return 0, time.Time{}, false
}
age := now.Year() - born.Year()
if now.Month() < born.Month() || (now.Month() == born.Month() && now.Day() < born.Day()) {
age--
}
if age < 0 {
return 0, time.Time{}, false
}
return age, born, true
}

// --- Helpers ---

func verifyPKCE(verifier, challenge, method string) bool {
Expand Down
143 changes: 143 additions & 0 deletions cmd/op/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"testing"
"time"

"github.com/sirosfoundation/mini-oidc/internal/users"
)
Expand Down Expand Up @@ -149,3 +150,145 @@ func TestClaimsForScopesEHICOmittedWhenNil(t *testing.T) {
}
}
}

func pidUser() *users.User {
u := testUser()
u.Birthdate = "1990-01-15"
u.PlaceOfBirth = "Stockholm"
u.Nationalities = []string{"SE", "NO"}
u.IssuingAuthority = "Swedish Tax Agency"
u.IssuingCountry = "SE"
return u
}

// apigw requests the credential type as the sole scope
// (Scopes: []string{session.CredentialType}), so a bare "pid_1_8" is the shape
// that actually reaches the OP during issuance - not "profile".
func TestClaimsForScopesPIDARF18(t *testing.T) {
claims := claimsForScopes(pidUser(), "openid pid_1_8")

if claims["birthdate"] != "1990-01-15" {
t.Fatalf("expected birthdate, got %v", claims)
}
if claims["place_of_birth"] != "Stockholm" {
t.Fatalf("expected ARF 1.8 place_of_birth, got %v", claims)
}
nats, ok := claims["nationalities"].([]string)
if !ok || len(nats) != 2 {
t.Fatalf("expected ARF 1.8 nationalities list, got %T %v", claims["nationalities"], claims["nationalities"])
}
if claims["issuing_authority"] != "Swedish Tax Agency" || claims["issuing_country"] != "SE" {
t.Fatalf("expected issuing authority/country, got %v", claims)
}

// ARF 1.5 spellings must NOT appear: a claim under the wrong version's
// name is not present to disclose, and pollutes the credential besides.
for _, k := range []string{"birth_place", "nationality", "age_over_18"} {
if _, ok := claims[k]; ok {
t.Fatalf("ARF 1.8 must not emit the 1.5 claim %q, got %v", k, claims)
}
}

over, ok := claims["age_equal_or_over"].(map[string]bool)
if !ok {
t.Fatalf("expected age_equal_or_over map, got %T", claims["age_equal_or_over"])
}
if !over["18"] {
t.Fatalf("a person born in 1990 is over 18, got %v", over)
}
if over["65"] {
t.Fatalf("a person born in 1990 is not yet 65, got %v", over)
}
}

func TestClaimsForScopesPIDARF15(t *testing.T) {
claims := claimsForScopes(pidUser(), "openid pid_1_5")

if claims["birth_place"] != "Stockholm" {
t.Fatalf("expected ARF 1.5 birth_place, got %v", claims)
}
if claims["nationality"] != "SE" {
t.Fatalf("expected ARF 1.5 single nationality, got %v", claims["nationality"])
}
if claims["age_over_18"] != true || claims["age_over_65"] != false {
t.Fatalf("expected flat ARF 1.5 age booleans, got %v", claims)
}

for _, k := range []string{"place_of_birth", "nationalities", "age_equal_or_over"} {
if _, ok := claims[k]; ok {
t.Fatalf("ARF 1.5 must not emit the 1.8 claim %q, got %v", k, claims)
}
}
}

// The unversioned "pid" scope follows the current ARF version.
func TestClaimsForScopesPIDUnversionedFollowsARF18(t *testing.T) {
claims := claimsForScopes(pidUser(), "openid pid")

if _, ok := claims["age_equal_or_over"]; !ok {
t.Fatalf("expected \"pid\" to use the ARF 1.8 vocabulary, got %v", claims)
}
if _, ok := claims["nationality"]; ok {
t.Fatalf("expected \"pid\" not to use ARF 1.5 spellings, got %v", claims)
}
}

// Regression guard for the bug this fixes: these scopes were advertised in
// scopes_supported with no handler, so they fell through to the
// backwards-compat block and released four claims - nowhere near enough to
// build a PID, which made the issuer reject the whole credential request.
func TestClaimsForScopesPIDDoesNotFallThrough(t *testing.T) {
for _, scope := range []string{"pid", "pid_1_5", "pid_1_8"} {
claims := claimsForScopes(pidUser(), "openid "+scope)
if _, ok := claims["name"]; ok {
t.Fatalf("%s must not fall through to the backwards-compat claims, got %v", scope, claims)
}
if _, ok := claims["birthdate"]; !ok {
t.Fatalf("%s released no birthdate - the fallthrough bug is back: %v", scope, claims)
}
}
}

func TestClaimsForScopesPIDOmitsAgeWithoutBirthdate(t *testing.T) {
u := pidUser()
u.Birthdate = ""
claims := claimsForScopes(u, "openid pid_1_8")

for _, k := range []string{"birthdate", "age_equal_or_over", "age_in_years", "age_birth_year"} {
if _, ok := claims[k]; ok {
t.Fatalf("expected no %s for a user without a birthdate, got %v", k, claims)
}
}
}

func TestAgeInYearsBirthdayBoundary(t *testing.T) {
// Day before, day of, and day after an 18th birthday. The day-of case is
// why this compares month/day rather than day-of-year: 2026 is not a leap
// year but 2008 was, so the two dates' day-of-year numbers differ.
for _, tc := range []struct {
now string
want int
}{
{"2026-06-19", 17},
{"2026-06-20", 18},
{"2026-06-21", 18},
} {
now, err := time.Parse("2006-01-02", tc.now)
if err != nil {
t.Fatal(err)
}
got, _, ok := ageInYears("2008-06-20", now)
if !ok || got != tc.want {
t.Fatalf("ageInYears at %s = %d (ok=%v), want %d", tc.now, got, ok, tc.want)
}
}
}

func TestAgeInYearsRejectsUnparseableOrFuture(t *testing.T) {
now, _ := time.Parse("2006-01-02", "2026-06-20")
for _, bd := range []string{"", "not-a-date", "1990", "2030-01-01"} {
if _, _, ok := ageInYears(bd, now); ok {
t.Fatalf("expected ageInYears(%q) to report no derivable age", bd)
}
}
}
Loading