aboutsummaryrefslogtreecommitdiffstats
path: root/pkg/auth/auth.go
blob: 8b72c24f5d07ebdf9bf55e11286beb4faccfd4f5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// Copyright 2021 syzkaller project authors. All rights reserved.
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.

// Relies on tokeninfo because it is properly documented:
// https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken

// The client
// The VM that wants to invoke the API:
// 1) Gets a token from the metainfo server with this http request:
//      META=http://metadata.google.internal/computeMetadata/v1
//      AUD=https://syzkaller.appspot.com/api
//      curl -sH 'Metadata-Flavor: Google' \
//           "$META/instance/service-accounts/default/identity?audience=$AUD"
// 2) Invokes /api with header 'Authorization: Bearer <token>'
//
// The AppEngine api server:
// 1) Receive the token, invokes this http request:
//      curl -s "https://oauth2.googleapis.com/tokeninfo?id_token=<token>"
// 2) Checks the resulting JSON having the expected audience and expiration.
// 3) Looks up the permissions in the config using the value of sub.
//
// https://cloud.google.com/iap/docs/signed-headers-howto#retrieving_the_user_identity
// from the IAP docs agrees to trust sub.

// Package auth contains authentication related code supporting secret
// passwords and oauth2 tokens on GCE.
package auth

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

const (
	// The official google oauth2 endpoint.
	GoogleTokenInfoEndpoint = "https://oauth2.googleapis.com/tokeninfo"
	// Used in the config map as a prefix to distinguish auth identifiers from secret passwords
	// (which contain arbitrary strings, that can't have this prefix).
	OauthMagic = "OauthSubject:"
)

// Represent a verification backend.
type Endpoint struct {
	// URL supporting tokeninfo auth2 protocol.
	url string
	// TODO(blackgnezdo): cache tokens with a bit of care for concurrency.
}

func MakeEndpoint(u string) Endpoint {
	return Endpoint{url: u}
}

// The JSON representation of JWT claims.
type jwtClaimsParse struct {
	Subject  string `json:"sub"`
	Audience string `json:"aud"`
	// The field in the JSON is a string but contains a UNIX time.
	Expiration string `json:"exp"`
}

// The typed representation of JWT claims.
type jwtClaims struct {
	Subject  string
	Audience string
	// The app uses the typed value.
	Expiration time.Time
}

func (auth *Endpoint) queryTokenInfo(tokenValue string) (*jwtClaims, error) {
	resp, err := http.PostForm(auth.url, url.Values{"id_token": {tokenValue}})
	if err != nil {
		return nil, fmt.Errorf("http.PostForm: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("verification failed %v", resp.StatusCode)
	}
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("io.ReadAll: %w", err)
	}
	claims := new(jwtClaimsParse)
	if err = json.Unmarshal(body, claims); err != nil {
		return nil, fmt.Errorf("json.Unmarshal: %w", err)
	}
	expInt, err := strconv.ParseInt(claims.Expiration, 10, 64)
	if err != nil {
		return nil, fmt.Errorf("strconv.ParseInt: %w", err)
	}
	r := jwtClaims{
		Subject:    claims.Subject,
		Audience:   claims.Audience,
		Expiration: time.Unix(expInt, 0),
	}
	return &r, nil
}

// Returns the verified subject value based on the provided header
// value or "" if it can't be determined. A valid result starts with
// auth.OauthMagic. The now parameter is the current time to compare the
// claims against. The authHeader is styled as is typical for HTTP headers
// which carry the tokens prefixed by "Bearer " string.
func (auth *Endpoint) DetermineAuthSubj(now time.Time, authHeader []string) (string, error) {
	if len(authHeader) != 1 || !strings.HasPrefix(authHeader[0], "Bearer") {
		// This is a normal case when the client uses a password.
		return "", nil
	}
	// Values past this point are real authentication attempts. Whether
	// or not they are valid is the question.
	tokenValue := strings.TrimSpace(strings.TrimPrefix(authHeader[0], "Bearer"))
	claims, err := auth.queryTokenInfo(tokenValue)
	if err != nil {
		return "", fmt.Errorf("auth.queryTokenInfo: %w", err)
	}
	if claims.Audience != DashboardAudience {
		return "", fmt.Errorf("unexpected audience %v", claims.Audience)
	}
	if claims.Expiration.Before(now) {
		return "", fmt.Errorf("token past expiration %v", claims.Expiration)
	}
	return OauthMagic + claims.Subject, nil
}