lib/ga4gh/identity.go (271 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 ga4gh
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/errutil" /* copybara-comment: errutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/globalflags" /* copybara-comment: globalflags */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/strutil" /* copybara-comment: strutil */
cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */
)
const (
// AccessTokenVisaFormat represents an "Embedded Access Token" visa format.
// See https://bit.ly/ga4gh-aai-profile#term-embedded-access-token.
AccessTokenVisaFormat VisaFormat = "access_token"
// DocumentVisaFormat represents an "Embedded Document Token" visa format.
// See https://bit.ly/ga4gh-aai-profile#term-embedded-document-token.
DocumentVisaFormat VisaFormat = "document"
// UnspecifiedVisaFormat is used when the token cannot be read or is not available.
UnspecifiedVisaFormat VisaFormat = ""
)
// VisaFormat indicates what GA4GH embedded token format is used for a visa.
// See https://bit.ly/ga4gh-aai-profile#embedded-token-issued-by-embedded-token-issuer.
type VisaFormat string
// OldClaim represents a claim object as defined by GA4GH.
type OldClaim struct {
Value string `json:"value"`
Source string `json:"source"`
Asserted float64 `json:"asserted,omitempty"`
Expires float64 `json:"expires,omitempty"`
Condition map[string]OldClaimCondition `json:"condition,omitempty"`
By string `json:"by,omitempty"`
Issuer string `json:"issuer,omitempty"`
VisaData *VisaData `json:"-"`
TokenFormat VisaFormat `json:"-"`
}
// OldClaimCondition represents a condition object as defined by GA4GH.
type OldClaimCondition struct {
Value []string `json:"value,omitempty"`
Source []string `json:"source,omitempty"`
By []string `json:"by,omitempty"`
}
// VisaRejection is filled in by a policy engine to understand why a visa was rejected.
// Visas unrelated to the policy are not considered rejected unless they are not trusted.
type VisaRejection struct {
Reason string `json:"reason,omitempty"`
Field string `json:"field,omitempty"`
Description string `json:"msg,omitempty"`
}
// RejectedVisa provides insight into why a policy engine is not making use of visas that
// are present within the passport.
type RejectedVisa struct {
TokenFormat string `json:"tokenFormat,omitempty"`
Issuer string `json:"iss,omitempty"`
Subject string `json:"sub,omitempty"`
Assertion Assertion `json:"assertion,omitempty"`
Rejection VisaRejection `json:"rejection,omitempty"`
}
// Identity is a GA4GH identity as described by the Data Use and Researcher
// Identity stream.
type Identity struct {
Subject string `json:"sub,omitempty"`
Issuer string `json:"iss,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Expiry int64 `json:"exp,omitempty"`
Scope string `json:"scope,omitempty"`
Scp []string `json:"scp,omitempty"`
Audiences Audiences `json:"aud,omitempty"`
AuthorizedParty string `json:"azp,omitempty"`
ID string `json:"jti,omitempty"`
TokenID string `json:"tid,omitempty"`
Nonce string `json:"nonce,omitempty"`
GA4GH map[string][]OldClaim `json:"-"` // do not emit
RejectedVisas []*RejectedVisa `json:"-"` // do not emit
IdentityProvider string `json:"idp,omitempty"`
Identities map[string][]string `json:"identities,omitempty"`
Username string `json:"preferred_username,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Name string `json:"name,omitempty"`
Nickname string `json:"nickname,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
ZoneInfo string `json:"zoneinfo,omitempty"`
Locale string `json:"locale,omitempty"`
Picture string `json:"picture,omitempty"`
Profile string `json:"profile,omitempty"`
Realm string `json:"realm,omitempty"`
VisaJWTs []string `json:"ga4gh_passport_v1,omitempty"`
Extra map[string]interface{} `json:"ext,omitempty"`
Patient string `json:"patient,omitempty"` // SMART-on-FHIR
FhirUser string `json:"fhirUser,omitempty"` // SMART-on-FHIR
}
// CheckIdentityAllVisasLinked checks if the Visas inside the identity are linked.
// Verifies all Visas of type LinkedIdentities.
// If JWTVerifier is not nil, will call f to verify LinkedIdentities Visas.
// If JWTVerifier is nil, verification is skipped.
func CheckIdentityAllVisasLinked(ctx context.Context, i *Identity, f JWTVerifier) error {
var visas []*Visa
for _, j := range i.VisaJWTs {
v, err := NewVisaFromJWT(VisaJWT(j))
if err != nil {
return err
}
if f != nil && v.Data().Assertion.Type == LinkedIdentities {
if err := f(ctx, j, v.Data().Issuer, v.JKU()); err != nil {
return fmt.Errorf("the verification of some LinkedIdentities visa failed: %v", err)
}
}
visas = append(visas, v)
}
return CheckLinkedIDs(visas)
}
// RejectVisa adds a new RejectedVisa report to the identity (up to a maximum number of reports).
func (t *Identity) RejectVisa(visa *VisaData, format VisaFormat, reason, field, message string) {
if len(t.RejectedVisas) > 20 {
return
}
t.RejectedVisas = append(t.RejectedVisas, NewRejectedVisa(visa, format, reason, field, message))
}
// NewRejectedVisa creates a rejected visa information struct.
func NewRejectedVisa(visa *VisaData, format VisaFormat, reason, field, message string) *RejectedVisa {
if visa == nil {
visa = &VisaData{}
}
detail := VisaRejection{
Reason: reason,
Field: field,
Description: message,
}
return &RejectedVisa{
TokenFormat: string(format),
Issuer: visa.Issuer,
Subject: visa.Subject,
Assertion: visa.Assertion,
Rejection: detail,
}
}
// VisasToOldClaims populates the GA4GH claim based on visas.
// Returns a map of visa types, each having a list of OldClaims for that type.
// TODO: use new policy engine instead when it becomes available.
func VisasToOldClaims(ctx context.Context, visas []VisaJWT, f JWTVerifier) (map[string][]OldClaim, []*RejectedVisa, error) {
out := make(map[string][]OldClaim)
var rejected []*RejectedVisa
for i, j := range visas {
// Skip this visa on validation errors such that a bad visa doesn't spoil the bunch.
// But do return errors if the visas are not compatible with the old claim format.
v, err := NewVisaFromJWT(VisaJWT(j))
if err != nil {
rejected = append(rejected, NewRejectedVisa(nil, UnspecifiedVisaFormat, "invalid_visa", "", fmt.Sprintf("cannot unpack visa %d", i)))
continue
}
d := v.Data()
if len(d.Issuer) == 0 {
rejected = append(rejected, NewRejectedVisa(d, v.Format(), "iss_missing", "iss", "empty 'iss' field"))
continue
}
if reject := checkViaJKU(v); reject != nil {
rejected = append(rejected, reject)
continue
}
if f != nil {
if err := f(ctx, string(j), v.Data().Issuer, v.JKU()); err != nil {
reason := errutil.ErrorReason(err)
if len(reason) == 0 {
reason = "verify_failed"
}
rejected = append(rejected, NewRejectedVisa(d, v.Format(), reason, "", err.Error()))
continue
}
}
var cond map[string]OldClaimCondition
if len(d.Assertion.Conditions) > 0 {
// Conditions on visas are not supported in non-experimental mode.
if !globalflags.Experimental {
rejected = append(rejected, NewRejectedVisa(d, v.Format(), "condition_not_supported", "visa.condition", "visa conditions not supported"))
continue
}
cond, err = toOldClaimConditions(d.Assertion.Conditions)
if err != nil {
rejected = append(rejected, NewRejectedVisa(d, v.Format(), "condition_not_supported", "visa.condition", err.Error()))
continue
}
}
typ := string(d.Assertion.Type)
values := splitVisaValues(d.Assertion.Value, d.Assertion.Type)
for _, value := range values {
c := OldClaim{
Value: value,
Source: string(d.Assertion.Source),
Asserted: float64(d.Assertion.Asserted),
Expires: float64(d.ExpiresAt),
By: string(d.Assertion.By),
Issuer: d.Issuer,
VisaData: d,
TokenFormat: v.Format(),
Condition: cond,
}
out[typ] = append(out[typ], c)
}
}
return out, rejected, nil
}
func checkViaJKU(v *Visa) *RejectedVisa {
d := v.Data()
openid := strutil.ContainsWord(string(d.Scope), "openid")
if openid {
if len(v.JKU()) > 0 {
return NewRejectedVisa(d, v.Format(), "openid_jku", "", "visa has openid scope and jku")
}
return nil
}
if len(v.JKU()) == 0 {
return NewRejectedVisa(d, v.Format(), "no_openid_no_jku", "", "visa does not have openid scope and jku")
}
issuerURL, err := url.Parse(d.Issuer)
if err != nil {
return NewRejectedVisa(d, v.Format(), "issuer_url_parse", "", fmt.Sprintf("issuer url parse failed: %v", err))
}
jkuURL, err := url.Parse(v.JKU())
if err != nil {
return NewRejectedVisa(d, v.Format(), "jku_url_parse", "", fmt.Sprintf("jku url parse failed: %v", err))
}
if jkuURL.Host != issuerURL.Host {
return NewRejectedVisa(d, v.Format(), "jku_issuer_host", "", "jku does not have same host with visa issuer")
}
if !httputils.IsHTTPS(v.JKU()) && !httputils.IsLocalhost(v.JKU()) {
return NewRejectedVisa(d, v.Format(), "jku_https", "", "jku does not use https")
}
return nil
}
func splitVisaValues(value Value, typ Type) []string {
if typ != LinkedIdentities {
return []string{string(value)}
}
return strings.Split(string(value), ";")
}
func toOldClaimConditions(conditions Conditions) (map[string]OldClaimCondition, error) {
// Input is non-empty DNF: outer OR array with inner AND array.
if len(conditions) > 1 {
return nil, fmt.Errorf("unsupported visa condition: OR conditions are not supported")
}
out := make(map[string]OldClaimCondition)
for _, cond := range conditions[0] {
ctyp := string(cond.Type)
oldCond, ok := out[ctyp]
if ok {
// Old format only allows one sub-condition per visa type, and this
// sub-condition has already been populated, therefore the new
// condition is not compatible with the old claim format.
return nil, fmt.Errorf("unsupported visa condition: multiple conditions on the same visa type not supported")
}
if !ok {
oldCond = OldClaimCondition{}
}
if len(cond.Value) > 0 {
parts := strings.SplitN(string(cond.Value), ":", 2)
if len(parts) != 2 || parts[0] != "const" {
return nil, fmt.Errorf("unsupported visa condition: non-const condition on %q field", "value")
}
oldCond.Value = append(oldCond.Value, parts[1])
}
if len(cond.Source) > 0 {
parts := strings.SplitN(string(cond.Source), ":", 2)
if len(parts) != 2 || parts[0] != "const" {
return nil, fmt.Errorf("unsupported visa condition: non-const condition on %q field", "source")
}
oldCond.Source = append(oldCond.Source, parts[1])
}
if len(cond.By) > 0 {
parts := strings.SplitN(string(cond.By), ":", 2)
if len(parts) != 2 || parts[0] != "const" {
return nil, fmt.Errorf("unsupported visa condition: non-const condition on %q field", "by")
}
oldCond.By = append(oldCond.By, parts[1])
}
out[ctyp] = oldCond
}
return out, nil
}
func toVisaRejectionProto(in VisaRejection) *cpb.VisaRejection {
return &cpb.VisaRejection{
Reason: in.Reason,
Field: in.Field,
Description: in.Description,
}
}
// ToRejectedVisaProto convert RejectedVisa to proto.
func ToRejectedVisaProto(in *RejectedVisa) *cpb.RejectedVisa {
if in == nil {
return nil
}
return &cpb.RejectedVisa{
TokenFormat: in.TokenFormat,
Issuer: in.Issuer,
Subject: in.Subject,
Assertion: toAssertionProto(in.Assertion),
Rejection: toVisaRejectionProto(in.Rejection),
}
}