lib/persona/persona.go (342 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package persona
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/testkeys" /* copybara-comment: testkeys */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/timeutil" /* copybara-comment: timeutil */
cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */
)
var (
// StandardClaims is the list of standard OIDC claims that personas import into GA4GH Identity objects.
StandardClaims = map[string]string{
"azp": "Authorized Party (application identifier)",
"email": "Email address",
"email_verified": "Email Verified (true or false)",
"family_name": "Family Name",
"given_name": "Given Name",
"iss": "Issuer of the Passport",
"locale": "Locale",
"middle_name": "Middle Name",
"name": "Full Name",
"nickname": "Nickname",
"picture": "Picture",
"preferred_username": "Preferred Username",
"profile": "Profile",
"sub": "Subject (user identifier)",
"zoneinfo": "Zone info (timezone)",
}
// DefaultScope is a list of standard scopes to request.
DefaultScope = "openid ga4gh_passport_v1"
// AccountScope has default scopes and the account_admin scope.
AccountScope = DefaultScope + " account_admin"
// LinkScope has account scope plus the additional account-linking scope.
LinkScope = AccountScope + " link"
// minPersonaFutureExpiry prevents users from setting expiries too close to now() that execution
// time across many personas may cause a test to accidently fail.
minPersonaFutureExpiry = 5 * time.Second
personaKey = testkeys.Keys[testkeys.PersonaBroker]
)
// AccessTokenWithPatient ...
type AccessTokenWithPatient struct {
ga4gh.AccessData
// Patient ...
Patient string `json:"scope,omitempty"`
}
// NewAccessToken returns an access token for a persona at a given issuer.
// The persona parameter may be nil.
func NewAccessToken(name, issuer, clientID, scope string, persona *cpb.TestPersona) (ga4gh.AccessJWT, string, error) {
now := time.Now().Unix()
sub := name
email := name
patient := ""
fhirUser := ""
if persona != nil {
if s := getStandardClaim(persona, "sub"); len(s) > 0 {
sub = s
email = s
}
if e := getStandardClaim(persona, "email"); len(e) > 0 {
email = e
}
patient = getStandardClaim(persona, "patient")
fhirUser = getStandardClaim(persona, "fhirUser")
}
if len(scope) == 0 {
scope = DefaultScope
}
d := &ga4gh.AccessData{
StdClaims: ga4gh.StdClaims{
Issuer: issuer,
Subject: sub,
IssuedAt: now,
ExpiresAt: now + 10000,
Audience: ga4gh.NewAudience(clientID),
ID: "token-id-" + name,
},
Scope: scope,
Identities: map[string][]string{
email: []string{"IC", "DAM"},
},
Patient: patient,
FhirUser: fhirUser,
}
ctx := context.Background()
signer := signer(&personaKey)
access, err := ga4gh.NewAccessFromData(ctx, d, signer)
if err != nil {
return "", "", err
}
return access.JWT(), sub, nil
}
// ToIdentity retuns an Identity from persona configuration settings.
func ToIdentity(ctx context.Context, name string, persona *cpb.TestPersona, scope, visaIssuer string) (*ga4gh.Identity, error) {
if persona.Passport == nil {
return nil, fmt.Errorf("persona %q has not configured a test identity token", name)
}
sub := getStandardClaim(persona, "sub")
if len(sub) == 0 {
sub = name
}
iss := visaIssuer
if len(iss) == 0 {
iss = getStandardClaim(persona, "iss")
visaIssuer = iss
}
firstName := getStandardClaim(persona, "firstName")
lastName := getStandardClaim(persona, "lastName")
fullName := getStandardClaim(persona, "name")
if len(fullName) == 0 && persona.Ui != nil && len(persona.Ui["label"]) > 0 {
fullName = persona.Ui["label"]
}
splitName := strings.Split(fullName, " ")
if len(splitName) > 0 && len(firstName) == 0 {
firstName = splitName[0]
}
if len(splitName) > 1 && len(lastName) == 0 {
lastName = splitName[1]
}
if len(splitName) == 1 && len(lastName) > 0 {
fullName = fullName + " " + lastName
} else if len(splitName) == 0 && len(firstName) > 0 && len(lastName) > 0 {
fullName = firstName + " " + lastName
} else if len(splitName) == 0 && len(firstName) > 0 {
fullName = firstName + " Persona"
} else if len(splitName) == 0 && len(lastName) > 0 {
fullName = "Sam " + lastName
} else if len(splitName) == 0 {
names := strings.FieldsFunc(name, nameSplit)
if len(names) > 1 {
fullName = strings.Join(names, " ")
} else {
fullName = name + " Persona"
}
if len(firstName) == 0 {
firstName = names[0]
}
if len(lastName) == 0 {
if len(names) > 1 {
lastName = names[1]
} else {
lastName = "Persona"
}
}
}
if len(firstName) == 0 || len(lastName) == 0 {
splitName = strings.Split(fullName, " ")
if len(firstName) == 0 {
firstName = splitName[0]
}
if len(lastName) == 0 {
if len(splitName) > 1 {
lastName = splitName[1]
} else {
lastName = "Persona"
}
}
}
nickname := getStandardClaim(persona, "nickname")
if nickname == "" {
nickname = strings.Split(toName(name), " ")[0]
}
if len(persona.Passport.ExtraScopes) > 0 {
scope = scope + " " + persona.Passport.ExtraScopes
}
email := getStandardClaim(persona, "email")
identity := ga4gh.Identity{
Subject: sub,
Email: email,
Issuer: iss,
Expiry: time.Now().Add(180 * 24 * time.Hour).Unix(),
Scope: scope,
AuthorizedParty: getStandardClaim(persona, "azp"),
Username: name,
EmailVerified: strings.ToLower(getStandardClaim(persona, "email_verified")) == "true",
Name: toName(fullName),
Nickname: nickname,
GivenName: toName(firstName),
FamilyName: toName(lastName),
MiddleName: getStandardClaim(persona, "middle_name"),
ZoneInfo: getStandardClaim(persona, "zoneinfo"),
Locale: getStandardClaim(persona, "locale"),
Picture: getStandardClaim(persona, "picture"),
Profile: getStandardClaim(persona, "profile"),
Patient: getStandardClaim(persona, "patient"),
FhirUser: getStandardClaim(persona, "fhirUser"),
Extra: map[string]interface{}{
"tid": sub,
},
}
if email != "" {
if persona.Passport.Ga4GhAssertions == nil {
persona.Passport.Ga4GhAssertions = []*cpb.Assertion{}
}
assert := &cpb.Assertion{
Type: "LinkedIdentities",
Value: url.QueryEscape(email) + "," + url.QueryEscape(visaIssuer),
Source: visaIssuer,
By: "system",
AssertedDuration: "30d",
ExpiresDuration: "30d",
}
persona.Passport.Ga4GhAssertions = append(persona.Passport.Ga4GhAssertions, assert)
}
if persona.Passport.Ga4GhAssertions == nil || len(persona.Passport.Ga4GhAssertions) == 0 {
return &identity, nil
}
return populatePersonaVisas(ctx, name, visaIssuer, persona.Passport.Ga4GhAssertions, &identity)
}
func toName(input string) string {
return strings.Join(strings.FieldsFunc(input, nameSplit), " ")
}
func nameSplit(r rune) bool {
return r == ' ' || r == '_' || r == '.' || r == '-'
}
func getStandardClaim(persona *cpb.TestPersona, claim string) string {
if persona.Passport.StandardClaims == nil || len(persona.Passport.StandardClaims[claim]) == 0 {
return ""
}
return persona.Passport.StandardClaims[claim]
}
func jkuURL(issuer string) string {
return strings.TrimSuffix(issuer, "/") + "/.well-known/jwks"
}
func populatePersonaVisas(ctx context.Context, pname, visaIssuer string, assertions []*cpb.Assertion, id *ga4gh.Identity) (*ga4gh.Identity, error) {
issuer := id.Issuer
jku := jkuURL(issuer)
id.GA4GH = make(map[string][]ga4gh.OldClaim)
id.VisaJWTs = make([]string, len(assertions))
now := float64(time.Now().Unix())
signer := signer(&personaKey)
for i, assert := range assertions {
typ := ga4gh.Type(assert.Type)
if len(typ) == 0 {
return nil, fmt.Errorf("persona %q visa %d missing assertion type", pname, i+1)
}
_, ok := id.GA4GH[assert.Type]
if !ok {
id.GA4GH[assert.Type] = make([]ga4gh.OldClaim, 0)
}
if len(assert.Value) == 0 {
return nil, fmt.Errorf("persona %q visa %d missing assertion value", pname, i+1)
}
src := ga4gh.Source(issuer)
if len(assert.Source) > 0 {
src = ga4gh.Source(assert.Source)
}
// assert.AssertedDuration cannot be negative and is assumed to be a duration in the past.
a, err := timeutil.ParseDuration(assert.AssertedDuration)
if err != nil {
return nil, fmt.Errorf("persona %q visa %d asserted duration %q: %v", pname, i+1, assert.AssertedDuration, err)
}
asserted := int64(now - a.Seconds())
// assert.ExpiresDuration may be negative or positive where a negative value represents the past.
e, err := timeutil.ParseDuration(assert.ExpiresDuration)
if err != nil {
return nil, fmt.Errorf("persona %q visa %d expires duration %q: %v", pname, i+1, assert.ExpiresDuration, err)
}
if e > 0 && e < minPersonaFutureExpiry {
e = minPersonaFutureExpiry
}
expires := int64(now + e.Seconds())
visa := ga4gh.VisaData{
StdClaims: ga4gh.StdClaims{
Subject: id.Subject,
Issuer: visaIssuer,
ExpiresAt: expires,
IssuedAt: int64(now),
},
Assertion: ga4gh.Assertion{
Type: typ,
Value: ga4gh.Value(assert.Value),
Source: src,
Asserted: asserted,
By: ga4gh.By(assert.By),
},
}
if len(assert.AnyOfConditions) > 0 {
visa.Assertion.Conditions = make(ga4gh.Conditions, 0)
for _, cond := range assert.AnyOfConditions {
clauses := []ga4gh.Condition{}
for _, clause := range cond.AllOf {
c := ga4gh.Condition{
Type: ga4gh.Type(clause.Type),
Value: ga4gh.Pattern(clause.Value),
Source: ga4gh.Pattern(clause.Source),
By: ga4gh.Pattern(clause.By),
}
clauses = append(clauses, c)
}
visa.Assertion.Conditions = append(visa.Assertion.Conditions, clauses)
}
}
v, err := ga4gh.NewVisaFromData(ctx, &visa, jku, signer)
if err != nil {
return nil, fmt.Errorf("signing persona %q visa %d failed: %s", pname, i+1, err)
}
id.VisaJWTs[i] = string(v.JWT())
// Populate old claims.
c := ga4gh.OldClaim{
Value: assert.Value,
Source: string(src),
Asserted: float64(asserted),
Expires: float64(expires),
By: assert.By,
}
if len(assert.AnyOfConditions) > 0 {
c.Condition = make(map[string]ga4gh.OldClaimCondition)
cType := ""
cValue := []string{}
cSource := []string{}
cBy := []string{}
for _, cond := range assert.AnyOfConditions {
for _, clause := range cond.AllOf {
cType = clause.Type
clValue := clause.Value
if clValues := strings.SplitN(clause.Value, ":", 2); len(clValues) > 1 {
clValue = clValues[1]
}
clSource := clause.Source
if clSources := strings.SplitN(clause.Source, ":", 2); len(clSources) > 1 {
clSource = clSources[1]
}
clBy := clause.By
if clBys := strings.SplitN(clause.By, ":", 2); len(clBys) > 1 {
clBy = clBys[1]
}
if len(clValue) > 0 {
cValue = append(cValue, clValue)
}
if len(clSource) > 0 {
cSource = append(cSource, clSource)
}
if len(clBy) > 0 {
cBy = append(cBy, clBy)
}
}
}
oldC := ga4gh.OldClaimCondition{}
if len(cValue) > 0 {
oldC.Value = cValue
}
if len(cSource) > 0 {
oldC.Source = cSource
}
if len(cBy) > 0 {
oldC.By = cBy
}
c.Condition[cType] = oldC
}
id.GA4GH[assert.Type] = append(id.GA4GH[assert.Type], c)
}
return id, nil
}