// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package cert
import (
"crypto"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"time"
"github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken"
)
// CreateX509Cert generates a self-signed x509 cert from a PK token
// - OP 'sub' claim is mapped to the CN and SANs fields
// - User public key is mapped to the RawSubjectPublicKeyInfo field
// - Raw PK token is mapped to the SubjectKeyId field
func CreateX509Cert(pkToken *pktoken.PKToken, signer crypto.Signer) ([]byte, error) {
template, err := PktToX509Template(pkToken)
if err != nil {
return nil, fmt.Errorf("error creating X.509 template: %w", err)
}
// create a self-signed X.509 certificate
certDER, err := x509.CreateCertificate(rand.Reader, template, template, signer.Public(), signer)
if err != nil {
return nil, fmt.Errorf("error creating X.509 certificate: %w", err)
}
certBlock := &pem.Block{Type: "CERTIFICATE", Bytes: certDER}
return pem.EncodeToMemory(certBlock), nil
}
// PktToX509Template takes a PK Token and returns a X.509 certificate template
// with the fields of the template set to the values in the X509
func PktToX509Template(pkt *pktoken.PKToken) (*x509.Certificate, error) {
pktJson, err := json.Marshal(pkt)
if err != nil {
return nil, fmt.Errorf("error marshalling PK token to JSON: %w", err)
}
// get subject identifier from pk token
idtClaims := new(oidc.OidcClaims)
if err := json.Unmarshal(pkt.Payload, idtClaims); err != nil {
return nil, err
}
cic, err := pkt.GetCicValues()
if err != nil {
return nil, err
}
upk := cic.PublicKey()
var rawkey interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey
if err := upk.Raw(&rawkey); err != nil {
return nil, err
}
// encode ephemeral public key
ecPub, err := x509.MarshalPKIXPublicKey(rawkey)
if err != nil {
return nil, fmt.Errorf("error marshalling public key: %w", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: idtClaims.Subject},
RawSubjectPublicKeyInfo: ecPub,
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // valid for 1 year
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
BasicConstraintsValid: true,
DNSNames: []string{idtClaims.Subject},
IsCA: false,
ExtraExtensions: []pkix.Extension{{
// OID for OIDC Issuer extension
Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1},
Critical: false,
Value: []byte(idtClaims.Issuer),
}},
SubjectKeyId: pktJson,
}
return template, nil
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package choosers
import (
"context"
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"sort"
"strings"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/util"
"github.com/sirupsen/logrus"
)
//go:embed static/*
var staticFiles embed.FS
//go:embed chooser.tmpl
var chooserTemplateFile string
// To add support for an OP to the the WebChooser:
// 1. Add the OP to IssuerToName func
// 2. Add the OP to the html template file: `chooser.tmpl`
// 3. Add the OP to the data which is supplied to `chooserTemplate.Execute(w, data)`
//
// Note that the web chooser can only support BrowserOpenIdProvider
// TODO: This should be an enum that can also autogenerate what gets passed to the template
type WebChooser struct {
OpList []providers.BrowserOpenIdProvider
opSelected providers.BrowserOpenIdProvider
OpenBrowser bool
useMockServer bool
mockServer *httptest.Server
server *http.Server
}
func NewWebChooser(opList []providers.BrowserOpenIdProvider, openBrowser bool) *WebChooser {
return &WebChooser{
OpList: opList,
OpenBrowser: openBrowser,
useMockServer: false,
}
}
func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, error) {
if wc.opSelected != nil {
return nil, fmt.Errorf("provider has already been chosen")
}
providerMap := map[string]providers.BrowserOpenIdProvider{}
for _, provider := range wc.OpList {
if providerName, err := IssuerToName(provider.Issuer()); err != nil {
return nil, err
} else {
if _, ok := providerMap[providerName]; ok {
return nil, fmt.Errorf("provider in web chooser found with duplicate issuer: %s", provider.Issuer())
}
providerMap[providerName] = provider
}
}
opCh := make(chan providers.BrowserOpenIdProvider, 1)
errCh := make(chan error, 1)
mux := http.NewServeMux()
staticContent, err := fs.Sub(staticFiles, "static")
if err != nil {
return nil, err
}
chooserTemplate, err := template.New("chooser-page").Parse(chooserTemplateFile)
if err != nil {
return nil, err
}
mux.HandleFunc("/chooser", func(w http.ResponseWriter, r *http.Request) {
type Provider struct {
Name string
Button string
}
data := struct {
Providers []Provider
}{}
sortedProviderNames := make([]string, 0)
for providerName := range providerMap {
sortedProviderNames = append(sortedProviderNames, providerName)
}
// Sort the provider names
sort.Strings(sortedProviderNames)
for _, providerName := range sortedProviderNames {
if providerName == "google" {
data.Providers = append(data.Providers, Provider{
Name: providerName,
Button: "google-light.svg",
})
continue
}
if providerName == "azure" {
data.Providers = append(data.Providers, Provider{
Name: providerName,
Button: "azure-dark.svg",
})
continue
}
if providerName == "gitlab" {
data.Providers = append(data.Providers, Provider{
Name: providerName,
Button: "gitlab-light.svg",
})
continue
}
if providerName == "hello" {
data.Providers = append(data.Providers, Provider{
Name: providerName,
Button: "hello-dark.png",
})
continue
}
data.Providers = append(data.Providers, Provider{
Name: providerName,
Button: "",
})
}
w.Header().Set("Content-Type", "text/html")
if err := chooserTemplate.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent))))
mux.HandleFunc("/select/", func(w http.ResponseWriter, r *http.Request) {
// Once we redirect to the OP localhost webserver, we can shutdown the web chooser localhost server
shutdownServer := func() {
go func() { // Put this in a go func so that it will not block the redirect
if wc.server != nil {
if err := wc.server.Shutdown(ctx); err != nil {
logrus.Errorf("Failed to shutdown http server: %v", err)
}
}
}()
}
defer shutdownServer()
opName := r.URL.Query().Get("op")
if opName == "" {
errorString := "missing op parameter"
http.Error(w, errorString, http.StatusBadRequest)
errCh <- errors.New(errorString)
return
}
if op, ok := providerMap[opName]; !ok {
errorString := fmt.Sprintf("unknown OpenID Provider: %s", opName)
http.Error(w, errorString, http.StatusBadRequest)
errCh <- errors.New(errorString)
return
} else {
opCh <- op
redirectUriCh := make(chan string, 1)
op.ReuseBrowserWindowHook(redirectUriCh)
redirectUri := <-redirectUriCh
http.Redirect(w, r, redirectUri, http.StatusFound)
}
})
if wc.useMockServer {
wc.mockServer = httptest.NewUnstartedServer(mux)
wc.mockServer.Start()
} else {
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
return nil, fmt.Errorf("failed to bind to an available port: %w", err)
}
wc.server = &http.Server{Handler: mux}
go func() {
err = wc.server.Serve(listener)
if err != nil && err != http.ErrServerClosed {
logrus.Error(err)
}
}()
var loginURI string
if listener.Addr().(*net.TCPAddr).IP.String() == "127.0.0.1" {
// For consistency in output messages in our code base we use localhost rather than 127.0.0.1
port := listener.Addr().(*net.TCPAddr).Port
loginURI = fmt.Sprintf("http://localhost:%d/chooser", port)
} else {
loginURI = fmt.Sprintf("http://%s/chooser", listener.Addr().String())
}
if wc.OpenBrowser {
logrus.Infof("Opening browser to %s", loginURI)
if err := util.OpenUrl(loginURI); err != nil {
logrus.Errorf("Failed to open url: %v", err)
}
} else {
// If wc.OpenBrowser is false, tell the user what URL to open.
// This is useful when a user wants to use a different browser than the default one.
logrus.Infof("Open your browser to: %s ", loginURI)
}
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case wc.opSelected = <-opCh:
return wc.opSelected, nil
}
}
func IssuerToName(issuer string) (string, error) {
switch {
case strings.HasPrefix(issuer, "https://accounts.google.com"):
return "google", nil
case strings.HasPrefix(issuer, "https://login.microsoftonline.com"):
return "azure", nil
case strings.HasPrefix(issuer, "https://gitlab.com"):
return "gitlab", nil
case strings.HasPrefix(issuer, "https://issuer.hello.coop"):
return "hello", nil
default:
if strings.HasPrefix(issuer, "https://") {
// Returns issuer without the "https://" prefix and without any path remaining on the url
// e.g. https://accounts.google.com/fdsfa/fdsafsad -> accounts.google.com
return strings.Split(strings.TrimPrefix(issuer, "https://"), "/")[0], nil
}
return "", fmt.Errorf("invalid OpenID Provider issuer: %s", issuer)
}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package client
import (
"context"
"crypto"
"fmt"
"net/http"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/util"
"github.com/openpubkey/openpubkey/verifier"
)
type OpenIdProvider = providers.OpenIdProvider
type BrowserOpenIdProvider = providers.BrowserOpenIdProvider
type PKTokenVerifier interface {
VerifyPKToken(ctx context.Context, pkt *pktoken.PKToken, extraChecks ...verifier.Check) error
}
type OpkClient struct {
Op OpenIdProvider
cosP *CosignerProvider
signer crypto.Signer
alg jwa.KeyAlgorithm
pkToken *pktoken.PKToken
refreshToken []byte
accessToken []byte
}
// ClientOpts contains options for constructing an OpkClient
type ClientOpts func(o *OpkClient)
// WithSigner allows the caller to inject their own signer and algorithm.
// Use this option if to generate to bring your own user key pair. If this
// option is not set the OpkClient constructor will automatically generate
// a signer, i.e., key pair.
// Example use:
//
// signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// WithSigner(signer, jwa.ES256)
func WithSigner(signer crypto.Signer, alg jwa.KeyAlgorithm) ClientOpts {
return func(o *OpkClient) {
o.signer = signer
o.alg = alg
}
}
// WithCosignerProvider specifies what cosigner provider should be used to
// cosign the PK Token. If this is not specified then the cosigning setup
// is skipped.
func WithCosignerProvider(cosP *CosignerProvider) ClientOpts {
return func(o *OpkClient) {
o.cosP = cosP
}
}
// New returns a new client.OpkClient. The op argument should be the
// OpenID Provider you want to authenticate against.
func New(op OpenIdProvider, opts ...ClientOpts) (*OpkClient, error) {
client := &OpkClient{
Op: op,
signer: nil,
alg: nil,
}
for _, applyOpt := range opts {
applyOpt(client)
}
if client.alg == nil && client.signer != nil {
return nil, fmt.Errorf("signer specified but alg is nil, must specify alg of signer")
}
if client.signer == nil {
// Generate signer for specified alg. If no alg specified, defaults to ES256
if client.alg == nil {
client.alg = jwa.ES256
}
signer, err := util.GenKeyPair(client.alg)
if err != nil {
return nil, fmt.Errorf("failed to create key pair for client: %w ", err)
}
client.signer = signer
}
return client, nil
}
type AuthOptsStruct struct {
extraClaims map[string]any
}
type AuthOpts func(a *AuthOptsStruct)
// WithExtraClaim specifies additional values to be included in the
// CIC. These claims will be include in the CIC protected header and
// will be hashed into the commitment claim in the ID Token. The
// commitment claim is typically the nonce or aud claim in the ID Token.
// Example use:
//
// WithExtraClaim("claimKey", "claimValue")
func WithExtraClaim(k string, v string) AuthOpts {
return func(a *AuthOptsStruct) {
if a.extraClaims == nil {
a.extraClaims = map[string]any{}
}
a.extraClaims[k] = v
}
}
// Auth returns a PK Token by running the OpenPubkey protocol. It will first
// authenticate to the configured OpenID Provider (OP) and receive an ID Token.
// Using this ID Token it will generate a PK Token. If a Cosigner has been
// configured it will also attempt to get the PK Token cosigned.
func (o *OpkClient) Auth(ctx context.Context, opts ...AuthOpts) (*pktoken.PKToken, error) {
authOpts := &AuthOptsStruct{
extraClaims: map[string]any{},
}
for _, applyOpt := range opts {
applyOpt(authOpts)
}
// If no Cosigner is set then do standard OIDC authentication
if o.cosP == nil {
pkt, err := o.oidcAuth(ctx, o.signer, o.alg, authOpts.extraClaims)
if err != nil {
return nil, err
}
o.pkToken = pkt
return o.pkToken.DeepCopy()
}
// If a Cosigner is set then check that will support doing Cosigner auth
if browserOp, ok := o.Op.(BrowserOpenIdProvider); !ok {
return nil, fmt.Errorf("OP supplied does not have support for MFA Cosigner")
} else {
redirCh := make(chan string, 1)
browserOp.HookHTTPSession(func(w http.ResponseWriter, r *http.Request) {
redirectUri := <-redirCh
http.Redirect(w, r, redirectUri, http.StatusFound)
})
pkt, err := o.oidcAuth(ctx, o.signer, o.alg, authOpts.extraClaims)
if err != nil {
return nil, err
}
pktCos, err := o.cosP.RequestToken(ctx, o.signer, pkt, redirCh)
if err != nil {
return nil, err
}
o.pkToken = pktCos
return o.pkToken.DeepCopy()
}
}
// oidcAuth performs the OpenIdConnect part of the protocol.
// Auth is the exposed function that should be called.
func (o *OpkClient) oidcAuth(
ctx context.Context,
signer crypto.Signer,
alg jwa.KeyAlgorithm,
extraClaims map[string]any,
) (*pktoken.PKToken, error) {
// keep track of any additional verifierChecks for the verifier
verifierChecks := []verifier.Check{}
// Use our signing key to generate a JWK key and set the "alg" header
jwkKey, err := jwk.PublicKeyOf(signer.Public())
if err != nil {
return nil, err
}
err = jwkKey.Set(jwk.AlgorithmKey, alg)
if err != nil {
return nil, err
}
// Use provided public key to generate client instance claims
cic, err := clientinstance.NewClaims(jwkKey, extraClaims)
if err != nil {
return nil, fmt.Errorf("failed to instantiate client instance claims: %w", err)
}
tokens, err := o.Op.RequestTokens(ctx, cic)
if err != nil {
return nil, fmt.Errorf("error requesting OIDC tokens from OpenID Provider: %w", err)
}
idToken := tokens.IDToken
o.refreshToken = tokens.RefreshToken
o.accessToken = tokens.AccessToken
// Sign over the payload from the ID token and client instance claims
cicToken, err := cic.Sign(signer, alg, idToken)
if err != nil {
return nil, fmt.Errorf("error creating cic token: %w", err)
}
// Combine our ID token and signature over the cic to create our PK Token
pkt, err := pktoken.New(idToken, cicToken)
if err != nil {
return nil, fmt.Errorf("error creating PK Token: %w", err)
}
pktVerifier, err := verifier.New(o.Op)
if err != nil {
return nil, err
}
if err := pktVerifier.VerifyPKToken(ctx, pkt, verifierChecks...); err != nil {
return nil, fmt.Errorf("error verifying PK Token: %w", err)
}
return pkt, nil
}
// Refresh uses a Refresh Token to request a fresh ID Token and Access Token from an OpenID Provider.
// It provides a way to refresh the Access and ID Tokens for an OpenID Provider that supports refresh requests,
// allowing the client to continue making authenticated requests without requiring the user to re-authenticate.
func (o *OpkClient) Refresh(ctx context.Context) (*pktoken.PKToken, error) {
if tokensOp, ok := o.Op.(providers.RefreshableOpenIdProvider); ok {
if o.refreshToken == nil {
return nil, fmt.Errorf("no refresh token set")
}
if o.pkToken == nil {
return nil, fmt.Errorf("no PK Token set, run Auth() to create a PK Token first")
}
tokens, err := tokensOp.RefreshTokens(ctx, o.refreshToken)
if err != nil {
return nil, fmt.Errorf("error requesting ID token: %w", err)
}
o.pkToken.FreshIDToken = tokens.IDToken
o.refreshToken = tokens.RefreshToken
o.accessToken = tokens.AccessToken
return o.pkToken.DeepCopy()
}
return nil, fmt.Errorf("OP (issuer=%s) does not support OIDC refresh requests", o.Op.Issuer())
}
// GetOp returns the OpenID Provider the OpkClient has been configured to use
func (o *OpkClient) GetOp() OpenIdProvider {
return o.Op
}
// GetCosP returns the MFA Cosigner Provider the OpkClient has been
// configured to use
func (o *OpkClient) GetCosP() *CosignerProvider {
return o.cosP
}
// GetSigner returns the client's key pair (Public Key, Signing Key)
func (o *OpkClient) GetSigner() crypto.Signer {
return o.signer
}
// GetAlg returns the algorithm of the client's key pair
// (Public Key, Signing Key)
func (o *OpkClient) GetAlg() jwa.KeyAlgorithm {
return o.alg
}
func (o *OpkClient) GetAccessToken() []byte {
return o.accessToken
}
func (o *OpkClient) SetPKToken(pkt *pktoken.PKToken) {
o.pkToken = pkt
}
// GetPKToken returns a deep copy of client's current PK Token
func (o *OpkClient) GetPKToken() (*pktoken.PKToken, error) {
return o.pkToken.DeepCopy()
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package client
import (
"context"
"crypto"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/cosigner/msgs"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/util"
"github.com/sirupsen/logrus"
)
type CosignerProvider struct {
Issuer string
CallbackPath string
}
func (c *CosignerProvider) RequestToken(ctx context.Context, signer crypto.Signer, pkt *pktoken.PKToken, redirCh chan string) (*pktoken.PKToken, error) {
// Find an unused port
listener, err := net.Listen("tcp", ":0")
if err != nil {
return nil, fmt.Errorf("failed to bind to an available port: %w", err)
}
port := listener.Addr().(*net.TCPAddr).Port
host := fmt.Sprintf("localhost:%d", port)
redirectURI := fmt.Sprintf("http://%s%s", host, c.CallbackPath)
// We set the buffer size to one and then in the CallbackPath handler we
// ensure only said either 0 or 1 message to a channel before returning.
// This prevents blocking inside CallbackPath handler when it attempts to
// write to the channel. If the callbackPath handler is called twice by the
// user's web browser the second call will block on a channel until the cxt
// is marked as done.
sigCh := make(chan []byte, 1)
errCh := make(chan error, 1)
// This is where we get the authcode from the Cosigner
mux := http.NewServeMux()
mux.Handle(c.CallbackPath,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cosSig, err := func() ([]byte, error) {
// Get authcode from Cosigner via Cosigner redirecting user's browser window
params := r.URL.Query()
if _, ok := params["authcode"]; !ok {
return nil, fmt.Errorf("cosigner did not return an authcode in the URI")
}
authcode := params["authcode"][0] // This is the authcode issued by the cosigner not the OP
// Sign authcode from cosigner under PK Token and send signed authcode to Cosigner
sig2, err := pkt.NewSignedMessage([]byte(authcode), signer)
if err != nil {
return nil, fmt.Errorf("cosigner client hit error when building authcode URI: %w", err)
}
authcodeSigUri, err := c.authcodeURI(sig2)
if err != nil {
return nil, fmt.Errorf("cosigner client hit error when building authcode URI: %w", err)
}
res, err := http.Get(authcodeSigUri)
if err != nil {
return nil, fmt.Errorf("error requesting MFA cosigner signature: %w", err)
}
// Receive response from Cosigner that has cosigner signature on PK Token
resBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("error reading MFA cosigner signature response: %w", err)
}
cosSig, err := util.Base64DecodeForJWT(resBody)
if err != nil {
return nil, fmt.Errorf("error reading MFA cosigner signature response: %w", err)
}
// Success
return cosSig, nil
}()
if err != nil {
// Write the error message to the user
if _, err := w.Write([]byte(err.Error())); err != nil {
logrus.Error(err)
}
select {
case errCh <- err:
case <-ctx.Done():
return
}
} else {
if _, err := w.Write([]byte("You may now close this window")); err != nil {
logrus.Error(err)
}
select {
case sigCh <- cosSig:
case <-ctx.Done():
return
}
}
}),
)
server := &http.Server{
Addr: host,
Handler: mux,
}
logrus.Infof("listening on http://%s/", host)
logrus.Info("press ctrl+c to stop")
go func() {
err := server.Serve(listener)
if err != nil && err != http.ErrServerClosed {
logrus.Error(err)
}
}()
defer func() {
if err := server.Shutdown(ctx); err != nil {
logrus.Error(err)
}
}()
pktJson, err := json.Marshal(pkt)
if err != nil {
return nil, fmt.Errorf("cosigner client hit error serializing PK Token: %w", err)
}
initAuthMsgJson, nonce, err := c.CreateInitAuthSig(redirectURI)
if err != nil {
return nil, fmt.Errorf("hit error creating init auth signed message: %w", err)
}
sig1, err := pkt.NewSignedMessage(initAuthMsgJson, signer)
if err != nil {
return nil, fmt.Errorf("cosigner client hit error init auth signed message: %w", err)
}
redirUri, err := c.initAuthURI(pktJson, sig1)
if err != nil {
return nil, fmt.Errorf("cosigner client hit error when building init auth URI: %w", err)
}
select {
// Trigger redirect of user's browser window to a URI controlled by the Cosigner sending the PK Token in the URI
case redirCh <- redirUri:
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case cosSig := <-sigCh: // Received cosigner signature
// To be safe we perform these checks before adding the cosSig to the pktoken
if err := c.ValidateCos(cosSig, nonce, redirectURI); err != nil {
return nil, err
}
if err := pkt.AddSignature(cosSig, pktoken.COS); err != nil {
return nil, fmt.Errorf("error in adding cosigner signature to PK Token: %w", err)
}
return pkt, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *CosignerProvider) initAuthURI(pktJson []byte, sig1 []byte) (string, error) {
pktB63 := util.Base64EncodeForJWT(pktJson)
if uri, err := url.Parse(c.Issuer); err != nil {
return "", err
} else {
uri := uri.JoinPath("mfa-auth-init")
v := uri.Query()
v.Add("pkt", string(pktB63))
v.Add("sig1", string(sig1))
uri.RawQuery = v.Encode()
// URI Should be: https://<issuer>/mfa-auth-init?pkt=<pktJsonB64>&sig1=<sig1>
return uri.String(), nil
}
}
func (c *CosignerProvider) authcodeURI(sig2 []byte) (string, error) {
if uri, err := url.Parse(c.Issuer); err != nil {
return "", err
} else {
uri := uri.JoinPath("sign")
v := uri.Query()
v.Add("sig2", string(sig2))
uri.RawQuery = v.Encode()
// URI Should be: https://<issuer>/sign?&sig2=<sig2>
return uri.String(), nil
}
}
func (c *CosignerProvider) ValidateCos(cosSig []byte, expectedNonce string, expectedRedirectURI string) error {
cosSigParsed, err := jws.Parse(cosSig)
if err != nil {
return fmt.Errorf("failed to parse Cosigner signature: %w", err)
}
if len(cosSigParsed.Signatures()) != 1 {
return fmt.Errorf("the Cosigner signature does not have the correct number of signatures: %w", err)
}
ph := cosSigParsed.Signatures()[0].ProtectedHeaders()
nonceRet, ok := ph.Get("nonce")
if !ok {
return fmt.Errorf("nonce not set in Cosigner signature protected header")
}
if expectedNonce != nonceRet {
return fmt.Errorf("incorrect nonce set in Cosigner signature")
}
ruriRet, ok := ph.Get("ruri")
if !ok {
return fmt.Errorf("ruri (redirect URI) not set in Cosigner signature protected header")
}
if expectedRedirectURI != ruriRet {
return fmt.Errorf("unexpected ruri (redirect URI) set in Cosigner signature, got %s expected %s", ruriRet, expectedRedirectURI)
}
issRet, ok := ph.Get("iss")
if !ok {
return fmt.Errorf("iss (Cosigner Issuer) not set in Cosigner signature protected header")
}
if c.Issuer != issRet {
return fmt.Errorf("unexpected iss (Cosigner Issuer) set in Cosigner signature, expected %s", c.Issuer)
}
return nil
}
// CreateInitAuthSig generates a random nonce, validates the redirectURI,
// creates an InitMFAAuth message, marshals it to JSON,
// and returns the JSON message along with the nonce.
func (c *CosignerProvider) CreateInitAuthSig(redirectURI string) ([]byte, string, error) {
bits := 256
rBytes := make([]byte, bits/8)
if _, err := rand.Read(rBytes); err != nil {
return nil, "", err
}
if !strings.HasSuffix(redirectURI, c.CallbackPath) {
return nil, "", fmt.Errorf("redirectURI (%s) does not end in expected callbackPath (%s)", redirectURI, c.CallbackPath)
}
nonce := hex.EncodeToString(rBytes)
msg := msgs.InitMFAAuth{
Issuer: c.Issuer,
RedirectUri: redirectURI,
TimeSigned: time.Now().Unix(),
Nonce: nonce,
}
msgJson, err := json.Marshal(msg)
if err != nil {
return nil, "", err
}
return msgJson, nonce, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package cosigner
import (
"crypto"
"encoding/json"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/cosigner/msgs"
"github.com/openpubkey/openpubkey/pktoken"
)
type AuthCosigner struct {
Cosigner
Issuer string
KeyID string
AuthStateStore AuthStateStore
}
func New(signer crypto.Signer, alg jwa.SignatureAlgorithm, issuer, keyID string, store AuthStateStore) (*AuthCosigner, error) {
return &AuthCosigner{
Cosigner: Cosigner{
Alg: alg,
Signer: signer},
Issuer: issuer,
KeyID: keyID,
AuthStateStore: store,
}, nil
}
func (c *AuthCosigner) InitAuth(pkt *pktoken.PKToken, sig []byte) (string, error) {
msg, err := pkt.VerifySignedMessage(sig)
if err != nil {
return "", fmt.Errorf("failed to verify sig: %w", err)
}
var initMFAAuth *msgs.InitMFAAuth
if err := json.Unmarshal(msg, &initMFAAuth); err != nil {
return "", fmt.Errorf("failed to parse InitMFAAuth message: %w", err)
} else if initMFAAuth.Issuer != c.Issuer {
return "", fmt.Errorf("signed message is for wrong cosigner, got issuer=(%s), expected issuer=(%s)", initMFAAuth.Issuer, c.Issuer)
} else if time.Since(time.Unix(initMFAAuth.TimeSigned, 0)).Minutes() > 2 {
return "", fmt.Errorf("timestamp (%d) in InitMFAAuth message too old, current time is (%d)", initMFAAuth.TimeSigned, time.Now().Unix())
} else if time.Until(time.Unix(initMFAAuth.TimeSigned, 0)).Minutes() > 2 {
return "", fmt.Errorf("timestamp (%d) in InitMFAAuth message too far in the future, current time is (%d)", initMFAAuth.TimeSigned, time.Now().Unix())
} else if authID, err := c.AuthStateStore.CreateNewAuthSession(pkt, initMFAAuth.RedirectUri, initMFAAuth.Nonce); err != nil {
return "", err
} else {
return authID, nil
}
}
func (c *AuthCosigner) NewAuthcode(authID string) (string, error) {
return c.AuthStateStore.CreateAuthcode(authID)
}
func (c *AuthCosigner) RedeemAuthcode(sig []byte) ([]byte, error) {
msg, err := jws.Parse(sig)
if err != nil {
return nil, fmt.Errorf("failed to parse sig: %s", err)
}
authcode := string(msg.Payload())
// We need redemption to be inside of our mutexes to ensure the same authcode can't be redeemed if requested at the same moment
if authState, authID, err := c.AuthStateStore.RedeemAuthcode(authcode); err != nil {
return nil, err
} else {
pkt := authState.Pkt
_, err := pkt.VerifySignedMessage(sig) // We check this after redeeming the authcode, so can't try the same correct authcode twice
if err != nil {
return nil, fmt.Errorf("error verifying sig: %w", err)
}
return c.IssueSignature(pkt, authState, authID)
}
}
func (c *AuthCosigner) IssueSignature(pkt *pktoken.PKToken, authState AuthState, authID string) ([]byte, error) {
protected := pktoken.CosignerClaims{
Issuer: c.Issuer,
KeyID: c.KeyID,
Algorithm: c.Alg.String(),
AuthID: authID,
AuthTime: time.Now().Unix(),
IssuedAt: time.Now().Unix(),
Expiration: time.Now().Add(time.Hour).Unix(),
RedirectURI: authState.RedirectURI,
Nonce: authState.Nonce,
Typ: string(pktoken.COS),
}
// Now that our mfa has authenticated the user, we can add our signature
return c.Cosign(pkt, protected)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package cosigner
import (
"crypto"
"crypto/hmac"
"encoding/binary"
"encoding/hex"
"fmt"
"sync/atomic"
)
type AuthIDIssuer struct {
authIdIter atomic.Uint64
hmacKey []byte
}
func NewAuthIDIssuer(hmacKey []byte) *AuthIDIssuer {
return &AuthIDIssuer{
authIdIter: atomic.Uint64{},
hmacKey: hmacKey,
}
}
func (i *AuthIDIssuer) CreateAuthID(timeNow uint64) (string, error) {
authIdInt := i.authIdIter.Add(1)
iterAndTime := []byte{}
iterAndTime = binary.LittleEndian.AppendUint64(iterAndTime, uint64(authIdInt))
iterAndTime = binary.LittleEndian.AppendUint64(iterAndTime, timeNow)
mac := hmac.New(crypto.SHA3_256.New, i.hmacKey)
if n, err := mac.Write(iterAndTime); err != nil {
return "", err
} else if n != 16 {
return "", fmt.Errorf("unexpected number of bytes read by HMAC, expected 16, got %d", n)
} else {
return hex.EncodeToString(mac.Sum(nil)), nil
}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package cosigner
import (
"encoding/json"
"fmt"
"strings"
"github.com/openpubkey/openpubkey/pktoken"
)
type AuthState struct {
Pkt *pktoken.PKToken
Issuer string // ID Token issuer (iss)
Aud string // ID Token audience (aud)
Sub string // ID Token subject ID (sub)
Username string // ID Token email or username
DisplayName string // ID Token display name (or username if none given)
RedirectURI string // Redirect URI
Nonce string // Nonce supplied by user
AuthcodeIssued bool // Has an authcode been issued for this auth session
AuthcodeRedeemed bool // Was the pkt cosigned
}
func NewAuthState(pkt *pktoken.PKToken, ruri string, nonce string) (*AuthState, error) {
var claims struct {
Issuer string `json:"iss"`
Aud any `json:"aud"`
Sub string `json:"sub"`
Email string `json:"email"`
}
if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal PK Token: %w", err)
}
// An audience can be a string or an array of strings.
//
// RFC-7519 JSON Web Token (JWT) says:
// "In the general case, the "aud" value is an array of case-
// sensitive strings, each containing a StringOrURI value. In the
// special case when the JWT has one audience, the "aud" value MAY be a
// single case-sensitive string containing a StringOrURI value."
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
var audience string
switch t := claims.Aud.(type) {
case string:
audience = t
case []any:
audList := []string{}
for _, v := range t {
audList = append(audList, v.(string))
}
audience = strings.Join(audList, ",")
default:
return nil, fmt.Errorf("failed to deserialize aud (audience) claim in ID Token: %T", t)
}
return &AuthState{
Pkt: pkt,
Issuer: claims.Issuer,
Aud: audience,
Sub: claims.Sub,
Username: claims.Email,
DisplayName: strings.Split(claims.Email, "@")[0], //TODO: Use full name from ID Token
RedirectURI: ruri,
Nonce: nonce,
AuthcodeRedeemed: false,
AuthcodeIssued: false,
}, nil
}
type UserKey struct {
Issuer string // ID Token issuer (iss)
Aud string // ID Token audience (aud)
Sub string // ID Token subject ID (sub)
}
func (as AuthState) UserKey() UserKey {
return UserKey{Issuer: as.Issuer, Aud: as.Aud, Sub: as.Sub}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package cosigner
import (
"crypto"
"encoding/json"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/openpubkey/openpubkey/pktoken"
)
type Cosigner struct {
Alg jwa.KeyAlgorithm
Signer crypto.Signer
}
func (c *Cosigner) Cosign(pkt *pktoken.PKToken, cosClaims pktoken.CosignerClaims) ([]byte, error) {
jsonBytes, err := json.Marshal(cosClaims)
if err != nil {
return nil, err
}
var headers map[string]any
if err := json.Unmarshal(jsonBytes, &headers); err != nil {
return nil, err
}
return pkt.SignToken(c.Signer, c.Alg, headers)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package cosigner
import (
"context"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/discover"
"github.com/openpubkey/openpubkey/pktoken"
)
type DefaultCosignerVerifier struct {
issuer string
options CosignerVerifierOpts
}
type CosignerVerifierOpts struct {
// Strict specifies whether or not a pk token MUST contain a signature by this cosigner.
// Defaults to true.
Strict *bool
// Allows users to set custom function for discovering public key of Cosigner
DiscoverPublicKey *discover.PublicKeyFinder
}
func NewCosignerVerifier(issuer string, options CosignerVerifierOpts) *DefaultCosignerVerifier {
v := &DefaultCosignerVerifier{
issuer: issuer,
options: options,
}
// If no custom DiscoverPublicKey function is set, set default
if v.options.DiscoverPublicKey == nil {
v.options.DiscoverPublicKey = discover.DefaultPubkeyFinder()
}
// If strict is not set, then default it to true
if v.options.Strict == nil {
v.options.Strict = new(bool)
*v.options.Strict = true
}
return v
}
func (v *DefaultCosignerVerifier) Issuer() string {
return v.issuer
}
func (v *DefaultCosignerVerifier) Strict() bool {
return *v.options.Strict
}
func (v *DefaultCosignerVerifier) VerifyCosigner(ctx context.Context, pkt *pktoken.PKToken) error {
if pkt.Cos == nil {
return fmt.Errorf("no cosigner signature")
}
// Parse our header
header, err := pkt.ParseCosignerClaims()
if err != nil {
return err
}
if v.issuer != header.Issuer {
return fmt.Errorf("cosigner issuer (%s) doesn't match expected issuer (%s)", header.Issuer, v.issuer)
}
keyRecord, err := v.options.DiscoverPublicKey.ByKeyID(ctx, v.issuer, header.KeyID)
if err != nil {
return err
}
key := keyRecord.PublicKey
alg := keyRecord.Alg
// Check if it's expired
if time.Now().After(time.Unix(header.Expiration, 0)) {
return fmt.Errorf("cosigner signature expired")
}
if header.Algorithm != alg {
return fmt.Errorf("key (kid=%s) has alg (%s) which doesn't match alg (%s) in protected", header.KeyID, alg, header.Algorithm)
}
jwsPubkey := jws.WithKey(jwa.KeyAlgorithmFrom(alg), key)
_, err = jws.Verify(pkt.CosToken, jwsPubkey)
return err
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/openpubkey/openpubkey/cosigner"
"github.com/openpubkey/openpubkey/pktoken"
)
// This is intended for testing purposes. The locking strategy used is not
// particularly efficient. Anyone building a Cosigner should use the interface
// above to replace this in-memory store with a database.
type AuthStateInMemoryStore struct {
AuthIDIssuer *cosigner.AuthIDIssuer
AuthStateMap map[string]*cosigner.AuthState
AuthCodeMap map[string]string
AuthStateMapLock sync.RWMutex
AuthcodeMapLock sync.RWMutex
}
func NewAuthStateInMemoryStore(hmacKey []byte) *AuthStateInMemoryStore {
return &AuthStateInMemoryStore{
AuthStateMap: make(map[string]*cosigner.AuthState),
AuthCodeMap: make(map[string]string),
AuthcodeMapLock: sync.RWMutex{},
AuthStateMapLock: sync.RWMutex{},
AuthIDIssuer: cosigner.NewAuthIDIssuer(hmacKey),
}
}
// Writes to the AuthState are not concurrency safe, do not write
func (s *AuthStateInMemoryStore) LookupAuthState(authID string) (*cosigner.AuthState, bool) {
s.AuthStateMapLock.RLock()
as, ok := s.AuthStateMap[authID]
s.AuthStateMapLock.RUnlock()
return as, ok // Pass by value to prevent writes to the original
}
func (s *AuthStateInMemoryStore) UpdateAuthState(authID string, authState cosigner.AuthState) error {
s.AuthStateMapLock.Lock()
defer s.AuthStateMapLock.Unlock()
if _, ok := s.AuthStateMap[authID]; !ok {
return fmt.Errorf("failed to upload auth session because authID specified matches no session")
} else {
s.AuthStateMap[authID] = &authState
return nil
}
}
func (s *AuthStateInMemoryStore) CreateNewAuthSession(pkt *pktoken.PKToken, ruri string, nonce string) (string, error) {
var claims struct {
Issuer string `json:"iss"`
Aud any `json:"aud"`
Sub string `json:"sub"`
Email string `json:"email"`
}
if err := json.Unmarshal(pkt.Payload, &claims); err != nil {
return "", fmt.Errorf("failed to unmarshal PK Token: %w", err)
}
// An audience can be a string or an array of strings.
//
// RFC-7519 JSON Web Token (JWT) says:
// "In the general case, the "aud" value is an array of case-
// sensitive strings, each containing a StringOrURI value. In the
// special case when the JWT has one audience, the "aud" value MAY be a
// single case-sensitive string containing a StringOrURI value."
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
var audience string
switch t := claims.Aud.(type) {
case string:
audience = t
case []any:
audList := []string{}
for _, v := range t {
audList = append(audList, v.(string))
}
audience = strings.Join(audList, ",")
default:
return "", fmt.Errorf("failed to deserialize aud (audience) claim in ID Token: %T", t)
}
authState := &cosigner.AuthState{
Pkt: pkt,
Issuer: claims.Issuer,
Aud: audience,
Sub: claims.Sub,
Username: claims.Email,
DisplayName: strings.Split(claims.Email, "@")[0], //TODO: Use full name from ID Token
RedirectURI: ruri,
Nonce: nonce,
AuthcodeRedeemed: false,
AuthcodeIssued: false,
}
if authID, err := s.AuthIDIssuer.CreateAuthID(uint64(time.Now().Unix())); err != nil {
return "", err
} else {
s.AuthStateMapLock.Lock()
if _, ok := s.AuthStateMap[authID]; ok {
return "", fmt.Errorf("specified authID is already in use")
}
s.AuthStateMap[authID] = authState
s.AuthStateMapLock.Unlock()
return authID, nil
}
}
func (s *AuthStateInMemoryStore) CreateAuthcode(authID string) (string, error) {
authCodeBytes := make([]byte, 32)
if _, err := rand.Read(authCodeBytes); err != nil {
return "", err
}
authcode := hex.EncodeToString(authCodeBytes)
// We take a full read write lock here to ensure we don't issue an authcode twice for the same session
s.AuthStateMapLock.Lock()
defer s.AuthStateMapLock.Unlock()
if authState, ok := s.AuthStateMap[authID]; !ok {
return "", fmt.Errorf("no such authID")
} else if authState.AuthcodeIssued {
return "", fmt.Errorf("authcode already issued for this authID")
} else {
s.AuthcodeMapLock.Lock()
defer s.AuthcodeMapLock.Unlock()
if _, ok := s.AuthCodeMap[authcode]; ok {
return "", fmt.Errorf("authcode collision implies randomness failure in RNG")
}
authState.AuthcodeIssued = true
s.AuthCodeMap[authcode] = authID
return authcode, nil
}
}
func (s *AuthStateInMemoryStore) RedeemAuthcode(authcode string) (cosigner.AuthState, string, error) {
s.AuthcodeMapLock.RLock()
authID, authcodeFound := s.AuthCodeMap[authcode]
s.AuthcodeMapLock.RUnlock()
if !authcodeFound {
return cosigner.AuthState{}, "", fmt.Errorf("invalid authcode")
} else {
s.AuthStateMapLock.Lock()
defer s.AuthStateMapLock.Unlock()
authState := s.AuthStateMap[authID]
if !authState.AuthcodeIssued {
// This should never happen
return cosigner.AuthState{}, "", fmt.Errorf("no authcode issued for this authID")
}
if authState.AuthcodeRedeemed {
return cosigner.AuthState{}, "", fmt.Errorf("authcode has already been redeemed")
}
authState.AuthcodeRedeemed = true
return *authState, authID, nil
}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package discover
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/gq"
"github.com/openpubkey/openpubkey/util"
oidcclient "github.com/zitadel/oidc/v3/pkg/client"
)
type PublicKeyRecord struct {
PublicKey crypto.PublicKey
Alg string
Issuer string
}
func NewPublicKeyRecord(key jwk.Key, issuer string) (*PublicKeyRecord, error) {
var pubKey interface{}
if key.Algorithm() == jwa.RS256 {
pubKey = new(rsa.PublicKey)
} else if key.Algorithm() == jwa.ES256 {
pubKey = new(ecdsa.PublicKey)
} else if key.Algorithm().String() == "" {
// OPs such as azure (microsoft) do not specify alg in their JWKS. To
// handle this case, assume no alg in JWKS means RSA as OIDC requires
// OPs use RSA.
pubKey = new(rsa.PublicKey)
} else {
return nil, fmt.Errorf("JWK has unsupported alg (%s)", key.Algorithm())
}
err := key.Raw(&pubKey)
if err != nil {
return nil, fmt.Errorf("failed to decode public key: %w", err)
}
var alg string
if key.Algorithm().String() == "" {
alg = jwa.RS256.String()
} else {
alg = key.Algorithm().String()
}
return &PublicKeyRecord{
PublicKey: pubKey,
Alg: alg,
Issuer: issuer,
}, nil
}
func DefaultPubkeyFinder() *PublicKeyFinder {
return &PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
return GetJwksByIssuer(ctx, issuer, nil)
},
}
}
type JwksFetchFunc func(ctx context.Context, issuer string) ([]byte, error)
type PublicKeyFinder struct {
JwksFunc JwksFetchFunc
}
// GetJwksByIssuer fetches the JWKS from the issuer's JWKS endpoint found at the
// issuer's well-known configuration. It doesn't attempt to parse the response
// but instead returns the JSON bytes of the JWKS. If httpClient is nil, then
// http.DefaultClient is used when fetching.
func GetJwksByIssuer(ctx context.Context, issuer string, httpClient *http.Client) ([]byte, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}
discConf, err := oidcclient.Discover(ctx, issuer, httpClient)
if err != nil {
return nil, fmt.Errorf("failed to call OIDC discovery endpoint: %w", err)
}
request, err := http.NewRequestWithContext(ctx, "GET", discConf.JwksURI, nil)
if err != nil {
return nil, err
}
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
resp, err := httpClient.Get(discConf.JwksURI)
if err != nil {
return nil, fmt.Errorf("failed to fetch to JWKS: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("received non-200 from JWKS URI: %s", http.StatusText(response.StatusCode))
}
return io.ReadAll(resp.Body)
}
func (f *PublicKeyFinder) fetchAndParseJwks(ctx context.Context, issuer string) (jwk.Set, error) {
jwksJson, err := f.JwksFunc(ctx, issuer)
if err != nil {
return nil, fmt.Errorf(`failed to fetch JWKS: %w`, err)
}
jwks := jwk.NewSet()
if err := json.Unmarshal(jwksJson, jwks); err != nil {
return nil, fmt.Errorf(`failed to unmarshal JWKS: %w`, err)
}
return jwks, nil
}
// ByToken looks up an OP public key in the JWKS using the KeyID (kid) in the
// protected header from the supplied token.
func (f *PublicKeyFinder) ByToken(ctx context.Context, issuer string, token []byte) (*PublicKeyRecord, error) {
jwt, err := jws.Parse(token)
if err != nil {
return nil, fmt.Errorf("error parsing JWK in JWKS: %w", err)
}
// a JWT is guaranteed to have exactly one signature
headers := jwt.Signatures()[0].ProtectedHeaders()
if headers.Algorithm() == gq.GQ256 {
origHeadersJson, err := util.Base64DecodeForJWT([]byte(headers.KeyID()))
if err != nil {
return nil, fmt.Errorf("error base64 decoding GQ kid: %w", err)
}
// If GQ then replace the GQ headers with the original headers
err = json.Unmarshal(origHeadersJson, &headers)
if err != nil {
return nil, fmt.Errorf("error unmarshalling GQ kid to original headers: %w", err)
}
}
// Use the KeyID (kid) in the headers from the supplied token to look up the public key
return f.ByKeyID(ctx, issuer, headers.KeyID())
}
// ByKeyID looks up an OP public key in the JWKS using the KeyID (kid) supplied.
// If no KeyID (kid) exists in the header and there is only one key in the JWKS,
// that key is returned. This is useful for cases where an OP may not set a KeyID
// (kid) in the JWT header.
//
// The JWT RFC states that it is acceptable to not use a KeyID (kid) if there is
// only one key in the JWKS:
// "The "kid" (key ID) parameter is used to match a specific key. This is used,
// for instance, to choose among a set of keys within a JWK Set
// during key rollover. The structure of the "kid" value is
// unspecified. When "kid" values are used within a JWK Set, different
// keys within the JWK Set SHOULD use distinct "kid" values. (One
// example in which different keys might use the same "kid" value is if
// they have different "kty" (key type) values but are considered to be
// equivalent alternatives by the application using them.) The "kid"
// value is a case-sensitive string. Use of this member is OPTIONAL.
// When used with JWS or JWE, the "kid" value is used to match a JWS or
// JWE "kid" Header Parameter value." - RFC 7517
// https://datatracker.ietf.org/doc/html/rfc7517#section-4.5
func (f *PublicKeyFinder) ByKeyID(ctx context.Context, issuer string, keyID string) (*PublicKeyRecord, error) {
jwks, err := f.fetchAndParseJwks(ctx, issuer)
if err != nil {
return nil, fmt.Errorf(`failed to fetch JWK set: %w`, err)
}
// If keyID is blank and there is only one key in the JWKS, return that key
key, ok := jwks.LookupKeyID(keyID)
if ok {
return NewPublicKeyRecord(key, issuer)
}
return nil, fmt.Errorf("no matching public key found for kid %s", keyID)
}
func (f *PublicKeyFinder) ByJKT(ctx context.Context, issuer string, jkt string) (*PublicKeyRecord, error) {
jwks, err := f.fetchAndParseJwks(ctx, issuer)
if err != nil {
return nil, err
}
it := jwks.Keys(ctx)
for it.Next(ctx) {
key := it.Pair().Value.(jwk.Key)
jktOfKey, err := key.Thumbprint(crypto.SHA256)
if err != nil {
return nil, fmt.Errorf("error computing Thumbprint of key in JWKS: %w", err)
}
jktOfKeyB64 := util.Base64EncodeForJWT(jktOfKey)
if jkt == string(jktOfKeyB64) {
return NewPublicKeyRecord(key, issuer)
}
}
return nil, fmt.Errorf("no matching public key found for jkt %s", jkt)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package discover
import (
"context"
"crypto"
"encoding/json"
"github.com/lestrrat-go/jwx/v2/jwk"
)
func MockGetJwksByIssuer(publicKeys []crypto.PublicKey, keyIDs []string, algs []string) (JwksFetchFunc, error) {
// Create JWKS (JWK Set)
jwks := jwk.NewSet()
for i, publicKey := range publicKeys {
jwkKey, err := jwk.PublicKeyOf(publicKey)
if err != nil {
return nil, err
}
if err := jwkKey.Set(jwk.AlgorithmKey, algs[i]); err != nil {
return nil, err
}
if keyIDs != nil {
if err := jwkKey.Set(jwk.KeyIDKey, keyIDs[i]); err != nil {
return nil, err
}
}
// Put our jwk into a set
if err := jwks.AddKey(jwkKey); err != nil {
return nil, err
}
}
jwksJson, err := json.Marshal(jwks)
if err != nil {
return nil, err
}
return func(ctx context.Context, issuer string) ([]byte, error) {
return jwksJson, nil
}, nil
}
func MockGetJwksByIssuerOneKey(publicKey crypto.PublicKey, keyID string, alg string) (JwksFetchFunc, error) {
// Create JWKS (JWK Set)
jwkKey, err := jwk.PublicKeyOf(publicKey)
if err != nil {
return nil, err
}
if err := jwkKey.Set(jwk.AlgorithmKey, alg); err != nil {
return nil, err
}
if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil {
return nil, err
}
// Put our jwk into a set
jwks := jwk.NewSet()
if err := jwks.AddKey(jwkKey); err != nil {
return nil, err
}
jwksJson, err := json.Marshal(jwks)
if err != nil {
return nil, err
}
return func(ctx context.Context, issuer string) ([]byte, error) {
return jwksJson, nil
}, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package gitlab_example
import (
"context"
"encoding/base64"
"fmt"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/verifier"
)
type Opts struct {
altOp providers.OpenIdProvider
}
func SignWithGitlab(opts ...Opts) error {
var op providers.OpenIdProvider
// If an alternative OP is provided, use that instead of the default.
// Currently only used for testing where a mockOP is provided.
if len(opts) > 0 && opts[0].altOp != nil {
op = opts[0].altOp
} else {
// Creates OpenID Provider (OP) configuration, this will be used to request the ID Token from Gitlab
op = providers.NewGitlabCiOpFromEnvironment("OPENPUBKEY_JWT")
}
// Creates a new OpenPubkey client
opkClient, err := client.New(op)
if err != nil {
return err
}
// Generates a PK Token by authorizing to the OpenID Provider
pkt, err := opkClient.Auth(context.Background())
if err != nil {
return err
}
// Serialize the PK Token to JSON so we can print it. Typically this
// serialization of the PK Token would be sent with the signed message
pktJson, err := pkt.MarshalJSON()
if err != nil {
return err
}
fmt.Println("pkt:", string(pktJson))
pktCom, _ := pkt.Compact()
b64pktCom := base64.StdEncoding.EncodeToString(pktCom)
fmt.Println("pkt compact:", string(b64pktCom))
// Create a verifier to check that the PK Token is well formed
// The OPK client does this as well, but for the purposes of the
// example we show how a relying party might verify a PK Token
verifier, err := verifier.New(op)
if err != nil {
return err
}
// Verify the PK Token. We supply the OP (gitlab) we wish to verify against
err = verifier.VerifyPKToken(context.Background(), pkt)
if err != nil {
return err
}
// Sign a message over the user's public key in the PK Token
msg := []byte("All is discovered - flee at once")
signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner())
if err != nil {
return err
}
fmt.Println("signedMsg:", string(signedMsg))
// Verify the signed message
_, err = pkt.VerifySignedMessage(signedMsg)
if err != nil {
return err
}
fmt.Println("Success!")
return nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"fmt"
"os"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/cosigner"
"github.com/openpubkey/openpubkey/examples/mfa/mfacosigner"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/verifier"
)
func main() {
provider := providers.NewGoogleOp()
cosignerProvider := client.CosignerProvider{
Issuer: "http://localhost:3003",
CallbackPath: "/mfacallback",
}
if len(os.Args) < 2 {
fmt.Printf("Example MFA Cosigner: command choices are: login, mfa")
return
}
command := os.Args[1]
switch command {
case "login":
opk, err := client.New(provider,
client.WithCosignerProvider(&cosignerProvider),
)
if err != nil {
fmt.Println(err)
return
}
pkt, err := opk.Auth(context.TODO())
if err != nil {
fmt.Println(err)
return
}
fmt.Println("New PK token generated")
// Verify our pktoken including the cosigner signature
cosVerifier := cosigner.NewCosignerVerifier(cosignerProvider.Issuer, cosigner.CosignerVerifierOpts{})
verifier, err := verifier.New(provider, verifier.WithCosignerVerifiers(cosVerifier))
if err != nil {
fmt.Println(err)
return
}
if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil {
fmt.Println("Failed to verify PK token:", err)
os.Exit(1)
} else {
fmt.Println("PK token verified successfully!")
}
os.Exit(0)
case "mfa":
rpID := "localhost"
serverUri := "http://localhost:3003"
rpOrigin := "http://localhost:3003"
rpDisplayName := "OpenPubkey"
_, err := mfacosigner.NewMfaCosignerHttpServer(serverUri, rpID, rpOrigin, rpDisplayName)
if err != nil {
fmt.Println("error starting mfa server: ", err)
return
}
default:
fmt.Println("Unrecognized command:", command)
fmt.Printf("Example MFA Cosigner: command choices are: login, mfa")
}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package jwks
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"golang.org/x/crypto/sha3"
)
type JwksServer struct {
uri string
jwksBytes []byte
}
// A very simple JWKS server for our MFA Cosigner example code.
func NewJwksServer(signer crypto.Signer, alg jwa.SignatureAlgorithm) (*JwksServer, string, error) {
// Compute the kid (Key ID) as the SHA-3 of the public key
pubkey := signer.Public().(*ecdsa.PublicKey) // TODO: handle non-ecdsa signers
pubkeyBytes := elliptic.Marshal(pubkey, pubkey.X, pubkey.Y)
pubkeyHash := sha3.Sum256(pubkeyBytes)
kid := hex.EncodeToString(pubkeyHash[:])
// Generate our JWKS using our signing key
jwkKey, err := jwk.PublicKeyOf(signer)
if err != nil {
return nil, "", err
}
jwkKey.Set(jwk.AlgorithmKey, alg)
jwkKey.Set(jwk.KeyIDKey, kid)
// Put our jwk into a set
keySet := jwk.NewSet()
keySet.AddKey(jwkKey)
// Now convert our key set into the raw bytes for printing later
keySetBytes, _ := json.MarshalIndent(keySet, "", " ")
if err != nil {
return nil, "", err
}
// Find an empty port
listener, err := net.Listen("tcp", ":0")
if err != nil {
return nil, "", fmt.Errorf("failed to bind to an available port: %w", err)
}
server := &JwksServer{
uri: fmt.Sprintf("http://localhost:%d", listener.Addr().(*net.TCPAddr).Port),
jwksBytes: keySetBytes,
}
// Host our JWKS at a localhost url
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/jwks.json", server.printJWKS)
go func() {
http.Serve(listener, mux)
}()
return server, kid, nil
}
func (s *JwksServer) URI() string {
return s.uri
}
func (s *JwksServer) printJWKS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(s.jwksBytes)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mfacosigner
import (
"crypto"
"crypto/rand"
"fmt"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/openpubkey/openpubkey/cosigner"
"github.com/openpubkey/openpubkey/cosigner/mocks"
)
func NewUser(as *cosigner.AuthState) *user {
return &user{
id: []byte(as.Sub),
username: as.Username,
displayName: as.DisplayName,
}
}
// This is intended as an example. Both sessionMap and users are not concurrency safe.
type MfaCosigner struct {
*cosigner.AuthCosigner
webAuthn *webauthn.WebAuthn
sessionMap map[string]*webauthn.SessionData
users map[cosigner.UserKey]*user
}
func New(signer crypto.Signer, alg jwa.SignatureAlgorithm, issuer, keyID string, cfg *webauthn.Config) (*MfaCosigner, error) {
hmacKey := make([]byte, 64)
if _, err := rand.Read(hmacKey); err != nil {
return nil, err
}
wauth, err := webauthn.New(cfg)
if err != nil {
return nil, err
}
authCos, err := cosigner.New(signer, alg, issuer, keyID, mocks.NewAuthStateInMemoryStore(hmacKey))
if err != nil {
return nil, err
}
return &MfaCosigner{
AuthCosigner: authCos,
webAuthn: wauth,
sessionMap: make(map[string]*webauthn.SessionData),
users: make(map[cosigner.UserKey]*user),
}, nil
}
func (c *MfaCosigner) CheckIsRegistered(authID string) bool {
authState, _ := c.AuthStateStore.LookupAuthState(authID)
userKey := authState.UserKey()
return c.IsRegistered(userKey)
}
func (c *MfaCosigner) IsRegistered(userKey cosigner.UserKey) bool {
_, ok := c.users[userKey]
return ok
}
func (c *MfaCosigner) BeginRegistration(authID string) (*protocol.CredentialCreation, error) {
authState, _ := c.AuthStateStore.LookupAuthState(authID)
userKey := authState.UserKey()
if c.IsRegistered(userKey) {
return nil, fmt.Errorf("already has a webauthn device registered for this user")
}
user := NewUser(authState)
credCreation, session, err := c.webAuthn.BeginRegistration(user)
if err != nil {
return nil, err
}
c.sessionMap[authID] = session
return credCreation, err
}
func (c *MfaCosigner) FinishRegistration(authID string, parsedResponse *protocol.ParsedCredentialCreationData) error {
authState, _ := c.AuthStateStore.LookupAuthState(authID)
session := c.sessionMap[authID]
userKey := authState.UserKey()
if c.IsRegistered(userKey) {
return fmt.Errorf("already has a webauthn device registered for this user")
}
user := NewUser(authState)
credential, err := c.webAuthn.CreateCredential(user, *session, parsedResponse)
if err != nil {
return err
}
user.AddCredential(*credential)
// TODO: Should use some mechanism to ensure that a registration session
// can't overwrite the result of another registration session for the same
// user if the user interleaved their registration sessions. It is a very
// unlikely possibility but it would be good to rule it out.
c.users[userKey] = user
return nil
}
func (c *MfaCosigner) BeginLogin(authID string) (*protocol.CredentialAssertion, error) {
authState, _ := c.AuthStateStore.LookupAuthState(authID)
userKey := authState.UserKey()
if user, ok := c.users[userKey]; !ok {
return nil, fmt.Errorf("user does not exist for userkey given %s", userKey)
} else if credAssertion, session, err := c.webAuthn.BeginLogin(user); err != nil {
return nil, err
} else {
c.sessionMap[authID] = session
return credAssertion, err
}
}
func (c *MfaCosigner) FinishLogin(authID string, parsedResponse *protocol.ParsedCredentialAssertionData) (string, string, error) {
authState, _ := c.AuthStateStore.LookupAuthState(authID)
session := c.sessionMap[authID]
userKey := authState.UserKey()
_, err := c.webAuthn.ValidateLogin(c.users[userKey], *session, parsedResponse)
if err != nil {
return "", "", err
}
if authcode, err := c.NewAuthcode(authID); err != nil {
return "", "", err
} else {
return authcode, authState.RedirectURI, nil
}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/protocol/webauthncbor"
"github.com/go-webauthn/webauthn/protocol/webauthncose"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/openpubkey/openpubkey/util"
)
// For testing purposes we create a WebAuthn device to run the client part of the protocol
type WebAuthnDevice struct {
signer crypto.Signer
PubkeyCbor []byte
RpID string
RpIDHash []byte
Userhandle []byte
RawID []byte
AuthFlags byte
Counter uint32
}
func NewWebauthnDevice(rpID string) (*WebAuthnDevice, error) {
alg := jwa.ES256
signer, err := util.GenKeyPair(alg)
if err != nil {
return nil, err
}
pubkey := signer.Public().(*ecdsa.PublicKey)
pubkeyCbor := webauthncose.EC2PublicKeyData{
PublicKeyData: webauthncose.PublicKeyData{
KeyType: int64(webauthncose.EllipticKey),
Algorithm: int64(webauthncose.AlgES256),
},
Curve: int64(webauthncose.AlgES256),
XCoord: pubkey.X.Bytes(),
YCoord: pubkey.Y.Bytes(),
}
pubkeyCborBytes, err := webauthncbor.Marshal(pubkeyCbor)
if err != nil {
return nil, err
}
rpIDHash := sha256.Sum256([]byte(rpID))
return &WebAuthnDevice{
signer: signer,
PubkeyCbor: pubkeyCborBytes,
RpID: rpID,
RpIDHash: rpIDHash[:],
// Checked by Webauthn RP to distinguish between different
// users accounts sharing the same device with the same RP.
// userHandle == user.WebAuthnID()?
//
// Not all devices can store a user handle it is allowed to be null
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/userHandle
//
// In OpenPubkey MFA Cosigner RP we set this to the ID Token sub
Userhandle: nil,
// The ID of the public key credential held by the device
RawID: []byte{5, 1, 1, 1, 1},
// Flag 0x41 has two bits set. 0b001 and 0b101
// FlagUserPresent Bit 00000001 - the user is present (UP flag)
// FlagUserVerified Bit 00000100 - user is verified using a biometric or PIN (UV flag)
AuthFlags: 0x41,
// Signature counter, used to identify cloned devices see https://www.w3.org/TR/webauthn/#signature-counter
Counter: 0,
}, nil
}
func (wa *WebAuthnDevice) RegResp(createCreation *protocol.CredentialCreation) (*protocol.ParsedCredentialCreationData, error) {
wa.Userhandle = []byte(createCreation.Response.User.ID.(protocol.URLEncodedBase64))
return &protocol.ParsedCredentialCreationData{
Response: protocol.ParsedAttestationResponse{
CollectedClientData: protocol.CollectedClientData{
Type: protocol.CeremonyType("webauthn.create"),
Challenge: createCreation.Response.Challenge.String(),
Origin: createCreation.Response.RelyingParty.ID,
},
AttestationObject: protocol.AttestationObject{
Format: "none",
AuthData: protocol.AuthenticatorData{
RPIDHash: wa.RpIDHash,
Counter: wa.Counter,
Flags: protocol.AuthenticatorFlags(wa.AuthFlags),
AttData: protocol.AttestedCredentialData{
AAGUID: make([]byte, 16),
CredentialID: wa.RawID,
CredentialPublicKey: wa.PubkeyCbor,
},
},
},
Transports: []protocol.AuthenticatorTransport{protocol.USB, protocol.NFC, "fake"},
},
}, nil
}
func (wa *WebAuthnDevice) LoginResp(credAssert *protocol.CredentialAssertion) (*protocol.ParsedCredentialAssertionData, error) {
loginRespData := &protocol.ParsedCredentialAssertionData{
ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{
// Checked by Webauthn RP to see if public key supplied is on the
// allowlist of public keys for this user:
// parsedResponse.RawID == session.AllowedCredentialIDs?
RawID: wa.RawID,
},
Response: protocol.ParsedAssertionResponse{
CollectedClientData: protocol.CollectedClientData{
Type: protocol.CeremonyType("webauthn.get"),
Challenge: credAssert.Response.Challenge.String(),
Origin: wa.RpID,
},
AuthenticatorData: protocol.AuthenticatorData{
RPIDHash: wa.RpIDHash,
Counter: wa.Counter,
Flags: protocol.AuthenticatorFlags(wa.AuthFlags),
},
UserHandle: wa.Userhandle, // Not a required field:
},
}
return wa.SignLoginChallenge(loginRespData)
}
func (wa *WebAuthnDevice) SignLoginChallenge(loginRespData *protocol.ParsedCredentialAssertionData) (*protocol.ParsedCredentialAssertionData, error) {
clientDataHash := sha256.Sum256(loginRespData.Raw.AssertionResponse.ClientDataJSON)
sigData := append(loginRespData.Raw.AssertionResponse.AuthenticatorData, clientDataHash[:]...)
sigHash := sha256.Sum256(sigData)
sigWebauthn, err := wa.signer.Sign(rand.Reader, sigHash[:], crypto.SHA256)
if err != nil {
return nil, err
}
loginRespData.Response.Signature = sigWebauthn
return loginRespData, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mfacosigner
import (
"encoding/json"
"fmt"
"net/http"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/openpubkey/openpubkey/examples/mfa/mfacosigner/jwks"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/util"
)
type Server struct {
cosigner *MfaCosigner
jwksUri string
}
func NewMfaCosignerHttpServer(serverUri, rpID, rpOrigin, RPDisplayName string) (*Server, error) {
server := &Server{}
// WebAuthn configuration
cfg := &webauthn.Config{
RPDisplayName: RPDisplayName,
RPID: rpID,
RPOrigin: rpOrigin,
}
// Generate the key pair for our cosigner
alg := jwa.ES256
signer, err := util.GenKeyPair(alg)
if err != nil {
return nil, err
}
jwksServer, kid, err := jwks.NewJwksServer(signer, alg)
if err != nil {
return nil, err
}
jwksHost := jwksServer.URI()
server.jwksUri = fmt.Sprintf("%s/.well-known/jwks.json", jwksHost)
issuer := rpOrigin
fmt.Println("JWKS hosted at", server.jwksUri)
server.cosigner, err = New(signer, alg, issuer, kid, cfg)
if err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.Dir("mfacosigner/static")))
mux.HandleFunc("/mfa-auth-init", server.initAuth)
mux.HandleFunc("/check-registration", server.checkIfRegistered)
mux.HandleFunc("/register/begin", server.beginRegistration)
mux.HandleFunc("/register/finish", server.finishRegistration)
mux.HandleFunc("/login/begin", server.beginLogin)
mux.HandleFunc("/login/finish", server.finishLogin)
mux.HandleFunc("/sign", server.signPkt)
mux.HandleFunc("/.well-known/openid-configuration", server.wellKnownConf)
err = http.ListenAndServe(":3003", mux)
return server, err
}
func (s *Server) URI() string {
return s.cosigner.Issuer
}
func (s *Server) initAuth(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
return
}
pktB64 := []byte(r.URL.Query().Get("pkt"))
pktJson, err := util.Base64DecodeForJWT(pktB64)
if err != nil {
return
}
var pkt *pktoken.PKToken
if err := json.Unmarshal(pktJson, &pkt); err != nil {
return
}
sig := []byte(r.URL.Query().Get("sig1"))
authID, err := s.cosigner.InitAuth(pkt, sig)
if err != nil {
http.Error(w, "Error initiating authentication", http.StatusInternalServerError)
return
}
mfapage := fmt.Sprintf("/?authid=%s", authID)
http.Redirect(w, r, mfapage, http.StatusFound)
}
func (s *Server) checkIfRegistered(w http.ResponseWriter, r *http.Request) {
authID, err := GetAuthID(r)
if err != nil {
http.Error(w, "Error in authID", http.StatusInternalServerError)
return
}
registered := s.cosigner.CheckIsRegistered(authID)
response, _ := json.Marshal(map[string]bool{
"isRegistered": registered,
})
w.WriteHeader(200)
w.Write(response)
}
func GetAuthID(r *http.Request) (string, error) {
if err := r.ParseForm(); err != nil {
return "", err
}
return string([]byte(r.URL.Query().Get("authid"))), nil
}
func (s *Server) beginRegistration(w http.ResponseWriter, r *http.Request) {
authID, err := GetAuthID(r)
if err != nil {
http.Error(w, "Error in authID", http.StatusInternalServerError)
return
}
options, err := s.cosigner.BeginRegistration(authID)
optionsJson, err := json.Marshal(options)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(optionsJson)
}
func (s *Server) finishRegistration(w http.ResponseWriter, r *http.Request) {
authID, err := GetAuthID(r)
if err != nil {
http.Error(w, "Error in authID", http.StatusInternalServerError)
return
}
parsedResponse, err := protocol.ParseCredentialCreationResponse(r)
if err != nil {
http.Error(w, "Error in parsing credential", http.StatusInternalServerError)
return
}
err = s.cosigner.FinishRegistration(authID, parsedResponse)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(201)
fmt.Println("MFA registration complete")
}
func (s *Server) beginLogin(w http.ResponseWriter, r *http.Request) {
authID, err := GetAuthID(r)
if err != nil {
http.Error(w, "Error in authID", http.StatusInternalServerError)
return
}
options, err := s.cosigner.BeginLogin(authID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
optionsJson, err := json.Marshal(options)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(optionsJson)
}
func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request) {
authID, err := GetAuthID(r)
if err != nil {
http.Error(w, "Error in authID", http.StatusInternalServerError)
return
}
parsedResponse, err := protocol.ParseCredentialRequestResponse(r)
if err != nil {
http.Error(w, "Error in parsing credential", http.StatusInternalServerError)
return
}
authcode, ruri, err := s.cosigner.FinishLogin(authID, parsedResponse)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
redirectURIl := fmt.Sprintf("%s?authcode=%s", ruri, authcode)
response, _ := json.Marshal(map[string]string{
"redirect_uri": redirectURIl,
})
w.WriteHeader(201)
w.Write(response)
}
func (s *Server) signPkt(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sig := []byte(r.URL.Query().Get("sig2"))
if cosSig, err := s.cosigner.RedeemAuthcode(sig); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else {
cosSigB64 := util.Base64EncodeForJWT(cosSig)
w.WriteHeader(201)
w.Write(cosSigB64)
}
}
func (s *Server) wellKnownConf(w http.ResponseWriter, r *http.Request) {
type WellKnown struct {
Issuer string `json:"issuer"`
JwksUri string `json:"jwks_uri"`
}
wk := WellKnown{
Issuer: s.cosigner.Issuer,
JwksUri: s.jwksUri,
}
wkJson, err := json.Marshal(wk)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(200)
w.Write(wkJson)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mfacosigner
import (
"github.com/go-webauthn/webauthn/webauthn"
)
type user struct {
id []byte
username string
displayName string
credentials []webauthn.Credential
}
var _ webauthn.User = (*user)(nil)
func (u *user) WebAuthnID() []byte {
return u.id
}
func (u *user) WebAuthnName() string {
return u.username
}
func (u *user) WebAuthnDisplayName() string {
return u.displayName
}
func (u *user) WebAuthnIcon() string {
return ""
}
func (u *user) AddCredential(cred webauthn.Credential) {
u.credentials = append(u.credentials, cred)
}
func (u *user) WebAuthnCredentials() []webauthn.Credential {
return u.credentials
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"fmt"
"github.com/goccy/go-json"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/verifier"
)
func Sign(op client.OpenIdProvider) ([]byte, []byte, error) {
// Create a OpenPubkey client, this automatically generates a fresh
// key pair (public key, signing key). The public key is added to any
// PK Tokens the client generates
opkClient, err := client.New(op)
if err != nil {
return nil, nil, err
}
// Generate a PK Token by authenticating to the OP (Google)
pkt, err := opkClient.Auth(context.Background())
if err != nil {
return nil, nil, err
}
// Use the signing key that the client just generated to sign the message
msg := []byte("All is discovered - flee at once")
signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner())
if err != nil {
return nil, nil, err
}
// Serialize the PK Token as JSON and distribute it with the signed message
pktJson, err := json.Marshal(pkt)
if err != nil {
return nil, nil, err
}
return pktJson, signedMsg, nil
}
func Verify(op client.OpenIdProvider, pktJson []byte, signedMsg []byte) error {
// Create a PK Token object from the PK Token JSON
pkt := new(pktoken.PKToken)
err := json.Unmarshal(pktJson, &pkt)
if err != nil {
return err
}
// Verify that PK Token is issued by the OP you wish to use
pktVerifier, err := verifier.New(op)
if err != nil {
return err
}
err = pktVerifier.VerifyPKToken(context.Background(), pkt)
if err != nil {
return err
}
// Check that the message verifies under the user's public key in the PK Token
msg, err := pkt.VerifySignedMessage(signedMsg)
if err != nil {
return err
}
// Get the signer's email address from ID Token inside the PK Token
idtClaims := new(oidc.OidcClaims)
if err := json.Unmarshal(pkt.Payload, idtClaims); err != nil {
return err
}
fmt.Printf("Verification successful: %s (%s) signed the message '%s'\n", idtClaims.Email, idtClaims.Issuer, string(msg))
return nil
}
func main() {
opOptions := providers.GetDefaultGoogleOpOptions()
// Change this to true to turn on GQ signatures
opOptions.GQSign = false
op := providers.NewGoogleOpWithOptions(opOptions)
pktJson, signedMsg, err := Sign(op)
if err != nil {
fmt.Println("Failed to sign message:", err)
return
}
err = Verify(op, pktJson, signedMsg)
if err != nil {
fmt.Println("Failed to verify message:", err)
return
}
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/awnumar/memguard"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/client/choosers"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/verifier"
)
func main() {
// Safely terminate in case of an interrupt signal
memguard.CatchInterrupt()
// Purge the session when we return
defer memguard.Purge()
if len(os.Args) < 2 {
fmt.Printf("OpenPubkey: command choices are login")
return
}
command := os.Args[1]
switch command {
case "login":
if err := login(); err != nil {
fmt.Println("Error logging in:", err)
} else {
fmt.Println("Login successful!")
}
default:
fmt.Println("Unrecognized command:", command)
}
}
func login() error {
googleOpOptions := providers.GetDefaultGoogleOpOptions()
googleOp := providers.NewGoogleOpWithOptions(googleOpOptions)
azureOpOptions := providers.GetDefaultAzureOpOptions()
azureOp := providers.NewAzureOpWithOptions(azureOpOptions)
gitlabOpOptions := providers.GetDefaultGitlabOpOptions()
gitlabOp := providers.NewGitlabOpWithOptions(gitlabOpOptions)
helloOpOptions := providers.GetDefaultHelloOpOptions()
helloOp := providers.NewHelloOpWithOptions(helloOpOptions)
ctx, cancel := context.WithCancel(context.Background())
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
fmt.Printf("Received shutdown signal, exiting... %v\n", sigs)
cancel()
}()
openBrowser := true
op, err := choosers.NewWebChooser(
[]providers.BrowserOpenIdProvider{googleOp, azureOp, helloOp, gitlabOp},
openBrowser,
).ChooseOp(ctx)
if err != nil {
return err
}
opkClient, err := client.New(op)
if err != nil {
return err
}
pkt, err := opkClient.Auth(ctx)
if err != nil {
return err
}
accessToken := opkClient.GetAccessToken()
fmt.Println("AccessToken", string(accessToken))
uiRequester, err := verifier.NewUserInfoRequester(pkt, string(accessToken))
if err != nil {
return err
}
userInfoJson, err := uiRequester.Request(ctx)
if err != nil {
return err
}
fmt.Println("UserInfo", string(userInfoJson))
return nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package main
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/signal"
"path"
"syscall"
"github.com/awnumar/memguard"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/client/choosers"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/util"
"github.com/openpubkey/openpubkey/verifier"
"golang.org/x/crypto/sha3"
)
var (
// File names for when we save or load our pktoken and the corresponding signing key
skFileName = "key.pem"
pktFileName = "pktoken.json"
)
func main() {
// Safely terminate in case of an interrupt signal
memguard.CatchInterrupt()
// Purge the session when we return
defer memguard.Purge()
if len(os.Args) < 2 {
fmt.Printf("OpenPubkey: command choices are login, sign, and cert")
return
}
gqSign := false
// Directory for saving data
outputDir := "output/google"
command := os.Args[1]
switch command {
case "login":
if err := login(outputDir, gqSign); err != nil {
fmt.Println("Error logging in:", err)
} else {
fmt.Println("Login successful!")
}
case "sign":
message := "sign me!!"
if err := sign(message, outputDir); err != nil {
fmt.Println("Failed to sign test message:", err)
}
default:
fmt.Println("Unrecognized command:", command)
}
}
func login(outputDir string, gqSign bool) error {
googleOpOptions := providers.GetDefaultGoogleOpOptions()
googleOpOptions.GQSign = gqSign
googleOp := providers.NewGoogleOpWithOptions(googleOpOptions)
azureOpOptions := providers.GetDefaultAzureOpOptions()
azureOpOptions.GQSign = gqSign
azureOp := providers.NewAzureOpWithOptions(azureOpOptions)
gitlabOpOptions := providers.GetDefaultGitlabOpOptions()
gitlabOpOptions.GQSign = gqSign
gitlabOp := providers.NewGitlabOpWithOptions(gitlabOpOptions)
helloOpOptions := providers.GetDefaultHelloOpOptions()
helloOpOptions.GQSign = gqSign
helloOp := providers.NewHelloOpWithOptions(helloOpOptions)
ctx, cancel := context.WithCancel(context.Background())
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
fmt.Printf("Received shutdown signal, exiting... %v\n", sigs)
cancel()
}()
openBrowser := true
op, err := choosers.NewWebChooser(
[]providers.BrowserOpenIdProvider{googleOp, azureOp, helloOp, gitlabOp},
openBrowser,
).ChooseOp(ctx)
if err != nil {
return err
}
opkClient, err := client.New(op)
if err != nil {
return err
}
pkt, err := opkClient.Auth(ctx,
client.WithExtraClaim("extra", "yes"))
if err != nil {
return err
}
// Pretty print our json token
pktJson, err := json.MarshalIndent(pkt, "", " ")
if err != nil {
return err
}
fmt.Println(string(pktJson))
pktCom, err := pkt.Compact()
if err != nil {
return err
}
fmt.Println("Compact", len(pktCom), string(pktCom))
if opkClient.Op != helloOp {
newPkt, err := opkClient.Refresh(ctx)
if err != nil {
return err
}
fmt.Println("refreshed ID Token", string(newPkt.FreshIDToken))
// Verify that PK Token is issued by the OP you wish to use and that it has a refreshed ID Token
ops := []verifier.ProviderVerifier{googleOp, azureOp, gitlabOp}
pktVerifier, err := verifier.NewFromMany(ops, verifier.RequireRefreshedIDToken())
if err != nil {
return err
}
err = pktVerifier.VerifyPKToken(context.Background(), newPkt)
if err != nil {
return err
}
// Save our signer and pktoken by writing them to a file
return saveLogin(outputDir, opkClient.GetSigner().(*ecdsa.PrivateKey), newPkt)
} else {
// HelloOP does not support refresh tokens
fmt.Println("skipping ID Token refresh for Hello OP as it does not support refresh tokens")
ops := []verifier.ProviderVerifier{googleOp, azureOp, gitlabOp, helloOp}
pktVerifier, err := verifier.NewFromMany(ops)
if err != nil {
return err
}
err = pktVerifier.VerifyPKToken(context.Background(), pkt)
if err != nil {
return err
}
// Save our signer and pktoken by writing them to a file
return saveLogin(outputDir, opkClient.GetSigner().(*ecdsa.PrivateKey), pkt)
}
}
func sign(message string, outputDir string) error {
signer, pkt, err := loadLogin(outputDir)
if err != nil {
return fmt.Errorf("failed to load client state: %w", err)
}
msgHashSum := sha3.Sum256([]byte(message))
sig, err := signer.Sign(rand.Reader, msgHashSum[:], crypto.SHA256)
if err != nil {
return err
}
fmt.Println("Signed Message:", message)
fmt.Println("Praise Sigma:", base64.StdEncoding.EncodeToString(sig))
fmt.Println("Hash:", hex.EncodeToString(msgHashSum[:]))
fmt.Println("Cert:")
pktJson, err := json.Marshal(pkt)
if err != nil {
return err
}
// Pretty print our json token
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, pktJson, "", " "); err != nil {
return err
}
fmt.Println(prettyJSON.String())
return nil
}
func saveLogin(outputDir string, sk *ecdsa.PrivateKey, pkt *pktoken.PKToken) error {
if err := os.MkdirAll(outputDir, 0777); err != nil {
return err
}
skFilePath := path.Join(outputDir, skFileName)
if err := util.WriteSKFile(skFilePath, sk); err != nil {
return err
}
pktFilePath := path.Join(outputDir, pktFileName)
pktJson, err := json.Marshal(pkt)
if err != nil {
return err
}
return os.WriteFile(pktFilePath, pktJson, 0600)
}
func loadLogin(outputDir string) (crypto.Signer, *pktoken.PKToken, error) {
skFilePath := path.Join(outputDir, skFileName)
key, err := util.ReadSKFile(skFilePath)
if err != nil {
return nil, nil, err
}
pktFilePath := path.Join(outputDir, pktFileName)
pktJson, err := os.ReadFile(pktFilePath)
if err != nil {
return nil, nil, err
}
var pkt *pktoken.PKToken
if err := json.Unmarshal(pktJson, &pkt); err != nil {
return nil, nil, err
}
return key, pkt, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package ca
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/openpubkey/openpubkey/cert"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/verifier"
)
type Ca struct {
pksk *ecdsa.PrivateKey
Alg jwa.KeyAlgorithm
// CaCertBytes []byte
RootCertPem []byte
op client.OpenIdProvider
}
func New(op client.OpenIdProvider) (*Ca, error) {
ca := Ca{
op: op,
}
alg := string(jwa.ES256)
err := ca.KeyGen(alg)
if err != nil {
return nil, err
}
return &ca, nil
}
func (a *Ca) KeyGen(alg string) error {
a.Alg = jwa.KeyAlgorithmFrom(alg)
pksk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
a.pksk = pksk
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Openpubkey-test-ca-cert"},
Country: []string{"International"},
Province: []string{""},
Locality: []string{""},
StreetAddress: []string{"255 Test St."},
PostalCode: []string{""},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageCodeSigning},
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
}
caBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &a.pksk.PublicKey, a.pksk)
if err != nil {
return err
}
caPEM := new(bytes.Buffer)
pem.Encode(caPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: caBytes,
})
a.RootCertPem = caPEM.Bytes()
return nil
}
func (a *Ca) CheckPKToken(pktJson []byte) (*pktoken.PKToken, error) {
pkt := new(pktoken.PKToken)
if err := json.Unmarshal(pktJson, pkt); err != nil {
return nil, err
}
verifier, err := verifier.New(a.op)
if err != nil {
return nil, err
}
if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil {
return nil, fmt.Errorf("failed to verify PK token: %w", err)
}
return pkt, nil
}
func (a *Ca) PktToSignedX509(pktJson []byte) ([]byte, error) {
pkt, err := a.CheckPKToken(pktJson)
if err != nil {
return nil, err
}
pktUpk, err := ExtractRawPubkey(pkt)
if err != nil {
return nil, err
}
subTemplate, err := cert.PktToX509Template(pkt)
if err != nil {
return nil, err
}
rootCert, _ := pem.Decode(a.RootCertPem)
if rootCert == nil {
return nil, fmt.Errorf("failed to parse certificate PEM")
}
caTemplate, err := x509.ParseCertificate(rootCert.Bytes)
if err != nil {
return nil, err
}
subCertBytes, err := x509.CreateCertificate(rand.Reader, subTemplate, caTemplate, pktUpk, a.pksk)
if err != nil {
return nil, err
}
subCert, err := x509.ParseCertificate(subCertBytes)
if err != nil {
return nil, err
}
var pemSubCert bytes.Buffer
err = pem.Encode(&pemSubCert, &pem.Block{Type: "CERTIFICATE", Bytes: subCert.Raw})
if err != nil {
return nil, err
}
return pemSubCert.Bytes(), nil
}
// VerifyPktCert checks that the X509 cert is signed by the CA and that
// the PK Token in the cert matches the public key in the cert.
func (a *Ca) VerifyPktCert(issuedCertPEM []byte) error {
roots := x509.NewCertPool()
ok := roots.AppendCertsFromPEM([]byte(a.RootCertPem))
if !ok {
return fmt.Errorf("failed to parse root certificate")
}
block, _ := pem.Decode([]byte(issuedCertPEM))
if block == nil {
return fmt.Errorf("failed to parse certificate PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse certificate PEM: %w", err)
}
_, err = cert.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: x509.NewCertPool(),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
})
if err != nil {
return fmt.Errorf("failed to verify certificate: %w", err)
}
pktJson := cert.SubjectKeyId
pkt := new(pktoken.PKToken)
if err := json.Unmarshal(pktJson, pkt); err != nil {
return err
}
pktUpk, err := ExtractRawPubkey(pkt)
if err != nil {
return err
}
certPublickey := cert.PublicKey.(*ecdsa.PublicKey)
if !certPublickey.Equal(pktUpk) {
return fmt.Errorf("public key in cert does not match PK Token's public key")
}
certPublicKeyBytes, err := x509.MarshalPKIXPublicKey(certPublickey)
if err := json.Unmarshal(pktJson, pkt); err != nil {
return err
}
if string(cert.RawSubjectPublicKeyInfo) != string(certPublicKeyBytes) {
return fmt.Errorf("certificate raw subject public key info does not match ephemeral public key")
}
// Verification succeeds
return nil
}
func ExtractRawPubkey(pkt *pktoken.PKToken) (interface{}, error) {
cic, err := pkt.GetCicValues()
if err != nil {
return nil, err
}
upk := cic.PublicKey()
var rawUpk interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey
if err := upk.Raw(&rawUpk); err != nil {
return nil, err
}
return rawUpk, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/awnumar/memguard"
"github.com/openpubkey/openpubkey/client"
"github.com/openpubkey/openpubkey/examples/x509/ca"
"github.com/openpubkey/openpubkey/providers"
)
func main() {
// Safely terminate in case of an interrupt signal
memguard.CatchInterrupt()
// Purge the session when we return
defer memguard.Purge()
if len(os.Args) < 2 {
fmt.Printf("OpenPubkey: command choices are login, sign, and cert")
return
}
command := os.Args[1]
switch command {
case "login":
opOpts := providers.GetDefaultGoogleOpOptions()
opOpts.GQSign = true
op := providers.NewGoogleOp()
if err := login(op); err != nil {
fmt.Println("Error logging in:", err)
} else {
fmt.Println("Login and X509 issuance successful!")
}
default:
fmt.Println("Unrecognized command:", command)
}
}
func login(op client.OpenIdProvider) error {
opkClient, err := client.New(
op,
)
if err != nil {
return err
}
pkt, err := opkClient.Auth(context.Background())
if err != nil {
return err
}
// Pretty print our json token
pktJson, err := json.MarshalIndent(pkt, "", " ")
if err != nil {
return err
}
CertAuth, err := ca.New(op)
if err != nil {
return err
}
pemSubCert, err := CertAuth.PktToSignedX509(pktJson)
if err != nil {
return err
}
fmt.Println("Issued Cert: \n", string(pemSubCert))
msg := []byte("All is discovered - flee at once")
signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner())
if err != nil {
return err
}
println("Signed Message: \n", string(signedMsg))
err = CertAuth.VerifyPktCert(pemSubCert)
if err != nil {
return err
}
return nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package gq
import (
"math/big"
"filippo.io/bigmod"
)
// leaks only the size of x
func modAsInt(x *bigmod.Modulus) *big.Int {
return new(big.Int).SetBytes(x.Nat().Bytes(x))
}
// leaks only the size of x
func natAsInt(x *bigmod.Nat, m *bigmod.Modulus) *big.Int {
return new(big.Int).SetBytes(x.Bytes(m))
}
// leaks only the size of x
func intAsNat(x *big.Int, m *bigmod.Modulus) (*bigmod.Nat, error) {
return bigmod.NewNat().SetBytes(x.Bytes(), m)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package gq
import (
"crypto/rsa"
"fmt"
"io"
"math/big"
"filippo.io/bigmod"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"golang.org/x/crypto/sha3"
)
var GQ256 = jwa.SignatureAlgorithm("GQ256")
func init() {
jwa.RegisterSignatureAlgorithm(GQ256)
}
type OptsStruct struct {
extraClaims map[string]any
}
type Opts func(a *OptsStruct)
// WithExtraClaim specifies additional values to be included in the
// GQ signed JWT. These claims will be included in the protected header
// of the JWT
// Example use:
//
// WithExtraClaim("claimKey", "claimValue")
func WithExtraClaim(k string, v string) Opts {
return func(a *OptsStruct) {
if a.extraClaims == nil {
a.extraClaims = map[string]any{}
}
a.extraClaims[k] = v
}
}
// GQ256SignJWT takes a rsaPublicKey and signed JWT and computes a GQ1 signature
// on the JWT. It returns a JWT whose RSA signature has been replaced by
// the GQ signature. It is wrapper around SignerVerifier.SignJWT
// an additional check that the correct rsa public key has been supplied.
// Use this instead of SignerVerifier.SignJWT.
func GQ256SignJWT(rsaPublicKey *rsa.PublicKey, jwt []byte, opts ...Opts) ([]byte, error) {
_, err := jws.Verify(jwt, jws.WithKey(jwa.RS256, rsaPublicKey))
if err != nil {
return nil, fmt.Errorf("incorrect public key supplied when GQ signing jwt: %w", err)
}
sv, err := New256SignerVerifier(rsaPublicKey)
if err != nil {
return nil, fmt.Errorf("error creating GQ signer: %w", err)
}
gqJWT, err := sv.SignJWT(jwt, opts...)
if err != nil {
return nil, fmt.Errorf("error creating GQ signature: %w", err)
}
return gqJWT, nil
}
// GQ256VerifyJWT verifies a GQ1 signature over GQ signed JWT
func GQ256VerifyJWT(rsaPublicKey *rsa.PublicKey, gqToken []byte) (bool, error) {
sv, err := New256SignerVerifier(rsaPublicKey)
if err != nil {
return false, fmt.Errorf("error creating GQ signer: %w", err)
}
return sv.VerifyJWT(gqToken), nil
}
// Signer allows for creating GQ1 signatures messages.
type Signer interface {
// Sign creates a GQ1 signature over the given message with the given GQ1 private number.
Sign(private []byte, message []byte) ([]byte, error)
// SignJWT creates a GQ1 signature over the JWT token's header/payload with a GQ1 private number derived from the JWT signature.
//
// This works because a GQ1 private number can be calculated as the inverse mod n of an RSA signature, where n is the public RSA modulus.
SignJWT(jwt []byte, opts ...Opts) ([]byte, error)
}
// Signer allows for verifying GQ1 signatures.
type Verifier interface {
// Verify verifies a GQ1 signature over a message, using the public identity of the signer.
Verify(signature []byte, identity []byte, message []byte) bool
// Compatible with SignJWT, this function verifies the GQ1 signature of the presented JSON Web Token.
VerifyJWT(jwt []byte) bool
}
// SignerVerifier combines the Signer and Verifier interfaces.
type SignerVerifier interface {
Signer
Verifier
}
type signerVerifier struct {
// n is the RSA public modulus (what Go's RSA lib calls N)
n *bigmod.Modulus
// v is the RSA public exponent (what Go's RSA lib calls E)
v *big.Int
// nBytes is the length of n in bytes
nBytes int
// vBytes is the length of v in bytes
vBytes int
// t is the signature length parameter
t int
}
// Creates a new SignerVerifier specifically for GQ256, meaning the security parameter is 256.
func New256SignerVerifier(publicKey *rsa.PublicKey) (SignerVerifier, error) {
return NewSignerVerifier(publicKey, 256)
}
// NewSignerVerifier creates a SignerVerifier from the RSA public key of the trusted third-party which creates
// the GQ1 private numbers.
//
// The securityParameter parameter is the level of desired security in bits. 256 is recommended.
func NewSignerVerifier(publicKey *rsa.PublicKey, securityParameter int) (SignerVerifier, error) {
if publicKey.E != 65537 {
// Danger: Currently it is unsafe to use this library with a RSA exponent other than 65537.
// This issue is being tracked in https://github.com/openpubkey/openpubkey/issues/230
return nil, fmt.Errorf("only 65537 is currently supported, unsupported RSA public key exponent: %d", publicKey.E)
}
n, v, nBytes, vBytes, err := parsePublicKey(publicKey)
t := securityParameter / (vBytes * 8)
return &signerVerifier{n, v, nBytes, vBytes, t}, err
}
func parsePublicKey(publicKey *rsa.PublicKey) (n *bigmod.Modulus, v *big.Int, nBytes int, vBytes int, err error) {
n, err = bigmod.NewModulusFromBig(publicKey.N)
if err != nil {
return
}
v = big.NewInt(int64(publicKey.E))
nLen := n.BitLen()
vLen := v.BitLen() - 1 // note the -1; GQ1 only ever uses the (length of v) - 1, so we can just do this here rather than throughout
nBytes = bytesForBits(nLen)
vBytes = bytesForBits(vLen)
return
}
func bytesForBits(bits int) int {
return (bits + 7) / 8
}
var hash = func(byteCount int, data ...[]byte) ([]byte, error) {
rng := sha3.NewShake256()
for _, d := range data {
rng.Write(d)
}
return randomBytes(rng, byteCount)
}
func randomBytes(rng io.Reader, byteCount int) ([]byte, error) {
bytes := make([]byte, byteCount)
_, err := io.ReadFull(rng, bytes)
if err != nil {
return nil, err
}
return bytes, nil
}
func OriginalJWTHeaders(jwt []byte) ([]byte, error) {
token, err := jws.Parse(jwt)
if err != nil {
return nil, err
}
// a JWT is guaranteed to have exactly one signature
headers := token.Signatures()[0].ProtectedHeaders()
if headers.Algorithm() != GQ256 {
return nil, fmt.Errorf("expected GQ256 alg, got %s", headers.Algorithm())
}
origHeaders := []byte(headers.KeyID())
return origHeaders, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package gq
import (
"crypto"
"crypto/sha256"
)
// Hardcoded padding prefix for SHA-256 from https://github.com/golang/go/blob/eca5a97340e6b475268a522012f30e8e25bb8b8f/src/crypto/rsa/pkcs1v15.go#L268
var prefix = []byte{0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20}
// encodePKCS1v15 is taken from the go stdlib, see [crypto/rsa.SignPKCS1v15].
//
// https://github.com/golang/go/blob/eca5a97340e6b475268a522012f30e8e25bb8b8f/src/crypto/rsa/pkcs1v15.go#L287-L317
var encodePKCS1v15 = func(k int, data []byte) []byte {
hashLen := crypto.SHA256.Size()
tLen := len(prefix) + hashLen
// EM = 0x00 || 0x01 || PS || 0x00 || T
em := make([]byte, k)
em[1] = 1
for i := 2; i < k-tLen-1; i++ {
em[i] = 0xff
}
copy(em[k-tLen:k-hashLen], prefix)
hashed := sha256.Sum256(data)
copy(em[k-hashLen:k], hashed[:])
return em
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package gq
import (
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"filippo.io/bigmod"
"github.com/awnumar/memguard"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/util"
)
// Sign creates a GQ1 signature over the given message with the given GQ1 private number.
//
// Comments throughout refer to stages as specified in the ISO/IEC 14888-2 standard.
func (sv *signerVerifier) Sign(private []byte, message []byte) ([]byte, error) {
n, v, t := sv.n, sv.v, sv.t
vBytes := sv.vBytes
M := message
Q, err := bigmod.NewNat().SetBytes(private, n)
if err != nil {
return nil, err
}
// Stage 1 - select t numbers, each consisting of nBytes random bytes.
// In order to guarantee our operation is constant time, we deviate slightly
// from the standard and directly select an integer less than n
r, err := randomNumbers(t, sv.n)
if err != nil {
return nil, err
}
// Stage 2 - calculate test number W
// for i from 1 to t, compute W_i <- r_i^v mod n
// combine to form W
var W []byte
for i := 0; i < t; i++ {
W_i := bigmod.NewNat().Exp(r[i], v.Bytes(), n)
W = append(W, W_i.Bytes(n)...)
}
// Stage 3 - calculate question number R
// hash W and M and take first t*vBytes bytes as R
R, err := hash(t*vBytes, W, M)
if err != nil {
return nil, err
}
// split R into t numbers each consisting of vBytes bytes
Rs := make([]*bigmod.Nat, t)
for i := 0; i < t; i++ {
Rs[i], err = new(bigmod.Nat).SetBytes(R[i*vBytes:(i+1)*vBytes], n)
if err != nil {
return nil, err
}
}
// Stage 4 - calculate witness number S
// for i from 1 to t, compute S_i <- r_i * Q^{R_i} mod n
// combine to form S
var S []byte
for i := 0; i < t; i++ {
S_i := bigmod.NewNat().Exp(Q, Rs[i].Bytes(n), n)
S_i.Mul(r[i], n)
S = append(S, S_i.Bytes(n)...)
}
// proof is combination of R and S
return encodeProof(R, S), nil
}
func (sv *signerVerifier) SignJWT(jwt []byte, opts ...Opts) ([]byte, error) {
options := &OptsStruct{}
for _, applyOpt := range opts {
applyOpt(options)
}
// Ensure that someone doesn't use a reserved protected header claim name
for _, reserved := range []string{"alg", "typ", "kid"} {
if _, ok := options.extraClaims[reserved]; ok {
return nil, fmt.Errorf("use of reserved header name, %s, in additional headers", reserved)
}
}
origHeaders, payload, signature, err := jws.SplitCompact(jwt)
if err != nil {
return nil, err
}
signingPayload := util.JoinJWTSegments(origHeaders, payload)
headers := jws.NewHeaders()
err = headers.Set(jws.AlgorithmKey, GQ256)
if err != nil {
return nil, err
}
err = headers.Set(jws.TypeKey, "JWT")
if err != nil {
return nil, err
}
err = headers.Set(jws.KeyIDKey, string(origHeaders))
if err != nil {
return nil, err
}
for k, v := range options.extraClaims {
if err = headers.Set(k, v); err != nil {
return nil, err
}
}
headersJSON, err := json.Marshal(headers)
if err != nil {
return nil, err
}
headersEnc := util.Base64EncodeForJWT(headersJSON)
// When jwt is parsed it's split into base64-encoded bytes, but
// we need the raw signature to calculate mod inverse
decodedSig, err := util.Base64DecodeForJWT(signature)
if err != nil {
return nil, err
}
// GQ1 private number (Q) is inverse of RSA signature mod n
private, err := sv.modInverse(memguard.NewBufferFromBytes(decodedSig))
if err != nil {
return nil, err
}
defer private.Destroy()
gqSig, err := sv.Sign(private.Bytes(), signingPayload)
if err != nil {
return nil, err
}
// Now make a new GQ-signed token
gqToken := util.JoinJWTSegments(headersEnc, payload, gqSig)
return gqToken, nil
}
// modInverse finds the modular multiplicative inverse of the value stored in b
//
// All operations involving the secret value are performed either with constant-
// time methods or with blinding (if sv has a source of randomness)
func (sv *signerVerifier) modInverse(b *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
x, err := bigmod.NewNat().SetBytes(b.Bytes(), sv.n)
if err != nil {
return nil, err
}
nInt := natAsInt(sv.n.Nat(), sv.n)
var r *big.Int
var rConstant, xr *bigmod.Nat
// Apply RSA blinding to the ModInverse operation.
// Translates the technique formerly used in the Go Standard Library before they
// switched to bigmod in late 2022. Since bigmod does not yet support constant-time
// ModInverse, we perform the blinding so that the value of the private key is not
// detectable via side channel.
// Ref: https://github.com/golang/go/blob/5f60f844beb0581a19cb425a3338d79d322a7db2/src/crypto/rsa/rsa.go#L567-L596
//
// For a secret value x, the idea is to find m = 1/x mod n by calculating
// rm/r mod n ==> r/(xr) mod n, where r is a random value
for {
// draw r
r, err = rand.Int(rand.Reader, nInt)
if err != nil {
return nil, err
}
// compute xr = x * r
xr, err = intAsNat(r, sv.n)
if err != nil {
return nil, err
}
xr.Mul(x, sv.n)
// check that xr has a multiplicative inverse mod n. It is exceedingly
// rare but technically possible for it not to, in which case we need
// to draw a new value for r
xrInt := natAsInt(xr, sv.n)
inverse := new(big.Int).ModInverse(xrInt, nInt)
if inverse != nil {
break
}
}
// overwrite x with the blinded value
x = xr
// calculate m/r mod n
m := natAsInt(x, sv.n).ModInverse(natAsInt(x, sv.n), nInt)
mConstant, err := intAsNat(m, sv.n)
if err != nil {
return nil, err
}
// remove the blinding by multiplying m/r by r
rConstant, err = intAsNat(r, sv.n)
if err != nil {
return nil, err
}
mConstant.Mul(rConstant, sv.n)
mFinal := natAsInt(mConstant, sv.n)
// need to allocate memory for fixed length slice using FillBytes
ret := make([]byte, len(b.Bytes()))
defer b.Destroy()
return memguard.NewBufferFromBytes(mFinal.FillBytes(ret)), nil
}
func encodeProof(R, S []byte) []byte {
var bin []byte
bin = append(bin, R...)
bin = append(bin, S...)
return util.Base64EncodeForJWT(bin)
}
var randomNumbers = func(t int, n *bigmod.Modulus) ([]*bigmod.Nat, error) {
nInt := modAsInt(n)
ys := make([]*bigmod.Nat, t)
for i := 0; i < t; i++ {
r, err := rand.Int(rand.Reader, nInt)
if err != nil {
return nil, err
}
ys[i], err = intAsNat(r, n)
if err != nil {
return nil, err
}
}
return ys, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package gq
import (
"bytes"
"fmt"
"math/big"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/util"
)
// Verify verifies a GQ1 signature over a message, using the public identity of the signer.
//
// Comments throughout refer to stages as specified in the ISO/IEC 14888-2 standard.
func (sv *signerVerifier) Verify(proof []byte, identity []byte, message []byte) bool {
n, v, t := modAsInt(sv.n), sv.v, sv.t
nBytes, vBytes := sv.nBytes, sv.vBytes
M := message
// Stage 0 - reject proof if it's the wrong size based on t
R, S, err := sv.decodeProof(proof)
if err != nil {
return false
}
// Stage 1 - create public number G
// currently this hardcoded to use PKCS#1 v1.5 padding as the format mechanism
paddedIdentity := encodePKCS1v15(nBytes, identity)
G := new(big.Int).SetBytes(paddedIdentity)
// Stage 2 - parse signature numbers and recalculate test number W*
// split R into t strings, each consisting of vBytes bytes
Rs := make([]*big.Int, t)
for i := 0; i < t; i++ {
Rs[i] = new(big.Int).SetBytes(R[i*vBytes : (i+1)*vBytes])
}
// split S into t strings, each consisting of nBytes bytes
Ss := make([]*big.Int, t)
for i := 0; i < t; i++ {
s_i := new(big.Int).SetBytes(S[i*nBytes : (i+1)*nBytes])
// reject if S_i = 0 or >= n
if s_i.Cmp(big.NewInt(0)) == 0 || s_i.Cmp(n) != -1 {
return false
}
Ss[i] = s_i
}
// recalculate test number W*
// for i from 1 to t, compute W*_i <- S_i^v * G^{R_i} mod n
// combine to form W*
var Wstar []byte
for i := 0; i < t; i++ {
l := new(big.Int).Exp(Ss[i], v, n)
r := new(big.Int).Exp(G, Rs[i], n)
Wstar_i := new(big.Int).Mul(l, r)
Wstar_i.Mod(Wstar_i, n)
b := make([]byte, nBytes)
Wstar = append(Wstar, Wstar_i.FillBytes(b)...)
}
// Stage 3 - recalculate question number R*
// hash W* and M and take first t*vBytes bytes as R*
Rstar, err := hash(t*vBytes, Wstar, M)
if err != nil {
// TODO: this can only happen if there's some error reading /dev/urandom or something
// so should we return the proper error?
return false
}
// Stage 4 - accept or reject depending on whether R and R* are identical
return bytes.Equal(R, Rstar)
}
func (sv *signerVerifier) VerifyJWT(jwt []byte) bool {
origHeaders, err := OriginalJWTHeaders(jwt)
if err != nil {
return false
}
_, payload, signature, err := jws.SplitCompact(jwt)
if err != nil {
return false
}
signingPayload := util.JoinJWTSegments(origHeaders, payload)
return sv.Verify(signature, signingPayload, signingPayload)
}
func (sv *signerVerifier) decodeProof(s []byte) (R, S []byte, err error) {
bin, err := util.Base64DecodeForJWT(s)
if err != nil {
return nil, nil, err
}
rSize := sv.vBytes * sv.t
sSize := sv.nBytes * sv.t
if len(bin) != rSize+sSize {
return nil, nil, fmt.Errorf("not the correct size")
}
R = bin[:rSize]
S = bin[rSize:]
return R, S, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"fmt"
)
type Jws struct {
Payload string `json:"payload"` // Base64 encoded
Signatures []Signature `json:"signatures"` // Base64 encoded
}
type SigOptStruct struct {
PublicHeader map[string]any
}
type SigOpts func(a *SigOptStruct)
// WithPublicHeader species that a public header be included in the
// signature. Public headers aren't Base64 encoded because they aren't signed.
// Example use: WithPublicHeader(map[string]any{"key1": "abc", "key2": "def"})
func WithPublicHeader(publicHeader map[string]any) SigOpts {
return func(o *SigOptStruct) {
o.PublicHeader = publicHeader
}
}
func (j *Jws) AddSignature(token []byte, opts ...SigOpts) error {
sigOpts := &SigOptStruct{}
for _, applyOpt := range opts {
applyOpt(sigOpts)
}
protected, payload, signature, err := SplitCompact(token)
if err != nil {
return err
}
if j.Payload != string(payload) {
return fmt.Errorf("payload in compact token does not match existing payload in jws, expected=(%s), got=(%s)",
string(j.Payload),
string(payload))
}
sig := Signature{
Protected: string(protected),
Public: sigOpts.PublicHeader,
Signature: string(signature),
}
if j.Signatures == nil {
j.Signatures = []Signature{}
}
j.Signatures = append(j.Signatures, sig)
return nil
}
func (j *Jws) GetToken(i int) ([]byte, error) {
if i < len(j.Signatures) && i >= 0 {
return []byte(j.Signatures[i].Protected + "." + j.Payload + "." + j.Signatures[i].Signature), nil
} else {
return nil, fmt.Errorf("no signature at index i (%d), len(signatures) (%d)", i, len(j.Signatures))
}
}
func (j *Jws) GetTokenByTyp(typ string) ([]byte, error) {
matchingTokens := []Signature{}
for _, v := range j.Signatures {
if typFound, err := v.GetTyp(); err != nil {
return nil, err
} else {
// Both the JWS standard and the OIDC standard states that typ is case sensitive
// so we treat it as case sensitive as well
//
// "The typ (type) header parameter is used to declare the type of the
// signed content. The typ value is case sensitive."
// https://openid.net/specs/draft-jones-json-web-signature-04.html#ReservedHeaderParameterName
//
// "The "typ" (type) Header Parameter is used by JWS applications to
// declare the media type [IANA.MediaTypes] of this complete JWS.
// [..] Per RFC 2045 [RFC2045], all media type values, subtype values, and
// parameter names are case insensitive. However, parameter values are case
// sensitive unless otherwise specified for the specific parameter."
// https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.9
if typFound == typ {
matchingTokens = append(matchingTokens, v)
}
}
}
if len(matchingTokens) > 1 {
// Currently we only have one token per token typ. We can change this later
// for COS tokens. This check prevents hidden tokens, where one token of
// the same typ hides another token of the same typ.
return nil, fmt.Errorf("more than one token found, all current token typs are unique")
} else if len(matchingTokens) == 0 {
// if typ not found return nil
return nil, nil
} else {
return []byte(matchingTokens[0].Protected + "." + j.Payload + "." + matchingTokens[0].Signature), nil
}
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"fmt"
)
type Jwt struct {
payload string
payloadClaims *OidcClaims
signature *Signature
raw []byte
}
func NewJwt(token []byte) (*Jwt, error) {
protected, payload, signature, err := SplitCompact(token)
if err != nil {
return nil, err
}
idt := &Jwt{
payload: string(payload),
signature: &Signature{
Protected: string(protected),
Signature: string(signature),
},
raw: token,
}
if err := ParseJWTSegment(protected, &idt.signature.protectedClaims); err != nil {
return nil, fmt.Errorf("error parsing protected: %w", err)
}
if err := ParseJWTSegment(payload, &idt.payloadClaims); err != nil {
return nil, fmt.Errorf("error parsing payload: %w", err)
}
return idt, nil
}
func (i *Jwt) GetClaims() *OidcClaims {
return i.payloadClaims
}
func (i *Jwt) GetPayload() string {
return i.payload
}
func (i *Jwt) GetSignature() *Signature {
return i.signature
}
func (i *Jwt) GetRaw() []byte {
return i.raw
}
// Compares two JWTs and determines if they are for the same identity (subject)
func SameIdentity(t1, t2 []byte) error {
token1, err := NewJwt(t1)
if err != nil {
return err
}
token2, err := NewJwt(t2)
if err != nil {
return err
}
// Subject identity can only be established within the same issuer
if token1.GetClaims().Issuer != token2.GetClaims().Issuer {
return fmt.Errorf("tokens have different issuers")
}
if token1.GetClaims().Subject != token2.GetClaims().Subject {
return fmt.Errorf("token have a different subject claims")
}
return nil
}
// RequireOlder returns an error if t1 is not older than t2
func RequireOlder(t1, t2 []byte) error {
token1, err := NewJwt(t1)
if err != nil {
return err
}
token2, err := NewJwt(t2)
if err != nil {
return err
}
// Check which token was issued first
if token1.GetClaims().IssuedAt > token2.GetClaims().IssuedAt {
return fmt.Errorf("tokens not issued in correct order")
}
return nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/openpubkey/openpubkey/util"
)
type OidcClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"-"`
Expiration int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Email string `json:"email,omitempty"`
Nonce string `json:"nonce,omitempty"`
Username string `json:"preferred_username,omitempty"`
FirstName string `json:"given_name,omitempty"`
LastName string `json:"family_name,omitempty"`
Groups []string `json:"groups,omitempty"`
Scopes []string `json:"scopes,omitempty"`
}
// Implement UnmarshalJSON for custom handling during JSON unmarshalling
func (id *OidcClaims) UnmarshalJSON(data []byte) error {
// unmarshal audience claim separately to account for []string, https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
type Alias OidcClaims
aux := &struct {
Audience any `json:"aud"`
*Alias
}{
Alias: (*Alias)(id),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
switch t := aux.Audience.(type) {
case string:
id.Audience = t
case []any:
audList := []string{}
for _, v := range t {
audList = append(audList, v.(string))
}
id.Audience = strings.Join(audList, ",")
default:
id.Audience = ""
}
return nil
}
// SplitCompact splits a JWT and returns its three parts
// separately: protected headers, payload and signature.
// This is copied from github.com/lestrrat-go/jwx/v2/jws.SplitCompact
// We include it here so so that jwx is not a dependency of simpleJws
func SplitCompact(src []byte) ([]byte, []byte, []byte, error) {
parts := bytes.Split(src, []byte("."))
if len(parts) != 3 {
return nil, nil, nil, fmt.Errorf(`invalid number of segments`)
}
return parts[0], parts[1], parts[2], nil
}
func ParseJWTSegment(segment []byte, v any) error {
segmentJSON, err := util.Base64DecodeForJWT(segment)
if err != nil {
return fmt.Errorf("error decoding segment: %w", err)
}
err = json.Unmarshal(segmentJSON, v)
if err != nil {
return fmt.Errorf("error parsing segment: %w", err)
}
return nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"encoding/json"
"github.com/openpubkey/openpubkey/util"
)
type Signature struct {
Protected string `json:"protected"` // Base64 encoded
protectedClaims *ProtectedClaims // Unmarshalled protected claims
Public map[string]interface{} `json:"header,omitempty"`
Signature string `json:"signature"` // Base64 encoded
}
type ProtectedClaims struct {
Alg string `json:"alg"`
Jkt string `json:"jkt,omitempty"`
KeyID string `json:"kid,omitempty"`
Type string `json:"typ,omitempty"`
CIC string `json:"cic,omitempty"`
}
func (s *Signature) GetTyp() (string, error) {
decodedProtected, err := util.Base64DecodeForJWT([]byte(s.Protected))
if err != nil {
return "", err
}
type protectedTyp struct {
Typ string `json:"typ"`
}
var ph protectedTyp
err = json.Unmarshal(decodedProtected, &ph)
if err != nil {
return "", err
}
return ph.Typ, nil
}
func (s *Signature) GetProtectedClaims() *ProtectedClaims {
return s.protectedClaims
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package clientinstance
import (
"crypto"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/util"
)
// Client Instance Claims, referred also as "cic" in the OpenPubKey paper
type Claims struct {
publicKey jwk.Key
// Claims are stored in the protected header portion of JWS signature
protected map[string]any
}
// Client instance claims must relate to a single key pair
func NewClaims(publicKey jwk.Key, claims map[string]any) (*Claims, error) {
// Make sure our JWK has the algorithm header set
if publicKey.Algorithm().String() == "" {
return nil, fmt.Errorf("user JWK requires algorithm to be set")
}
// Make sure no claims are using our reserved values
for _, reserved := range []string{"alg", "upk", "rz", "typ"} {
if _, ok := claims[reserved]; ok {
return nil, fmt.Errorf("use of reserved header name, %s, in additional headers", reserved)
}
}
rand, err := generateRand()
if err != nil {
return nil, fmt.Errorf("failed to generate random value: %w", err)
}
// Assign required values
claims["typ"] = "CIC"
claims["alg"] = publicKey.Algorithm().String()
claims["upk"] = publicKey
claims["rz"] = rand
return &Claims{
publicKey: publicKey,
protected: claims,
}, nil
}
func ParseClaims(protected map[string]any) (*Claims, error) {
// Get our standard headers and make sure they match up
if _, ok := protected["rz"]; !ok {
return nil, fmt.Errorf(`missing required "rz" claim`)
}
upk, ok := protected["upk"]
if !ok {
return nil, fmt.Errorf(`missing required "upk" claim`)
}
upkBytes, err := json.Marshal(upk)
if err != nil {
return nil, err
}
upkjwk, err := jwk.ParseKey(upkBytes)
if err != nil {
return nil, err
}
alg, ok := protected["alg"]
if !ok {
return nil, fmt.Errorf(`missing required "alg" claim`)
} else if alg != upkjwk.Algorithm() {
return nil, fmt.Errorf(`provided "alg" value different from algorithm provided in "upk" jwk`)
}
return &Claims{
publicKey: upkjwk,
protected: protected,
}, nil
}
func (c *Claims) PublicKey() jwk.Key {
return c.publicKey
}
func (c *Claims) KeyAlgorithm() jwa.KeyAlgorithm {
return c.publicKey.Algorithm()
}
// Returns a hash of all client instance claims which includes a random value
func (c *Claims) Hash() ([]byte, error) {
buf, err := json.Marshal(c.protected)
if err != nil {
return nil, err
}
return util.B64SHA3_256(buf), nil
}
// This function signs the payload of the provided token with the protected headers
// as defined by the client instance claims and returns a jwt in compact form.
func (c *Claims) Sign(signer crypto.Signer, algorithm jwa.KeyAlgorithm, token []byte) ([]byte, error) {
_, payload, _, err := jws.SplitCompact(token)
if err != nil {
return nil, err
}
// We need to make sure we're signing the decoded bytes
payloadDecoded, err := util.Base64DecodeForJWT(payload)
if err != nil {
return nil, err
}
headers := jws.NewHeaders()
for key, val := range c.protected {
if err := headers.Set(key, val); err != nil {
return nil, err
}
}
cicToken, err := jws.Sign(
payloadDecoded,
jws.WithKey(
algorithm,
signer,
jws.WithProtectedHeaders(headers),
),
)
if err != nil {
return nil, err
}
return cicToken, nil
}
func generateRand() (string, error) {
bits := 256
rBytes := make([]byte, bits/8)
_, err := rand.Read(rBytes)
if err != nil {
return "", err
}
rz := hex.EncodeToString(rBytes)
return rz, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package pktoken
import (
"bytes"
"fmt"
"github.com/openpubkey/openpubkey/oidc"
)
// CompactPKToken creates a compact representation of a PK Token from a list of tokens
func CompactPKToken(tokens [][]byte, freshIDToken []byte) ([]byte, error) {
if len(tokens) == 0 {
return nil, fmt.Errorf("no tokens provided")
}
compact := [][]byte{}
var payload []byte
for _, tok := range tokens {
tokProtected, tokPayload, tokSig, err := oidc.SplitCompact(tok)
if err != nil {
return nil, err
}
if payload != nil {
if !bytes.Equal(payload, tokPayload) {
return nil, fmt.Errorf("payloads in tokens are not the same got %s and %s", payload, tokPayload)
}
} else {
payload = tokPayload
}
compact = append(compact, tokProtected, tokSig)
}
// prepend the payload to the front
compact = append([][]byte{payload}, compact...)
pktCom := bytes.Join(compact, []byte(":"))
// If we have a refreshed ID Token, append it to the compact representation using "."
if freshIDToken != nil {
if len(bytes.Split(freshIDToken, []byte("."))) != 3 {
// Compact ID Token should be reformated as Base64(protected)"."Base64(payload)"."Base64(signature)
return nil, fmt.Errorf("invalid refreshed ID Token")
}
pktCom = bytes.Join([][]byte{pktCom, freshIDToken}, []byte("."))
}
return pktCom, nil
}
// SplitCompactPKToken breaks a compact representation of a PK Token into its constituent tokens
func SplitCompactPKToken(pktCom []byte) ([][]byte, []byte, error) {
tokensBytes, freshIDToken, _ := bytes.Cut(pktCom, []byte("."))
tokensParts := bytes.Split(tokensBytes, []byte(":"))
if freshIDToken != nil && len(bytes.Split(freshIDToken, []byte("."))) != 3 {
// Compact ID Token should be reformated as Base64(protected)"."Base64(payload)"."Base64(signature)
return nil, nil, fmt.Errorf("invalid refreshed ID Token")
}
// Compact PK Token with refreshed ID Token should have at least 3 parts and should be:
// Base64(payload)":"Base64(protected1)":"Base64(signature1)"...":"Base64(protectedN)":"Base64(signatureN)"
if len(tokensParts) < 3 || len(tokensParts)%2 != 1 {
return nil, nil, fmt.Errorf("invalid number of segments, got %d", len(tokensParts))
}
tokens := [][]byte{}
payload := tokensParts[0]
for i := 1; i < len(tokensParts); i += 2 {
// We return each token in JWT compact format (Base64(protected)"."Base64(payload)"."Base64(signature))
token := bytes.Join([][]byte{tokensParts[i], payload, tokensParts[i+1]}, []byte("."))
tokens = append(tokens, token)
}
return tokens, freshIDToken, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package pktoken
import (
"encoding/json"
"fmt"
)
type CosignerClaims struct {
Issuer string `json:"iss"`
KeyID string `json:"kid"`
Algorithm string `json:"alg"`
AuthID string `json:"eid"`
AuthTime int64 `json:"auth_time"`
IssuedAt int64 `json:"iat"` // may differ from auth_time because of refresh
Expiration int64 `json:"exp"`
RedirectURI string `json:"ruri"`
Nonce string `json:"nonce"`
Typ string `json:"typ"`
}
func (p *PKToken) ParseCosignerClaims() (*CosignerClaims, error) {
protected, err := json.Marshal(p.Cos.ProtectedHeaders())
if err != nil {
return nil, err
}
var claims CosignerClaims
if err := json.Unmarshal(protected, &claims); err != nil {
return nil, err
}
// Check that all fields are present
var missing []string
if claims.Issuer == "" {
missing = append(missing, `iss`)
}
if claims.KeyID == "" {
missing = append(missing, `kid`)
}
if claims.Algorithm == "" {
missing = append(missing, `alg`)
}
if claims.AuthID == "" {
missing = append(missing, `eid`)
}
if claims.AuthTime == 0 {
missing = append(missing, `auth_time`)
}
if claims.IssuedAt == 0 {
missing = append(missing, `iat`)
}
if claims.Expiration == 0 {
missing = append(missing, `exp`)
}
if claims.RedirectURI == "" {
missing = append(missing, `ruri`)
}
if claims.Nonce == "" {
missing = append(missing, `nonce`)
}
if len(missing) > 0 {
return nil, fmt.Errorf("cosigner protect header missing required headers: %v", missing)
}
return &claims, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"context"
"crypto"
"fmt"
"testing"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/providers"
"github.com/openpubkey/openpubkey/providers/mocks"
"github.com/openpubkey/openpubkey/util"
"github.com/stretchr/testify/require"
)
func GenerateMockPKToken(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm) (*pktoken.PKToken, error) {
options := &MockPKTokenOpts{
GQSign: false,
CommitType: providers.CommitTypesEnum.NONCE_CLAIM,
CorrectCicHash: true,
CorrectCicSig: true,
}
pkt, _, err := GenerateMockPKTokenWithOpts(t, signingKey, alg, mocks.DefaultIDTokenTemplate(), options)
return pkt, err
}
func GenerateMockPKTokenGQ(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm) (*pktoken.PKToken, error) {
options := &MockPKTokenOpts{
GQSign: true,
CommitType: providers.CommitTypesEnum.NONCE_CLAIM,
CorrectCicHash: true,
CorrectCicSig: true,
}
pkt, _, err := GenerateMockPKTokenWithOpts(t, signingKey, alg, mocks.DefaultIDTokenTemplate(), options)
return pkt, err
}
type MockPKTokenOpts struct {
GQSign bool
CommitType providers.CommitType
GQOnly bool
CorrectCicHash bool
CorrectCicSig bool
}
func GenerateMockPKTokenWithOpts(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm,
idtTemplate mocks.IDTokenTemplate, options *MockPKTokenOpts) (*pktoken.PKToken, *mocks.MockProviderBackend, error) {
jwkKey, err := jwk.PublicKeyOf(signingKey)
if err != nil {
return nil, nil, err
}
err = jwkKey.Set(jwk.AlgorithmKey, alg)
if err != nil {
return nil, nil, err
}
cic, err := clientinstance.NewClaims(jwkKey, map[string]any{})
require.NoError(t, err)
// Set gqOnly to gqCommitment since gqCommitment requires gqOnly
gqOnly := options.CommitType.GQCommitment
providerOpts := providers.MockProviderOpts{
GQSign: options.GQSign,
CommitType: options.CommitType,
NumKeys: 2,
VerifierOpts: providers.ProviderVerifierOpts{
SkipClientIDCheck: false,
GQOnly: gqOnly,
CommitType: options.CommitType,
ClientID: "mockClient-ID",
},
}
op, backend, _, err := providers.NewMockProvider(providerOpts)
require.NoError(t, err)
opSignKey, keyID, _ := backend.RandomSigningKey()
idtTemplate.KeyID = keyID
idtTemplate.SigningKey = opSignKey
switch options.CommitType {
case providers.CommitTypesEnum.NONCE_CLAIM:
idtTemplate.CommitFunc = mocks.AddNonceCommit
case providers.CommitTypesEnum.AUD_CLAIM:
idtTemplate.CommitFunc = mocks.AddAudCommit
case providers.CommitTypesEnum.GQ_BOUND:
default:
return nil, nil, fmt.Errorf("unknown CommitType: %v", options.CommitType)
}
backend.SetIDTokenTemplate(&idtTemplate)
tokens, err := op.RequestTokens(context.Background(), cic)
if err != nil {
return nil, nil, err
}
idToken := tokens.IDToken
// Return a PK Token where the CIC which doesn't match the commitment
if !options.CorrectCicHash {
// overwrite the cic with a new cic with a different hash
cic, err = clientinstance.NewClaims(jwkKey, map[string]any{"cause": "differentCicHash"})
if err != nil {
return nil, nil, err
}
}
// Return a PK Token where the CIC that is signed by the wrong key
if !options.CorrectCicSig {
// overwrite the signkey with a new key
signingKey, err = util.GenKeyPair(alg)
require.NoError(t, err)
jwkKey, err = jwk.PublicKeyOf(signingKey)
if err != nil {
return nil, nil, err
}
err = jwkKey.Set(jwk.AlgorithmKey, alg)
if err != nil {
return nil, nil, err
}
}
// Sign mock id token payload with cic headers
cicToken, err := cic.Sign(signingKey, jwkKey.Algorithm(), idToken)
if err != nil {
return nil, nil, err
}
// Combine two tokens into a PK Token
pkt, err := pktoken.New(idToken, cicToken)
if err != nil {
return nil, nil, err
}
return pkt, backend, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package pktoken
import (
"crypto"
"fmt"
"github.com/lestrrat-go/jwx/v2/jws"
)
// Options configures VerifySignedMessage behavior
type Options struct {
Typ string // Override for the expected typ value
}
type OptionFunc func(*Options)
// WithTyp sets a custom typ value for verification
func WithTyp(typ string) OptionFunc {
return func(o *Options) {
o.Typ = typ
}
}
// NewSignedMessage signs a message with the signer provided. The signed
// message is OSM (OpenPubkey Signed Message) which is a type of
// JWS (JSON Web Signature). OSMs commit to the PK Token which was used
// to generate the OSM.
func (p *PKToken) NewSignedMessage(content []byte, signer crypto.Signer) ([]byte, error) {
cic, err := p.GetCicValues()
if err != nil {
return nil, err
}
pktHash, err := p.Hash()
if err != nil {
return nil, err
}
// Create our headers as defined by section 3.5 of the OpenPubkey paper
protected := jws.NewHeaders()
if err := protected.Set("alg", cic.PublicKey().Algorithm()); err != nil {
return nil, err
}
if err := protected.Set("kid", pktHash); err != nil {
return nil, err
}
if err := protected.Set("typ", "osm"); err != nil {
return nil, err
}
return jws.Sign(
content,
jws.WithKey(
cic.PublicKey().Algorithm(),
signer,
jws.WithProtectedHeaders(protected),
),
)
}
// VerifySignedMessage verifies that an OSM (OpenPubkey Signed Message) using
// the public key in this PK Token. If verification is successful,
// VerifySignedMessage returns the content of the signed message. Otherwise
// it returns an error explaining why verification failed.
//
// Note: VerifySignedMessage does not check this the PK Token is valid.
// The PK Token should always be verified first before calling
// VerifySignedMessage
func (p *PKToken) VerifySignedMessage(osm []byte, options ...OptionFunc) ([]byte, error) {
// Default options
opts := Options{
Typ: "osm", // Default to "osm" for backward compatibility
}
// Apply provided options
for _, opt := range options {
opt(&opts)
}
cic, err := p.GetCicValues()
if err != nil {
return nil, err
}
message, err := jws.Parse(osm)
if err != nil {
return nil, err
}
// Check that our OSM headers are correct
if len(message.Signatures()) != 1 {
return nil, fmt.Errorf("expected only one signature on jwt, received %d", len(message.Signatures()))
}
protected := message.Signatures()[0].ProtectedHeaders()
// Verify typ header matches expected value from options
typ, ok := protected.Get("typ")
if !ok {
return nil, fmt.Errorf("missing required header `typ`")
}
if typ != opts.Typ {
return nil, fmt.Errorf(`incorrect "typ" header, expected %q but received %s`, opts.Typ, typ)
}
// Verify key algorithm header matches cic
if protected.Algorithm() != cic.PublicKey().Algorithm() {
return nil, fmt.Errorf(`incorrect "alg" header, expected %s but received %s`, cic.PublicKey().Algorithm(), protected.Algorithm())
}
// Verify kid header matches hash of pktoken
kid, ok := protected.Get("kid")
if !ok {
return nil, fmt.Errorf("missing required header `kid`")
}
pktHash, err := p.Hash()
if err != nil {
return nil, fmt.Errorf("unable to hash PK Token: %w", err)
}
if kid != string(pktHash) {
return nil, fmt.Errorf(`incorrect "kid" header, expected %s but received %s`, pktHash, kid)
}
_, err = jws.Verify(osm, jws.WithKey(cic.PublicKey().Algorithm(), cic.PublicKey()))
if err != nil {
return nil, err
}
// Return the osm payload
return message.Payload(), nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package pktoken
import (
"bytes"
"context"
"crypto"
"encoding/json"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/util"
_ "golang.org/x/crypto/sha3"
)
type SignatureType string
const (
OIDC SignatureType = "JWT"
CIC SignatureType = "CIC"
COS SignatureType = "COS"
)
type Signature = jws.Signature
type PKToken struct {
raw []byte // the original, raw representation of the object
Payload []byte // decoded payload
Op *Signature // Provider Signature
Cic *Signature // Client Signature
Cos *Signature // Cosigner Signature
// We keep the tokens around as unmarshalled values can no longer be verified
OpToken []byte // Compact encoded ID Token signed by the OP, i.e., Base64(Protected).Base64(Payload).Base64(Sig)
CicToken []byte // Compact encoded Token signed by the Client
CosToken []byte // Compact encoded Token signed by the Cosigner
// FreshIDToken is the refreshed ID Token. It has a different payload from
// other tokens and must be handled separately.
// It is only used for POP Authentication
FreshIDToken []byte // Compact encoded Refreshed ID Token
}
// New creates a new PKToken from an ID Token and a CIC Token.
// It adds signatures for both tokens to the PK Token and returns the PK Token.
func New(idToken []byte, cicToken []byte) (*PKToken, error) {
pkt := &PKToken{}
if err := pkt.AddSignature(idToken, OIDC); err != nil {
return nil, err
}
if err := pkt.AddSignature(cicToken, CIC); err != nil {
return nil, err
}
return pkt, nil
}
// NewFromCompact creates a PK Token from a compact representation
func NewFromCompact(pktCom []byte) (*PKToken, error) {
tokens, freshIDToken, err := SplitCompactPKToken(pktCom)
if err != nil {
return nil, err
}
pkt := &PKToken{}
for _, token := range tokens {
parsedToken, err := oidc.NewJwt(token)
if err != nil {
return nil, err
}
typ := parsedToken.GetSignature().GetProtectedClaims().Type
if typ == "" {
// missing typ claim, assuming this is from the OIDC provider and set typ=OIDC=JWT
// Okta is known not to set the typ parameter on their ID Tokens
// The JWT RFC-7519 encourages but does not require that typ be set saying about typ
// "This parameter is ignored by JWT implementations; any processing of this parameter is
// performed by the JWT application. If present, it is RECOMMENDED that its value be "JWT"
// to indicate that this object is a JWT."
// https://datatracker.ietf.org/doc/html/rfc7519#section-5.1
typ = string(OIDC)
}
sigType := SignatureType(typ)
if err := pkt.AddSignature(token, sigType); err != nil {
return nil, err
}
}
pkt.FreshIDToken = freshIDToken
return pkt, nil
}
// Issuer returns the issuer (`iss`) of the ID Token in the PKToken.
// It extracts the issuer from the PKToken payload and returns it as a string.
func (p *PKToken) Issuer() (string, error) {
var claims struct {
Issuer string `json:"iss"`
}
if err := json.Unmarshal(p.Payload, &claims); err != nil {
return "", fmt.Errorf("malformatted PK token claims: %w", err)
}
return claims.Issuer, nil
}
// Audience returns the audience (`aud`) of the ID Token in the PKToken.
// The audience is also known as the client ID.
func (p *PKToken) Audience() (string, error) {
var claims struct {
Audience string `json:"aud"`
}
if err := json.Unmarshal(p.Payload, &claims); err != nil {
return "", fmt.Errorf("malformatted PK token claims: %w", err)
}
return claims.Audience, nil
}
// Subject returns the subject (`sub`) of the ID Token in the PKToken.
// This is a unique identifier for the user at the OpenID Provider.
func (p *PKToken) Subject() (string, error) {
var claims struct {
Subject string `json:"sub"`
}
if err := json.Unmarshal(p.Payload, &claims); err != nil {
return "", fmt.Errorf("malformatted PK token claims: %w", err)
}
return claims.Subject, nil
}
// IdentityString string returns the three attributes that are used to uniquely identify a user
// in the OpenID Connect protocol: the subject, the issuer
func (p *PKToken) IdentityString() (string, error) {
sub, err := p.Subject()
if err != nil {
return "", err
}
iss, err := p.Issuer()
if err != nil {
return "", err
}
return fmt.Sprintf("%s %s", sub, iss), nil
}
// Signs PK Token and then returns only the payload, header and signature as a JWT
func (p *PKToken) SignToken(
signer crypto.Signer,
alg jwa.KeyAlgorithm,
protected map[string]any,
) ([]byte, error) {
headers := jws.NewHeaders()
for key, val := range protected {
if err := headers.Set(key, val); err != nil {
return nil, fmt.Errorf("malformatted headers: %w", err)
}
}
return jws.Sign(
p.Payload,
jws.WithKey(
alg,
signer,
jws.WithProtectedHeaders(headers),
),
)
}
// AddSignature will add a signature to the PKToken with the specified signature type.
// It takes a token byte slice and a signature type as input, and returns an error if the signature cannot be added.
//
// To use AddSignature, first parse the token byte slice using the jws.Parse function to obtain a jws.Message object.
// You can then extract the signature from the message object using the Signatures method, and pass it to AddSignature along with the desired signature type.
//
// The function supports three signature types: OIDC, CIC, and COS.
// These signature types correspond to the JWTs in the PK Token.
// Depending on the signature type, the function will set the corresponding field in the PKToken struct (Op, Cic, or Cos) to the provided signature.
// It will also set the corresponding token field (OpToken, CicToken, or CosToken) to the provided token byte slice.
//
// If the signature type is not recognized, an error will be returned.
func (p *PKToken) AddSignature(token []byte, sigType SignatureType) error {
message, err := jws.Parse(token)
if err != nil {
return err
}
// If there is no payload, we set the provided token's payload as current, otherwise
// we make sure that the new payload matches current
if p.Payload == nil {
p.Payload = message.Payload()
} else if !bytes.Equal(p.Payload, message.Payload()) {
return fmt.Errorf("payload in the GQ token (%s) does not match the existing payload in the PK Token (%s)", p.Payload, message.Payload())
}
signature := message.Signatures()[0]
if sigType == CIC || sigType == COS {
protected := signature.ProtectedHeaders()
if sigTypeFound, ok := protected.Get(jws.TypeKey); !ok {
return fmt.Errorf("required 'typ' claim not found in protected")
} else if sigTypeFoundStr, ok := sigTypeFound.(string); !ok {
return fmt.Errorf("'typ' claim in protected must be a string but was a %T", sigTypeFound)
} else if sigTypeFoundStr != string(sigType) {
return fmt.Errorf("incorrect 'typ' claim in protected, expected (%s), got (%s)", sigType, sigTypeFound)
}
}
switch sigType {
case OIDC:
p.Op = signature
p.OpToken = token
case CIC:
p.Cic = signature
p.CicToken = token
case COS:
p.Cos = signature
p.CosToken = token
default:
return fmt.Errorf("unrecognized signature type: %s", string(sigType))
}
return nil
}
func (p *PKToken) ProviderAlgorithm() (jwa.SignatureAlgorithm, bool) {
alg, ok := p.Op.ProtectedHeaders().Get(jws.AlgorithmKey)
if !ok {
return "", false
}
return alg.(jwa.SignatureAlgorithm), true
}
func (p *PKToken) GetCicValues() (*clientinstance.Claims, error) {
cicPH, err := p.Cic.ProtectedHeaders().AsMap(context.TODO())
if err != nil {
return nil, err
}
return clientinstance.ParseClaims(cicPH)
}
func (p *PKToken) Hash() (string, error) {
/*
We set the raw variable when unmarshalling from json (the only current string representation of a
PK Token) so when we hash we use the same representation that was given for consistency. When the
token being hashed is a new PK Token, we marshal it ourselves. This can introduce some issues based
on how different languages format their json strings.
*/
message := p.raw
var err error
if message == nil {
message, err = json.Marshal(p)
if err != nil {
return "", err
}
}
hash := util.B64SHA3_256(message)
return string(hash), nil
}
// Compact serializes a PK Token into a compact representation.
func (p *PKToken) Compact() ([]byte, error) {
tokens := [][]byte{}
if p.OpToken != nil {
tokens = append(tokens, p.OpToken)
}
if p.CicToken != nil {
tokens = append(tokens, p.CicToken)
}
if p.CosToken != nil {
tokens = append(tokens, p.CosToken)
}
return CompactPKToken(tokens, p.FreshIDToken)
}
func (p *PKToken) MarshalJSON() ([]byte, error) {
rawJws := oidc.Jws{
Payload: string(util.Base64EncodeForJWT(p.Payload)),
Signatures: []oidc.Signature{},
}
var opPublicHeader map[string]any
var err error
if p.Op.PublicHeaders() != nil {
if opPublicHeader, err = p.Op.PublicHeaders().AsMap(context.Background()); err != nil {
return nil, err
}
}
if err = rawJws.AddSignature(p.OpToken, oidc.WithPublicHeader(opPublicHeader)); err != nil {
return nil, err
}
if err = rawJws.AddSignature(p.CicToken); err != nil {
return nil, err
}
if p.CosToken != nil {
if err = rawJws.AddSignature(p.CosToken); err != nil {
return nil, err
}
}
return json.Marshal(rawJws)
}
func (p *PKToken) UnmarshalJSON(data []byte) error {
var rawJws oidc.Jws
if err := json.Unmarshal(data, &rawJws); err != nil {
return err
}
var parsed jws.Message
if err := json.Unmarshal(data, &parsed); err != nil {
return err
}
p.Payload = parsed.Payload() // base64 decoded
opCount := 0
cicCount := 0
cosCount := 0
for i, signature := range parsed.Signatures() {
// for some reason the unmarshaled signatures have empty non-nil
// public headers. set them to nil instead.
public := signature.PublicHeaders()
pubMap, _ := public.AsMap(context.Background())
if len(pubMap) == 0 {
signature.SetPublicHeaders(nil)
}
protected := signature.ProtectedHeaders()
var sigType SignatureType
typeHeader, ok := protected.Get(jws.TypeKey)
if ok {
sigTypeStr, ok := typeHeader.(string)
if !ok {
return fmt.Errorf(`provided "%s" is of wrong type, expected string`, jws.TypeKey)
}
sigType = SignatureType(sigTypeStr)
} else {
// missing typ claim, assuming this is from the OIDC provider
sigType = OIDC
}
switch sigType {
case OIDC:
opCount += 1
p.Op = signature
p.OpToken = []byte(rawJws.Signatures[i].Protected + "." + rawJws.Payload + "." + rawJws.Signatures[i].Signature)
case CIC:
cicCount += 1
p.Cic = signature
p.CicToken = []byte(rawJws.Signatures[i].Protected + "." + rawJws.Payload + "." + rawJws.Signatures[i].Signature)
case COS:
cosCount += 1
p.Cos = signature
p.CosToken = []byte(rawJws.Signatures[i].Protected + "." + rawJws.Payload + "." + rawJws.Signatures[i].Signature)
default:
return fmt.Errorf("unrecognized signature type: %s", sigType)
}
}
// Do some signature count verifications
if opCount == 0 {
return fmt.Errorf(`at least one signature of type "oidc" or "oidc_gq" is required`)
} else if opCount > 1 {
return fmt.Errorf(`only one signature of type "oidc" or "oidc_gq" is allowed, found %d`, opCount)
}
if cicCount == 0 {
return fmt.Errorf(`at least one signature of type "cic" is required`)
} else if cicCount > 1 {
return fmt.Errorf(`only one signature of type "cic" is allowed, found %d`, cicCount)
}
if cosCount > 1 {
return fmt.Errorf(`only one signature of type "cos" is allowed, found %d`, cosCount)
}
return nil
}
// DeepCopy creates a complete and independent copy of this PKToken,
func (p *PKToken) DeepCopy() (*PKToken, error) {
pktJson, err := p.MarshalJSON()
if err != nil {
return nil, err
}
var pktCopy PKToken
if err := json.Unmarshal(pktJson, &pktCopy); err != nil {
return nil, err
}
pktCopy.FreshIDToken = p.FreshIDToken
return &pktCopy, nil
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/openpubkey/openpubkey/discover"
)
// AzureOptions is an options struct that configures how providers.AzureOp
// operates. See providers.GetDefaultAzureOpOptions for the recommended default
// values to use when interacting with Azure as the OpenIdProvider.
type AzureOptions struct {
// ClientID is the client ID of the OIDC application. It should be the
// expected "aud" claim in received ID tokens from the OP.
ClientID string
// Issuer is the OP's issuer URI for performing OIDC authorization and
// discovery.
Issuer string
// Scopes is the list of scopes to send to the OP in the initial
// authorization request.
Scopes []string
// PromptType is the type of prompt to use when requesting authorization from the user. Typically
// this is set to "consent".
PromptType string
// AccessType is the type of access to request from the OP. Typically this is set to "offline".
AccessType string
// RedirectURIs is the list of authorized redirect URIs that can be
// redirected to by the OP after the user completes the authorization code
// flow exchange. Ensure that your OIDC application is configured to accept
// these URIs otherwise an error may occur.
RedirectURIs []string
// GQSign denotes if the received ID token should be upgraded to a GQ token
// using GQ signatures.
GQSign bool
// OpenBrowser denotes if the client's default browser should be opened
// automatically when performing the OIDC authorization flow. This value
// should typically be set to true, unless performing some headless
// automation (e.g. integration tests) where you don't want the browser to
// open.
OpenBrowser bool
// HttpClient is the http.Client to use when making queries to the OP (OIDC
// code exchange, refresh, verification of ID token, fetch of JWKS endpoint,
// etc.). If nil, then http.DefaultClient is used.
HttpClient *http.Client
// IssuedAtOffset configures the offset to add when validating the "iss" and
// "exp" claims of received ID tokens from the OP.
IssuedAtOffset time.Duration
// TenantID is the GUID of the Azure tenant/organization. Azure has a
// different issuer URI for each tenant. Users that are not part of Azure
// organization, which microsoft nicknames consumers have a default
// tenant ID of "9188040d-6c67-4c5b-b112-36a304b66dad"
// More details can be found at
// https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
TenantID string
}
func GetDefaultAzureOpOptions() *AzureOptions {
defaultTenantID := "9188040d-6c67-4c5b-b112-36a304b66dad"
return &AzureOptions{
Issuer: azureIssuer(defaultTenantID),
ClientID: "096ce0a3-5e72-4da8-9c86-12924b294a01",
// Scopes: []string{"openid profile email"},
Scopes: []string{"openid profile email offline_access"}, // offline_access is required for refresh tokens
PromptType: "consent",
AccessType: "offline",
RedirectURIs: []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
},
GQSign: false,
OpenBrowser: true,
HttpClient: nil,
IssuedAtOffset: 1 * time.Minute,
}
}
// NewAzureOp creates a Azure OP (OpenID Provider) using the
// default configurations options. It uses the OIDC Relying Party (Client)
// setup by the OpenPubkey project.
func NewAzureOp() BrowserOpenIdProvider {
options := GetDefaultAzureOpOptions()
return NewAzureOpWithOptions(options)
}
// NewAzureOpWithOptions creates a Azure OP with configuration specified
// using an options struct. This is useful if you want to use your own OIDC
// Client or override the configuration.
func NewAzureOpWithOptions(opts *AzureOptions) BrowserOpenIdProvider {
return &AzureOp{
StandardOp{
clientID: opts.ClientID,
Scopes: opts.Scopes,
PromptType: opts.PromptType,
AccessType: opts.AccessType,
RedirectURIs: opts.RedirectURIs,
GQSign: opts.GQSign,
OpenBrowser: opts.OpenBrowser,
HttpClient: opts.HttpClient,
IssuedAtOffset: opts.IssuedAtOffset,
issuer: opts.Issuer,
requestTokensOverrideFunc: nil,
publicKeyFinder: discover.PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient)
},
},
},
}
}
type AzureOp = StandardOpRefreshable
var _ OpenIdProvider = (*AzureOp)(nil)
var _ BrowserOpenIdProvider = (*AzureOp)(nil)
var _ RefreshableOpenIdProvider = (*AzureOp)(nil)
func azureIssuer(tenantID string) string {
return fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", tenantID)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/awnumar/memguard"
"github.com/openpubkey/openpubkey/discover"
simpleoidc "github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
)
const githubIssuer = "https://token.actions.githubusercontent.com"
type GithubOp struct {
issuer string // Change issuer to point this to a test issuer
rawTokenRequestURL string
tokenRequestAuthToken string
publicKeyFinder discover.PublicKeyFinder
requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error)
}
var _ OpenIdProvider = (*GithubOp)(nil)
func NewGithubOpFromEnvironment() (*GithubOp, error) {
tokenURL, err := getEnvVar("ACTIONS_ID_TOKEN_REQUEST_URL")
if err != nil {
return nil, err
}
token, err := getEnvVar("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
if err != nil {
return nil, err
}
return NewGithubOp(tokenURL, token), nil
}
func NewGithubOp(tokenURL string, token string) *GithubOp {
op := &GithubOp{
issuer: githubIssuer,
rawTokenRequestURL: tokenURL,
tokenRequestAuthToken: token,
publicKeyFinder: *discover.DefaultPubkeyFinder(),
requestTokensOverrideFunc: nil,
}
return op
}
func buildTokenURL(rawTokenURL, audience string) (string, error) {
parsedURL, err := url.Parse(rawTokenURL)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
if audience == "" {
return "", fmt.Errorf("audience is required")
}
query := parsedURL.Query()
query.Set("audience", audience)
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func (g *GithubOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) {
return g.publicKeyFinder.ByToken(ctx, g.issuer, token)
}
func (g *GithubOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) {
return g.publicKeyFinder.ByKeyID(ctx, g.issuer, keyID)
}
func (g *GithubOp) requestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error) {
if g.requestTokensOverrideFunc != nil {
tokens, err := g.requestTokensOverrideFunc(cicHash)
if err != nil {
return nil, fmt.Errorf("error requesting ID Token: %w", err)
}
return memguard.NewBufferFromBytes(tokens.IDToken), nil
}
tokenURL, err := buildTokenURL(g.rawTokenRequestURL, cicHash)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+g.tokenRequestAuthToken)
var httpClient http.Client
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("received non-200 from jwt api: %s", http.StatusText(response.StatusCode))
}
rawBody, err := memguard.NewBufferFromEntireReader(response.Body)
if err != nil {
return nil, err
}
defer rawBody.Destroy()
var jwt struct {
Value json.RawMessage
}
err = json.Unmarshal(rawBody.Bytes(), &jwt)
if err != nil {
return nil, err
}
defer memguard.WipeBytes([]byte(jwt.Value))
// json.RawMessage leaves the " (quotes) on the string. We need to remove the quotes
return memguard.NewBufferFromBytes(jwt.Value[1 : len(jwt.Value)-1]), nil
}
func (g *GithubOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) {
// Define our commitment as the hash of the client instance claims
commitment, err := cic.Hash()
if err != nil {
return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err)
}
// Use the commitment nonce to complete the OIDC flow and get an ID token from the provider
idTokenLB, err := g.requestTokens(ctx, string(commitment))
// idTokenLB is the ID Token in a memguard LockedBuffer, this is done
// because the ID Token contains the OPs RSA signature which is a secret
// in GQ signatures. For non-GQ signatures OPs RSA signature is considered
// a public value.
if err != nil {
return nil, fmt.Errorf("error requesting ID Token: %w", err)
}
defer idTokenLB.Destroy()
gqToken, err := CreateGQToken(ctx, idTokenLB.Bytes(), g)
return &simpleoidc.Tokens{IDToken: gqToken}, err
}
func (g *GithubOp) Issuer() string {
return g.issuer
}
func (g *GithubOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error {
vp := NewProviderVerifier(g.issuer, ProviderVerifierOpts{CommitType: CommitTypesEnum.AUD_CLAIM, GQOnly: true, SkipClientIDCheck: true})
return vp.VerifyIDToken(ctx, idt, cic)
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"net/http"
"time"
"github.com/openpubkey/openpubkey/discover"
)
type GitlabOptions struct {
// ClientID is the client ID of the OIDC application. It should be the
// expected "aud" claim in received ID tokens from the OP.
ClientID string
// ClientSecret is the client secret of the OIDC application. Some OPs do
// not require that this value is set.
ClientSecret string
// Issuer is the OP's issuer URI for performing OIDC authorization and
// discovery.
Issuer string
// Scopes is the list of scopes to send to the OP in the initial
// authorization request.
Scopes []string
// PromptType is the type of prompt to use when requesting authorization from the user. Typically
// this is set to "consent".
PromptType string
// AccessType is the type of access to request from the OP. Typically this is set to "offline".
AccessType string
// RedirectURIs is the list of authorized redirect URIs that can be
// redirected to by the OP after the user completes the authorization code
// flow exchange. Ensure that your OIDC application is configured to accept
// these URIs otherwise an error may occur.
RedirectURIs []string
// GQSign denotes if the received ID token should be upgraded to a GQ token
// using GQ signatures.
GQSign bool
// OpenBrowser denotes if the client's default browser should be opened
// automatically when performing the OIDC authorization flow. This value
// should typically be set to true, unless performing some headless
// automation (e.g. integration tests) where you don't want the browser to
// open.
OpenBrowser bool
// HttpClient is the http.Client to use when making queries to the OP (OIDC
// code exchange, refresh, verification of ID token, fetch of JWKS endpoint,
// etc.). If nil, then http.DefaultClient is used.
HttpClient *http.Client
// IssuedAtOffset configures the offset to add when validating the "iss" and
// "exp" claims of received ID tokens from the OP.
IssuedAtOffset time.Duration
}
func GetDefaultGitlabOpOptions() *GitlabOptions {
return &GitlabOptions{
ClientID: "8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923",
Issuer: gitlabIssuer,
Scopes: []string{"openid email"},
PromptType: "consent",
AccessType: "offline",
RedirectURIs: []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
},
GQSign: false,
OpenBrowser: true,
HttpClient: nil,
IssuedAtOffset: 1 * time.Minute,
}
}
func NewGitlabOpWithOptions(opts *GitlabOptions) BrowserOpenIdProvider {
return &GitlabOp{
StandardOp{
clientID: opts.ClientID,
Scopes: opts.Scopes,
PromptType: opts.PromptType,
AccessType: opts.AccessType,
RedirectURIs: opts.RedirectURIs,
GQSign: opts.GQSign,
OpenBrowser: opts.OpenBrowser,
HttpClient: opts.HttpClient,
IssuedAtOffset: opts.IssuedAtOffset,
issuer: opts.Issuer,
requestTokensOverrideFunc: nil,
publicKeyFinder: discover.PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient)
},
},
},
}
}
type GitlabOp = StandardOpRefreshable
var _ OpenIdProvider = (*GitlabOp)(nil)
var _ BrowserOpenIdProvider = (*GitlabOp)(nil)
var _ RefreshableOpenIdProvider = (*GitlabOp)(nil)
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"fmt"
"github.com/awnumar/memguard"
"github.com/openpubkey/openpubkey/discover"
simpleoidc "github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
)
const gitlabIssuer = "https://gitlab.com"
type GitlabCiOp struct {
issuer string // Change issuer to point this to a test issuer
publicKeyFinder discover.PublicKeyFinder
tokenEnvVar string
requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error)
}
func NewGitlabCiOpFromEnvironmentDefault() *GitlabCiOp {
return NewGitlabCiOpFromEnvironment("OPENPUBKEY_JWT")
}
func NewGitlabCiOpFromEnvironment(tokenEnvVar string) *GitlabCiOp {
return NewGitlabCiOp(gitlabIssuer, tokenEnvVar)
}
func NewGitlabCiOp(issuer string, tokenEnvVar string) *GitlabCiOp {
op := &GitlabCiOp{
issuer: issuer,
publicKeyFinder: *discover.DefaultPubkeyFinder(),
tokenEnvVar: tokenEnvVar,
requestTokensOverrideFunc: nil,
}
return op
}
func (g *GitlabCiOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) {
return g.publicKeyFinder.ByToken(ctx, g.issuer, token)
}
func (g *GitlabCiOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) {
return g.publicKeyFinder.ByKeyID(ctx, g.issuer, keyID)
}
func (g *GitlabCiOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) {
// Define our commitment as the hash of the client instance claims
cicHash, err := cic.Hash()
if err != nil {
return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err)
}
var idToken []byte
if g.requestTokensOverrideFunc != nil {
noCicHashInIDToken := ""
if tokens, err := g.requestTokensOverrideFunc(noCicHashInIDToken); err != nil {
return nil, fmt.Errorf("error requesting ID Token: %w", err)
} else {
idToken = tokens.IDToken
}
} else {
idTokenStr, err := getEnvVar(g.tokenEnvVar)
if err != nil {
return nil, fmt.Errorf("error requesting ID Token: %w", err)
}
idToken = []byte(idTokenStr)
}
// idTokenLB is the ID Token in a memguard LockedBuffer, this is done
// because the ID Token contains the OPs RSA signature which is a secret
// in GQ signatures. For non-GQ signatures OPs RSA signature is considered
// a public value.
idTokenLB := memguard.NewBufferFromBytes([]byte(idToken))
defer idTokenLB.Destroy()
gqToken, err := CreateGQBoundToken(ctx, idTokenLB.Bytes(), g, string(cicHash))
if err != nil {
return nil, err
}
return &simpleoidc.Tokens{IDToken: []byte(gqToken)}, nil
}
func (g *GitlabCiOp) Issuer() string {
return g.issuer
}
func (g *GitlabCiOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error {
vp := NewProviderVerifier(g.issuer,
ProviderVerifierOpts{CommitType: CommitTypesEnum.GQ_BOUND, GQOnly: true, SkipClientIDCheck: true},
)
return vp.VerifyIDToken(ctx, idt, cic)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"net/http"
"time"
"github.com/openpubkey/openpubkey/discover"
)
const googleIssuer = "https://accounts.google.com"
// GoogleOptions is an options struct that configures how providers.GoogleOp
// operates. See providers.GetDefaultGoogleOpOptions for the recommended default
// values to use when interacting with Google as the OpenIdProvider.
type GoogleOptions struct {
// ClientID is the client ID of the OIDC application. It should be the
// expected "aud" claim in received ID tokens from the OP.
ClientID string
// ClientSecret is the client secret of the OIDC application. Some OPs do
// not require that this value is set.
ClientSecret string
// Issuer is the OP's issuer URI for performing OIDC authorization and
// discovery.
Issuer string
// Scopes is the list of scopes to send to the OP in the initial
// authorization request.
Scopes []string
// PromptType is the type of prompt to use when requesting authorization from the user. Typically
// this is set to "consent".
PromptType string
// AccessType is the type of access to request from the OP. Typically this is set to "offline".
AccessType string
// RedirectURIs is the list of authorized redirect URIs that can be
// redirected to by the OP after the user completes the authorization code
// flow exchange. Ensure that your OIDC application is configured to accept
// these URIs otherwise an error may occur.
RedirectURIs []string
// GQSign denotes if the received ID token should be upgraded to a GQ token
// using GQ signatures.
GQSign bool
// OpenBrowser denotes if the client's default browser should be opened
// automatically when performing the OIDC authorization flow. This value
// should typically be set to true, unless performing some headless
// automation (e.g. integration tests) where you don't want the browser to
// open.
OpenBrowser bool
// HttpClient is the http.Client to use when making queries to the OP (OIDC
// code exchange, refresh, verification of ID token, fetch of JWKS endpoint,
// etc.). If nil, then http.DefaultClient is used.
HttpClient *http.Client
// IssuedAtOffset configures the offset to add when validating the "iss" and
// "exp" claims of received ID tokens from the OP.
IssuedAtOffset time.Duration
}
func GetDefaultGoogleOpOptions() *GoogleOptions {
return &GoogleOptions{
Issuer: googleIssuer,
ClientID: "206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com",
// The clientSecret was intentionally checked in. It holds no power. Do not report as a security issue
// Google requires a ClientSecret even if this a public OIDC App
ClientSecret: "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", // The client secret is a public value
Scopes: []string{"openid profile email"},
PromptType: "consent",
AccessType: "offline",
RedirectURIs: []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
},
GQSign: false,
OpenBrowser: true,
HttpClient: nil,
IssuedAtOffset: 1 * time.Minute,
}
}
// NewGoogleOp creates a Google OP (OpenID Provider) using the
// default configurations options. It uses the OIDC Relying Party (Client)
// setup by the OpenPubkey project.
func NewGoogleOp() BrowserOpenIdProvider {
options := GetDefaultGoogleOpOptions()
return NewGoogleOpWithOptions(options)
}
// NewGoogleOpWithOptions creates a Google OP with configuration specified
// using an options struct. This is useful if you want to use your own OIDC
// Client or override the configuration.
func NewGoogleOpWithOptions(opts *GoogleOptions) BrowserOpenIdProvider {
return &GoogleOp{
StandardOp{
clientID: opts.ClientID,
ClientSecret: opts.ClientSecret,
Scopes: opts.Scopes,
PromptType: opts.PromptType,
AccessType: opts.AccessType,
RedirectURIs: opts.RedirectURIs,
GQSign: opts.GQSign,
OpenBrowser: opts.OpenBrowser,
HttpClient: opts.HttpClient,
IssuedAtOffset: opts.IssuedAtOffset,
issuer: opts.Issuer,
requestTokensOverrideFunc: nil,
publicKeyFinder: discover.PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient)
},
},
},
}
}
type GoogleOp = StandardOpRefreshable
var _ OpenIdProvider = (*GoogleOp)(nil)
var _ BrowserOpenIdProvider = (*GoogleOp)(nil)
var _ RefreshableOpenIdProvider = (*GoogleOp)(nil)
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"crypto"
"crypto/rsa"
"encoding/json"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/gq"
"github.com/openpubkey/openpubkey/util"
)
func CreateGQToken(ctx context.Context, idToken []byte, op OpenIdProvider) ([]byte, error) {
return createGQTokenAllParams(ctx, idToken, op, "", false)
}
func CreateGQBoundToken(ctx context.Context, idToken []byte, op OpenIdProvider, cicHash string) ([]byte, error) {
return createGQTokenAllParams(ctx, idToken, op, cicHash, true)
}
func createGQTokenAllParams(ctx context.Context, idToken []byte, op OpenIdProvider, cicHash string, gqCommitment bool) ([]byte, error) {
if cicHash != "" && !gqCommitment {
// If gqCommitment is false, we will ignore the cicHash. This is a
// misconfiguration, and we should fail because the caller is likely
// expecting the cicHash to be included in the token.
return nil, fmt.Errorf("misconfiguration, cicHash is set but gqCommitment is false, set gqCommitment to true to include cicHash in the gq signature")
}
headersB64, _, _, err := jws.SplitCompact(idToken)
if err != nil {
return nil, fmt.Errorf("error splitting compact ID Token: %w", err)
}
// TODO: We should create a util function for extracting headers from tokens
headersJson, err := util.Base64DecodeForJWT(headersB64)
if err != nil {
return nil, fmt.Errorf("error base64 decoding ID Token headers: %w", err)
}
headers := jws.NewHeaders()
err = json.Unmarshal(headersJson, &headers)
if err != nil {
return nil, fmt.Errorf("error unmarshalling ID Token headers: %w", err)
}
if headers.Algorithm() != "RS256" {
return nil, fmt.Errorf("gq signatures require ID Token have signed with an RSA key, ID Token alg was (%s)", headers.Algorithm())
}
opKey, err := op.PublicKeyByToken(ctx, idToken)
if err != nil {
return nil, err
}
if opKey.Alg != "RS256" {
return nil, fmt.Errorf("gq signatures require original provider to have signed with an RSA key, jWK.alg was (%s)", opKey.Alg)
}
rsaKey, ok := opKey.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("gq signatures require original provider to have signed with an RSA key")
}
jktB64, err := createJkt(rsaKey)
if err != nil {
return nil, err
}
if cicHash == "" {
return gq.GQ256SignJWT(rsaKey, idToken, gq.WithExtraClaim("jkt", jktB64))
} else {
return gq.GQ256SignJWT(rsaKey, idToken, gq.WithExtraClaim("jkt", jktB64), gq.WithExtraClaim("cic", cicHash))
}
}
func createJkt(publicKey crypto.PublicKey) (string, error) {
jwkKey, err := jwk.PublicKeyOf(publicKey)
if err != nil {
return "", err
}
thumbprint, err := jwkKey.Thumbprint(crypto.SHA256)
if err != nil {
return "", err
}
return string(util.Base64EncodeForJWT(thumbprint)), nil
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"net/http"
"time"
"github.com/openpubkey/openpubkey/discover"
)
const helloIssuer = "https://issuer.hello.coop"
// HelloOptions is an options struct that configures how providers.HelloOp
// operates. See providers.GetDefaultGoogleOpOptions for the recommended default
// values to use when interacting with Google as the OpenIdProvider.
type HelloOptions struct {
// ClientID is the client ID of the OIDC application. It should be the
// expected "aud" claim in received ID tokens from the OP.
ClientID string
// Issuer is the OP's issuer URI for performing OIDC authorization and
// discovery.
Issuer string
// Scopes is the list of scopes to send to the OP in the initial
// authorization request.
Scopes []string
// PromptType is the type of prompt to use when requesting authorization from the user. Typically
// this is set to "consent".
PromptType string
// AccessType is the type of access to request from the OP. Typically this is set to "offline".
AccessType string
// RedirectURIs is the list of authorized redirect URIs that can be
// redirected to by the OP after the user completes the authorization code
// flow exchange. Ensure that your OIDC application is configured to accept
// these URIs otherwise an error may occur.
RedirectURIs []string
// GQSign denotes if the received ID token should be upgraded to a GQ token
// using GQ signatures.
GQSign bool
// OpenBrowser denotes if the client's default browser should be opened
// automatically when performing the OIDC authorization flow. This value
// should typically be set to true, unless performing some headless
// automation (e.g. integration tests) where you don't want the browser to
// open.
OpenBrowser bool
// HttpClient is the http.Client to use when making queries to the OP (OIDC
// code exchange, refresh, verification of ID token, fetch of JWKS endpoint,
// etc.). If nil, then http.DefaultClient is used.
HttpClient *http.Client
// IssuedAtOffset configures the offset to add when validating the "iss" and
// "exp" claims of received ID tokens from the OP.
IssuedAtOffset time.Duration
}
func GetDefaultHelloOpOptions() *HelloOptions {
return &HelloOptions{
Issuer: helloIssuer,
ClientID: "app_xejobTKEsDNSRd5vofKB2iay_2rN",
Scopes: []string{"openid profile email"},
PromptType: "consent",
AccessType: "offline",
RedirectURIs: []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
},
GQSign: false,
OpenBrowser: true,
HttpClient: nil,
IssuedAtOffset: 1 * time.Minute,
}
}
// NewHelloOp creates a Google OP (OpenID Provider) using the
// default configurations options. It uses the OIDC Relying Party (Client)
// setup by the OpenPubkey project.
func NewHelloOp() BrowserOpenIdProvider {
options := GetDefaultHelloOpOptions()
return NewHelloOpWithOptions(options)
}
// NewHelloOpWithOptions creates a Hello OP with configuration specified
// using an options struct. This is useful if you want to use your own OIDC
// Client or override the configuration.
func NewHelloOpWithOptions(opts *HelloOptions) BrowserOpenIdProvider {
return &HelloOp{
clientID: opts.ClientID,
Scopes: opts.Scopes,
RedirectURIs: opts.RedirectURIs,
PromptType: opts.PromptType,
AccessType: opts.AccessType,
GQSign: opts.GQSign,
OpenBrowser: opts.OpenBrowser,
HttpClient: opts.HttpClient,
IssuedAtOffset: opts.IssuedAtOffset,
issuer: opts.Issuer,
requestTokensOverrideFunc: nil,
publicKeyFinder: discover.PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient)
},
},
}
}
type HelloOp = StandardOp
var _ OpenIdProvider = (*HelloOp)(nil)
var _ BrowserOpenIdProvider = (*HelloOp)(nil)
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/discover"
simpleoidc "github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/providers/mocks"
)
const mockProviderIssuer = "https://accounts.example.com"
var _ OpenIdProvider = (*MockProvider)(nil)
type MockProviderOpts struct {
Issuer string
Alg string
ClientID string
GQSign bool
NumKeys int
CommitType CommitType
// We keep VerifierOpts as a variable separate to let us test failures
// where the mock op does something which causes a verification failure
VerifierOpts ProviderVerifierOpts
}
func DefaultMockProviderOpts() MockProviderOpts {
clientID := "test_client_id"
return MockProviderOpts{
Issuer: "https://accounts.example.com",
Alg: "RS256",
ClientID: clientID,
GQSign: false,
NumKeys: 2,
CommitType: CommitTypesEnum.NONCE_CLAIM,
VerifierOpts: ProviderVerifierOpts{
CommitType: CommitTypesEnum.NONCE_CLAIM,
ClientID: clientID,
SkipClientIDCheck: false,
GQOnly: false,
},
}
}
type MockProvider struct {
options MockProviderOpts
issuer string
clientID string
publicKeyFinder discover.PublicKeyFinder
requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error)
}
// NewMockProvider creates a new mock provider with a random signing key and a random key ID. It returns the provider,
// the mock backend, and the ID token template. Tests can use the mock backend to look up keys issued by the mock provider.
// Tests can use the ID token template to create ID tokens and test the provider's behavior when verifying incorrectly set ID Tokens.
func NewMockProvider(opts MockProviderOpts) (*MockProvider, *mocks.MockProviderBackend, *mocks.IDTokenTemplate, error) {
if opts.Issuer == "" {
opts.Issuer = mockProviderIssuer
}
if opts.Alg == "" {
opts.Alg = "RS256"
}
mockBackend, err := mocks.NewMockProviderBackend(opts.Issuer, opts.Alg, opts.NumKeys)
if err != nil {
return nil, nil, nil, err
}
provider := &MockProvider{
options: opts,
issuer: mockBackend.Issuer,
clientID: opts.ClientID,
requestTokensOverrideFunc: mockBackend.RequestTokensOverrideFunc,
publicKeyFinder: mockBackend.PublicKeyFinder,
}
providerSigner, keyID, record := mockBackend.RandomSigningKey()
commitmentFunc := mocks.NoClaimCommit
if opts.CommitType.Claim == "nonce" {
commitmentFunc = mocks.AddNonceCommit
} else if opts.CommitType.Claim == "aud" {
commitmentFunc = mocks.AddAudCommit
}
idTokenTemplate := &mocks.IDTokenTemplate{
CommitFunc: commitmentFunc,
Issuer: provider.Issuer(),
Nonce: "empty",
NoNonce: false,
Aud: opts.ClientID,
KeyID: keyID,
NoKeyID: false,
Alg: record.Alg,
NoAlg: false,
SigningKey: providerSigner,
}
if opts.CommitType.GQCommitment {
idTokenTemplate.Aud = AudPrefixForGQCommitment
}
mockBackend.SetIDTokenTemplate(idTokenTemplate)
return provider, mockBackend, idTokenTemplate, nil
}
func (m *MockProvider) requestTokens(_ context.Context, cicHash string) (*simpleoidc.Tokens, error) {
return m.requestTokensOverrideFunc(cicHash)
}
func (m *MockProvider) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) {
if m.options.CommitType.GQCommitment && !m.options.GQSign {
// Catch misconfigurations in tests
return nil, fmt.Errorf("if GQCommitment is true then GQSign must also be true")
}
// Define our commitment as the hash of the client instance claims
cicHash, err := cic.Hash()
if err != nil {
return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err)
}
tokens, err := m.requestTokens(ctx, string(cicHash))
if err != nil {
return nil, err
}
if m.options.CommitType.GQCommitment {
if tokens.IDToken, err = CreateGQBoundToken(ctx, tokens.IDToken, m, string(cicHash)); err != nil {
return nil, err
}
} else if m.options.GQSign {
if tokens.IDToken, err = CreateGQToken(ctx, tokens.IDToken, m); err != nil {
return nil, err
}
}
return tokens, nil
}
func (m *MockProvider) RefreshTokens(ctx context.Context, _ []byte) (*simpleoidc.Tokens, error) {
tokens, err := m.requestTokensOverrideFunc("")
if err != nil {
return nil, err
}
return tokens, nil
}
func (m *MockProvider) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) {
return m.publicKeyFinder.ByToken(ctx, m.issuer, token)
}
func (m *MockProvider) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) {
return m.publicKeyFinder.ByKeyID(ctx, m.issuer, keyID)
}
func (m *MockProvider) Issuer() string {
return m.issuer
}
func (m *MockProvider) ClientID() string {
return m.clientID
}
func (m *MockProvider) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error {
m.options.VerifierOpts.DiscoverPublicKey = &m.publicKeyFinder //TODO: this should be set in the constructor once we have constructors for each OP
return NewProviderVerifier(m.Issuer(), m.options.VerifierOpts).VerifyIDToken(ctx, idt, cic)
}
func (m *MockProvider) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error {
if err := simpleoidc.SameIdentity(origIdt, reIdt); err != nil {
return fmt.Errorf("refreshed ID Token is for different subject than original ID Token: %w", err)
}
if err := simpleoidc.RequireOlder(origIdt, reIdt); err != nil {
return fmt.Errorf("refreshed ID Token should not be issued before original ID Token: %w", err)
}
pkr, err := m.publicKeyFinder.ByToken(ctx, m.Issuer(), reIdt)
if err != nil {
return err
}
alg := jwa.SignatureAlgorithm(pkr.Alg)
if _, err := jws.Verify(reIdt, jws.WithKey(alg, pkr.PublicKey)); err != nil {
return err
}
return nil
}
// Mock provider that does not support refresh
type NonRefreshableOp struct {
op *MockProvider
}
func NewNonRefreshableOp(op *MockProvider) *NonRefreshableOp {
return &NonRefreshableOp{op: op}
}
func (nro *NonRefreshableOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) {
tokens, err := nro.op.RequestTokens(ctx, cic)
return &simpleoidc.Tokens{IDToken: tokens.IDToken}, err
}
func (nro *NonRefreshableOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) {
return nro.op.PublicKeyByKeyId(ctx, keyID)
}
func (nro *NonRefreshableOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) {
return nro.op.PublicKeyByToken(ctx, token)
}
func (nro *NonRefreshableOp) Issuer() string {
return nro.op.Issuer()
}
func (nro *NonRefreshableOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error {
return nro.op.VerifyIDToken(ctx, idt, cic)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
mathrand "math/rand"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/openpubkey/openpubkey/discover"
"github.com/openpubkey/openpubkey/oidc"
"golang.org/x/exp/maps"
)
type MockProviderBackend struct {
Issuer string
PublicKeyFinder discover.PublicKeyFinder
ProviderSigningKeySet map[string]crypto.Signer // kid (keyId) -> signing key
ProviderPublicKeySet map[string]discover.PublicKeyRecord // kid (keyId) -> PublicKeyRecord
IDTokensTemplate *IDTokenTemplate
}
func NewMockProviderBackend(issuer string, alg string, numKeys int) (*MockProviderBackend, error) {
var providerSigningKeySet map[string]crypto.Signer
var providerPublicKeySet map[string]discover.PublicKeyRecord
var err error
if alg == "RS256" {
if providerSigningKeySet, providerPublicKeySet, err = CreateRS256KeySet(issuer, numKeys); err != nil {
return nil, err
}
} else if alg == "ES256" {
if providerSigningKeySet, providerPublicKeySet, err = CreateES256KeySet(issuer, numKeys); err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("unsupported provider alg: %s", alg)
}
return &MockProviderBackend{
Issuer: issuer,
PublicKeyFinder: discover.PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
keySet := jwk.NewSet()
for kid, record := range providerPublicKeySet {
jwkKey, err := jwk.PublicKeyOf(record.PublicKey)
if err != nil {
return nil, err
}
if err := jwkKey.Set(jwk.AlgorithmKey, record.Alg); err != nil {
return nil, err
}
if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil {
return nil, err
}
// Put our jwk into a set
if err := keySet.AddKey(jwkKey); err != nil {
return nil, err
}
}
return json.MarshalIndent(keySet, "", " ")
},
},
ProviderSigningKeySet: providerSigningKeySet,
ProviderPublicKeySet: providerPublicKeySet,
}, nil
}
func (o *MockProviderBackend) GetPublicKeyFinder() *discover.PublicKeyFinder {
return &o.PublicKeyFinder
}
func (o *MockProviderBackend) GetProviderPublicKeySet() map[string]discover.PublicKeyRecord {
return o.ProviderPublicKeySet
}
func (o *MockProviderBackend) GetProviderSigningKeySet() map[string]crypto.Signer {
return o.ProviderSigningKeySet
}
func (o *MockProviderBackend) SetIDTokenTemplate(template *IDTokenTemplate) {
o.IDTokensTemplate = template
}
func (o *MockProviderBackend) RequestTokensOverrideFunc(cicHash string) (*oidc.Tokens, error) {
o.IDTokensTemplate.AddCommit(cicHash)
return o.IDTokensTemplate.IssueTokens()
}
func (o *MockProviderBackend) RandomSigningKey() (crypto.Signer, string, discover.PublicKeyRecord) {
keyIDs := maps.Keys(o.GetProviderPublicKeySet())
keyID := keyIDs[mathrand.Intn(len(keyIDs))]
return o.GetProviderSigningKeySet()[keyID], keyID, o.GetProviderPublicKeySet()[keyID]
}
func CreateRS256KeySet(issuer string, numKeys int) (map[string]crypto.Signer, map[string]discover.PublicKeyRecord, error) {
return CreateKeySet(issuer, "RS256", numKeys)
}
func CreateES256KeySet(issuer string, numKeys int) (map[string]crypto.Signer, map[string]discover.PublicKeyRecord, error) {
return CreateKeySet(issuer, "ES256", numKeys)
}
func CreateKeySet(issuer string, alg string, numKeys int) (map[string]crypto.Signer, map[string]discover.PublicKeyRecord, error) {
providerSigningKeySet := map[string]crypto.Signer{}
providerPublicKeySet := map[string]discover.PublicKeyRecord{}
for i := 0; i < numKeys; i++ {
kid := fmt.Sprintf("kid-%d", i)
var signingKey crypto.Signer
var err error
switch alg {
case "ES256":
if signingKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
return nil, nil, err
}
case "RS256":
if signingKey, err = rsa.GenerateKey(rand.Reader, 2048); err != nil {
return nil, nil, err
}
default:
return nil, nil, fmt.Errorf("unsupported alg: %s", alg)
}
providerSigningKeySet[string(kid)] = signingKey
providerPublicKeySet[string(kid)] = discover.PublicKeyRecord{
PublicKey: signingKey.Public(),
Alg: alg,
Issuer: issuer,
}
}
return providerSigningKeySet, providerPublicKeySet, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"encoding/json"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/oidc"
)
type CommitmentType struct {
ClaimCommitment bool
ClaimName string
}
type IDTokenTemplate struct {
CommitFunc func(*IDTokenTemplate, string)
Issuer string
Nonce string
NoNonce bool
Aud string
KeyID string
NoKeyID bool
Alg string
NoAlg bool // Even if NOAlg is true, we still need Alg to be set to generate the signature
ExtraClaims map[string]any
ExtraProtectedClaims map[string]any
SigningKey crypto.Signer // The key we will use to sign the ID Token
}
func DefaultIDTokenTemplate() IDTokenTemplate {
return IDTokenTemplate{
CommitFunc: AddAudCommit,
Issuer: "mockIssuer",
Nonce: "empty",
NoNonce: false,
Aud: "empty",
KeyID: "mockKeyID",
NoKeyID: false,
Alg: "RS256",
NoAlg: false,
}
}
// AddCommit adds the commitment to the CIC to the ID Token. The
// CommitmentFunc is specified allowing custom commitment functions to be specified
func (t *IDTokenTemplate) AddCommit(cicHash string) {
t.CommitFunc(t, cicHash)
}
func (t *IDTokenTemplate) IssueTokens() (*oidc.Tokens, error) {
headers := jws.NewHeaders()
if !t.NoAlg {
if err := headers.Set(jws.AlgorithmKey, t.Alg); err != nil {
return nil, err
}
}
if !t.NoKeyID {
if err := headers.Set(jws.KeyIDKey, t.KeyID); err != nil {
return nil, err
}
}
if err := headers.Set(jws.TypeKey, "JWT"); err != nil {
return nil, err
}
if t.ExtraProtectedClaims != nil {
for k, v := range t.ExtraProtectedClaims {
if err := headers.Set(k, v); err != nil {
return nil, err
}
}
}
payloadMap := map[string]any{
"sub": "me",
"aud": t.Aud,
"iss": t.Issuer,
"iat": time.Now().Unix(),
"exp": time.Now().Add(2 * time.Hour).Unix(),
}
if !t.NoNonce {
payloadMap["nonce"] = t.Nonce
}
if t.ExtraClaims != nil {
for k, v := range t.ExtraClaims {
payloadMap[k] = v
}
}
payloadBytes, err := json.Marshal(payloadMap)
if err != nil {
return nil, err
}
var providerAlg jwa.KeyAlgorithm
if _, ok := t.SigningKey.Public().(*rsa.PublicKey); ok {
providerAlg = jwa.RS256
} else if _, ok := t.SigningKey.Public().(*ecdsa.PublicKey); ok {
providerAlg = jwa.ES256
} else {
return nil, fmt.Errorf("unsupported public key type")
}
if jwa.KeyAlgorithmFrom(t.Alg) != providerAlg {
return nil, fmt.Errorf("alg in template (%s) does not match providers signing key alg (%s)", t.Alg, providerAlg)
}
idToken, err := jws.Sign(
payloadBytes,
jws.WithKey(
providerAlg,
t.SigningKey,
jws.WithProtectedHeaders(headers),
),
)
if err != nil {
return nil, err
}
return &oidc.Tokens{
IDToken: idToken,
RefreshToken: []byte("mock-refresh-token"),
AccessToken: []byte("mock-access-token")}, nil
}
func AddNonceCommit(idtTemp *IDTokenTemplate, cicHash string) {
idtTemp.Nonce = cicHash
idtTemp.NoNonce = false
}
func AddAudCommit(idtTemp *IDTokenTemplate, cicHash string) {
idtTemp.Aud = cicHash
}
func NoClaimCommit(idtTemp *IDTokenTemplate, cicHash string) {
// Do nothing
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package mocks
import (
"fmt"
"io"
"net/http"
"strings"
)
const googleWellknownResponse = `{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"claims_supported": [
"aud",
"email",
"email_verified",
"exp",
"family_name",
"given_name",
"iat",
"iss",
"name",
"picture",
"sub"
],
"code_challenge_methods_supported": [
"plain",
"S256"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer"
]
}`
const googleCorruptedResponse = `{
"issuer": "https://accounts.go
}`
func NewMockGoogleUserInfoHTTPClient(userInfoResponse, requiredToken string) *http.Client {
return NewMockUserInfoClient(
"https://accounts.google.com/.well-known/openid-configuration",
"https://openidconnect.googleapis.com/v1/userinfo",
googleWellknownResponse,
userInfoResponse,
requiredToken,
)
}
func NewMockBrokenHTTPClient(userInfoResponse, requiredToken string, retErr error) *http.Client {
return &http.Client{
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, retErr
}),
}
}
func NewMockGoogleUserInfoHTTPClientCorruptedJson(userInfoResponse, requiredToken string) *http.Client {
return NewMockUserInfoClient(
"https://accounts.google.com/.well-known/openid-configuration",
"https://openidconnect.googleapis.com/v1/userinfo",
googleCorruptedResponse,
userInfoResponse,
requiredToken,
)
}
func NewMockUserInfoClient(wellKnownUri string, userInfoUri string, wellknownResponse string, userInfoResponse string, requiredToken string) *http.Client {
return &http.Client{
Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet && strings.HasPrefix(req.URL.String(), wellKnownUri) {
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(wellknownResponse)),
}, nil
}
if req.Method == http.MethodGet && req.URL.String() == userInfoUri {
if req.Header.Get("Authorization") != "Bearer "+requiredToken {
return nil, fmt.Errorf("invalid access token")
}
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(userInfoResponse)),
}, nil
}
return nil, fmt.Errorf("unexpected HTTP call to %s %s", req.Method, req.URL)
}),
}
}
type RoundTripFunc func(req *http.Request) (*http.Response, error)
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"fmt"
"net/http"
"os"
"github.com/openpubkey/openpubkey/discover"
simpleoidc "github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
)
// Interface for interacting with the OP (OpenID Provider) that only returns
// an ID Token
type OpenIdProvider interface {
RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error)
PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error)
PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error)
// Returns the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com"
Issuer() string
VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error
}
type BrowserOpenIdProvider interface {
OpenIdProvider
ClientID() string
HookHTTPSession(h http.HandlerFunc)
ReuseBrowserWindowHook(chan string)
}
// Interface for an OpenIdProvider that returns an ID Token, Refresh Token and Access Token
type RefreshableOpenIdProvider interface {
OpenIdProvider
RefreshTokens(ctx context.Context, refreshToken []byte) (*simpleoidc.Tokens, error)
VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error
}
type CommitType struct {
Claim string
GQCommitment bool
}
var CommitTypesEnum = struct {
NONCE_CLAIM CommitType
AUD_CLAIM CommitType
GQ_BOUND CommitType
}{
NONCE_CLAIM: CommitType{Claim: "nonce", GQCommitment: false},
AUD_CLAIM: CommitType{Claim: "aud", GQCommitment: false},
GQ_BOUND: CommitType{Claim: "", GQCommitment: true}, // The commitmentClaim is bound to the ID Token using only the GQ signature
}
func getEnvVar(name string) (string, error) {
value, ok := os.LookupEnv(name)
if !ok {
return "", fmt.Errorf("%q environment variable not set", name)
}
return value, nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"encoding/json"
"fmt"
"strings"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/discover"
"github.com/openpubkey/openpubkey/gq"
"github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/util"
)
const AudPrefixForGQCommitment = "OPENPUBKEY-PKTOKEN:"
type DefaultProviderVerifier struct {
issuer string
commitType CommitType
options ProviderVerifierOpts
}
type ProviderVerifierOpts struct {
// If ClientID is specified, then verification will require that the ClientID
// be present in the audience ("aud") claim of the PK token payload
ClientID string
// Describes the place where the cicHash is committed to in the the ID token.
// For instance the nonce payload claim name where the cicHash was stored during issuance
CommitType CommitType
// Specifies whether to skip the Client ID check, defaults to false
SkipClientIDCheck bool
// Custom function for discovering public key of Provider
DiscoverPublicKey *discover.PublicKeyFinder
// Only allows GQ signatures, a provider signature under any other algorithm
// is seen as an error
GQOnly bool
}
// Creates a new ProviderVerifier with required fields
//
// issuer: Is the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com"
// commitmentClaim: the ID token payload claim name where the cicHash was stored during issuance
func NewProviderVerifier(issuer string, options ProviderVerifierOpts) *DefaultProviderVerifier {
v := &DefaultProviderVerifier{
issuer: issuer,
commitType: options.CommitType,
options: options,
}
// If no custom DiscoverPublicKey function is set, set default
if v.options.DiscoverPublicKey == nil {
v.options.DiscoverPublicKey = discover.DefaultPubkeyFinder()
}
return v
}
func (v *DefaultProviderVerifier) Issuer() string {
return v.issuer
}
func (v *DefaultProviderVerifier) VerifyIDToken(ctx context.Context, idToken []byte, cic *clientinstance.Claims) error {
// Sanity check that if GQCommitment is enabled then the other options
// are set correctly for doing GQ commitment verification. The intention is
// to catch misconfigurations early and provide meaningful error messages.
if v.options.CommitType.GQCommitment {
if !v.options.GQOnly {
return fmt.Errorf("GQCommitment requires that GQOnly is true, but GQOnly is (%t)", v.options.GQOnly)
}
if v.commitType.Claim != "" {
return fmt.Errorf("GQCommitment requires that commitmentClaim is empty but commitmentClaim is (%s)", v.commitType.Claim)
}
if !v.options.SkipClientIDCheck {
// When we bind the commitment to the ID Token using GQ Signatures,
// We require that the audience is prefixed with
// "OPENPUBKEY-PKTOKEN:". Thus, the audience can't be the client-id
// If you are hitting this error of set SkipClientIDCheck to true
return fmt.Errorf("GQCommitment requires that audience (aud) is not set to client-id")
}
}
idt, err := oidc.NewJwt(idToken)
if err != nil {
return err
}
// Check whether Audience claim matches provided Client ID
// No error is thrown if option is set to skip client ID check
if err := verifyAudience(idt, v.options.ClientID); err != nil && !v.options.SkipClientIDCheck {
return err
}
algStr := idt.GetSignature().GetProtectedClaims().Alg
if algStr == "" {
return fmt.Errorf("provider algorithm type missing")
}
alg := jwa.SignatureAlgorithm(algStr)
if alg != gq.GQ256 && v.options.GQOnly {
return fmt.Errorf("non-GQ signatures are not supported")
}
switch alg {
case gq.GQ256:
if err := v.verifyGQSig(ctx, idt); err != nil {
return fmt.Errorf("error verifying OP GQ signature on PK Token: %w", err)
}
case jwa.RS256:
pubKeyRecord, err := v.providerPublicKey(ctx, idToken)
if err != nil {
return fmt.Errorf("failed to get OP public key: %w", err)
}
// Ensure that the algorithm of public key from OpenID Provider matches the algorithm specified in the ID Token
if _, ok := pubKeyRecord.PublicKey.(*rsa.PublicKey); !ok {
return fmt.Errorf("public key is not an RSA public key")
}
if _, err := jws.Verify(idToken, jws.WithKey(alg, pubKeyRecord.PublicKey)); err != nil {
return err
}
case jwa.ES256:
pubKeyRecord, err := v.providerPublicKey(ctx, idToken)
if err != nil {
return fmt.Errorf("failed to get OP public key: %w", err)
}
// Ensure that the algorithm of public key from OpenID Provider matches the algorithm specified in the ID Token
if _, ok := pubKeyRecord.PublicKey.(*ecdsa.PublicKey); !ok {
return fmt.Errorf("public key is not an ECDSA public key")
}
if _, err := jws.Verify(idToken, jws.WithKey(alg, pubKeyRecord.PublicKey)); err != nil {
return err
}
default:
return fmt.Errorf("unsupported signature algorithm %s", alg)
}
if err := v.verifyCommitment(idt, cic); err != nil {
return err
}
return nil
}
// This function takes in an OIDC Provider created ID token or GQ-signed modification of one and returns
// the associated public key
func (v *DefaultProviderVerifier) providerPublicKey(ctx context.Context, idToken []byte) (*discover.PublicKeyRecord, error) {
return v.options.DiscoverPublicKey.ByToken(ctx, v.Issuer(), idToken)
}
func (v *DefaultProviderVerifier) verifyCommitment(idt *oidc.Jwt, cic *clientinstance.Claims) error {
var claims map[string]any
payload, err := util.Base64DecodeForJWT([]byte(idt.GetPayload()))
if err != nil {
return err
}
if err := json.Unmarshal(payload, &claims); err != nil {
return err
}
expectedCommitment, err := cic.Hash()
if err != nil {
return err
}
var commitment any
var commitmentFound bool
if v.options.CommitType.GQCommitment {
aud, ok := claims["aud"]
if !ok {
return fmt.Errorf("require audience claim prefix missing in PK Token's GQCommitment")
}
// To prevent attacks where a attacker takes someone else's ID Token
// and turns it into a PK Token using a GQCommitment, we require that
// all GQ commitments explicitly signal they want to be used as
// PK Tokens. To signal this, they prefix the audience (aud)
// claim with the string "OPENPUBKEY-PKTOKEN:".
// We reject all GQ commitment PK Tokens that don't have this prefix
// in the aud claim.
if _, ok := strings.CutPrefix(aud.(string), AudPrefixForGQCommitment); !ok {
return fmt.Errorf("audience claim in PK Token's GQCommitment must be prefixed by (%s), got (%s) instead",
AudPrefixForGQCommitment, aud.(string))
}
// Get the commitment from the GQ signed protected header claim "cic" in the ID Token
commitment = idt.GetSignature().GetProtectedClaims().CIC
if commitment == "" {
return fmt.Errorf("missing GQ commitment")
}
} else {
if v.commitType.Claim == "" {
return fmt.Errorf("verifier configured with empty commitment claim")
}
commitment, commitmentFound = claims[v.commitType.Claim]
if !commitmentFound {
return fmt.Errorf("missing commitment claim %s", v.commitType.Claim)
}
}
if commitment != string(expectedCommitment) {
return fmt.Errorf("commitment claim doesn't match, got %q, expected %s", commitment, string(expectedCommitment))
}
return nil
}
// verifyGQSig verifies the signature of a PK token with a GQ signature. The
// parameter issuer should be the issuer of the ProviderVerifier not the
// issuer of the PK Token
func (v *DefaultProviderVerifier) verifyGQSig(ctx context.Context, idt *oidc.Jwt) error {
algStr := idt.GetSignature().GetProtectedClaims().Alg
if algStr == "" {
return fmt.Errorf("missing provider algorithm header")
}
if algStr != gq.GQ256.String() {
return fmt.Errorf("signature is not of type GQ")
}
origHeaders, err := originalTokenHeaders(idt.GetRaw())
if err != nil {
return fmt.Errorf("malformed ID Token headers: %w", err)
}
origAlg := origHeaders.Algorithm()
if origAlg != jwa.RS256 {
return fmt.Errorf("expected original headers to contain RS256 alg, got %s", origAlg)
}
if idt.GetClaims().Issuer == "" {
return fmt.Errorf("missing issuer in payload: %s", idt.GetPayload())
}
if idt.GetClaims().Issuer != v.issuer {
return fmt.Errorf("issuer of ID Token (%s) doesn't match expected issuer (%s)", idt.GetClaims().Issuer, v.issuer)
}
publicKeyRecord, err := v.options.DiscoverPublicKey.ByToken(ctx, v.Issuer(), idt.GetRaw())
if err != nil {
return fmt.Errorf("failed to get provider public key: %w", err)
}
rsaKey, ok := publicKeyRecord.PublicKey.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("jwk is not an RSA key")
}
ok, err = gq.GQ256VerifyJWT(rsaKey, idt.GetRaw())
if err != nil {
return err
}
if !ok {
return fmt.Errorf("error verifying OP GQ signature on PK Token (ID Token invalid)")
}
return nil
}
func originalTokenHeaders(token []byte) (jws.Headers, error) {
origHeadersB64, err := gq.OriginalJWTHeaders(token)
if err != nil {
return nil, fmt.Errorf("malformatted PK token headers: %w", err)
}
origHeaders, err := util.Base64DecodeForJWT(origHeadersB64)
if err != nil {
return nil, fmt.Errorf("error decoding original token headers: %w", err)
}
headers := jws.NewHeaders()
err = json.Unmarshal(origHeaders, &headers)
if err != nil {
return nil, fmt.Errorf("error parsing segment: %w", err)
}
return headers, nil
}
func verifyAudience(idt *oidc.Jwt, clientID string) error {
if idt.GetClaims().Audience == "" {
return fmt.Errorf("missing audience claim")
}
for _, audience := range strings.Split(idt.GetClaims().Audience, ",") {
if audience == clientID {
return nil
}
}
return fmt.Errorf("audience does not contain clientID %s, aud = %v", clientID, idt.GetClaims().Audience)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/openpubkey/openpubkey/discover"
simpleoidc "github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/util"
"github.com/sirupsen/logrus"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
)
// StandardOpOptions is an options struct that configures how providers.StandardOp
// operates. See providers.GetDefaultStandardOpOptions for the recommended default
// values to use when using the standardOp for a custom OpenIdProvider.
type StandardOpOptions struct {
// ClientID is the client ID of the OIDC application. It should be the
// expected "aud" claim in received ID tokens from the OP.
ClientID string
// ClientSecret is the client secret of the OIDC application. Some OPs do
// not require that this value is set.
ClientSecret string
// Issuer is the OP's issuer URI for performing OIDC authorization and
// discovery.
Issuer string
// Scopes is the list of scopes to send to the OP in the initial
// authorization request.
Scopes []string
// PromptType is the type of prompt to use when requesting authorization from the user. Typically
// this is set to "consent".
PromptType string
// AccessType is the type of access to request from the OP. Typically this is set to "offline".
AccessType string
// RedirectURIs is the list of authorized redirect URIs that can be
// redirected to by the OP after the user completes the authorization code
// flow exchange. Ensure that your OIDC application is configured to accept
// these URIs otherwise an error may occur.
RedirectURIs []string
// GQSign denotes if the received ID token should be upgraded to a GQ token
// using GQ signatures.
GQSign bool
// OpenBrowser denotes if the client's default browser should be opened
// automatically when performing the OIDC authorization flow. This value
// should typically be set to true, unless performing some headless
// automation (e.g. integration tests) where you don't want the browser to
// open.
OpenBrowser bool
// HttpClient is the http.Client to use when making queries to the OP (OIDC
// code exchange, refresh, verification of ID token, fetch of JWKS endpoint,
// etc.). If nil, then http.DefaultClient is used.
HttpClient *http.Client
// IssuedAtOffset configures the offset to add when validating the "iss" and
// "exp" claims of received ID tokens from the OP.
IssuedAtOffset time.Duration
}
func GetDefaultStandardOpOptions(issuer string, clientID string) *StandardOpOptions {
return &StandardOpOptions{
Issuer: issuer,
ClientID: clientID,
Scopes: []string{"openid profile email"},
PromptType: "consent",
AccessType: "offline",
RedirectURIs: []string{
"http://localhost:3000/login-callback",
"http://localhost:10001/login-callback",
"http://localhost:11110/login-callback",
},
GQSign: false,
OpenBrowser: true,
HttpClient: nil,
IssuedAtOffset: 1 * time.Minute,
}
}
// NewStandardOpWithOptions creates a standard OP with configuration specified
// using an options struct. This is useful if you want to use your own OIDC
// Client or override the configuration.
func NewStandardOpWithOptions(opts *StandardOpOptions) BrowserOpenIdProvider {
return &StandardOp{
clientID: opts.ClientID,
ClientSecret: opts.ClientSecret,
Scopes: opts.Scopes,
PromptType: opts.PromptType,
AccessType: opts.AccessType,
RedirectURIs: opts.RedirectURIs,
GQSign: opts.GQSign,
OpenBrowser: opts.OpenBrowser,
HttpClient: opts.HttpClient,
IssuedAtOffset: opts.IssuedAtOffset,
issuer: opts.Issuer,
requestTokensOverrideFunc: nil,
publicKeyFinder: discover.PublicKeyFinder{
JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient)
},
},
}
}
type StandardOp struct {
clientID string
ClientSecret string
Scopes []string
PromptType string
AccessType string
RedirectURIs []string
GQSign bool
OpenBrowser bool
HttpClient *http.Client
IssuedAtOffset time.Duration
issuer string
server *http.Server
publicKeyFinder discover.PublicKeyFinder
requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error)
httpSessionHook http.HandlerFunc
reuseBrowserWindowHook chan string
}
type StandardOpRefreshable struct {
StandardOp
}
var _ OpenIdProvider = (*StandardOp)(nil)
var _ BrowserOpenIdProvider = (*StandardOp)(nil)
var _ RefreshableOpenIdProvider = (*StandardOpRefreshable)(nil)
// NewStandardOp creates a standard OP (OpenID Provider) using the
// default configuration options and returns a BrowserOpenIdProvider.
func NewStandardOp(issuer string, clientID string) BrowserOpenIdProvider {
options := GetDefaultStandardOpOptions(issuer, clientID)
return NewStandardOpWithOptions(options)
}
func (s *StandardOp) requestTokens(ctx context.Context, cicHash string) (*simpleoidc.Tokens, error) {
if s.requestTokensOverrideFunc != nil {
return s.requestTokensOverrideFunc(cicHash)
}
redirectURI, ln, err := FindAvailablePort(s.RedirectURIs)
if err != nil {
return nil, err
}
logrus.Infof("listening on http://%s/", ln.Addr().String())
logrus.Info("press ctrl+c to stop")
mux := http.NewServeMux()
s.server = &http.Server{Handler: mux}
cookieHandler, err := configCookieHandler()
if err != nil {
return nil, err
}
options := []rp.Option{
rp.WithCookieHandler(cookieHandler),
rp.WithSigningAlgsFromDiscovery(),
rp.WithVerifierOpts(
rp.WithIssuedAtOffset(s.IssuedAtOffset), rp.WithNonce(
func(ctx context.Context) string { return cicHash })),
}
options = append(options, rp.WithPKCE(cookieHandler))
if s.HttpClient != nil {
options = append(options, rp.WithHTTPClient(s.HttpClient))
}
// The reason we don't set the relyingParty on the struct and reuse it,
// is because refresh requests require a slightly different set of
// options. For instance we want the option to check the nonce (WithNonce)
// here, but in RefreshTokens we don't want that option set because
// a refreshed ID token doesn't have a nonce.
relyingParty, err := rp.NewRelyingPartyOIDC(ctx,
s.issuer, s.clientID, s.ClientSecret, redirectURI.String(),
s.Scopes, options...)
if err != nil {
return nil, fmt.Errorf("error creating provider: %w", err)
}
state := func() string {
return uuid.New().String()
}
shutdownServer := func() {
if err := s.server.Shutdown(ctx); err != nil {
logrus.Errorf("Failed to shutdown http server: %v", err)
}
}
chTokens := make(chan *oidc.Tokens[*oidc.IDTokenClaims], 1)
chErr := make(chan error, 1)
mux.Handle("/login", rp.AuthURLHandler(state, relyingParty,
rp.WithURLParam("nonce", cicHash),
// Select account requires that the user click the account they want to use.
// Results in better UX than just automatically dropping them into their
// only signed in account.
// See prompt parameter in OIDC spec https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
rp.WithPromptURLParam(s.PromptType),
rp.WithURLParam("access_type", s.AccessType)),
)
marshalToken := func(w http.ResponseWriter, r *http.Request, retTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty) {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
chErr <- err
return
}
chTokens <- retTokens
// If defined the OIDC client hands over control of the HTTP server session to the OpenPubkey client.
// Useful for redirecting the user's browser window that just finished OIDC Auth flow to the
// MFA Cosigner Auth URI.
if s.httpSessionHook != nil {
s.httpSessionHook(w, r)
defer shutdownServer() // If no http session hook is set, we do server shutdown in RequestTokens
} else {
if _, err := w.Write([]byte("You may now close this window")); err != nil {
logrus.Error(err)
}
}
}
callbackPath := redirectURI.Path
mux.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, relyingParty))
go func() {
err := s.server.Serve(ln)
if err != nil && err != http.ErrServerClosed {
logrus.Error(err)
}
}()
loginURI := fmt.Sprintf("http://localhost:%s/login", redirectURI.Port())
// If reuseBrowserWindowHook is set, don't open a new browser window
// instead redirect the user's existing browser window
if s.reuseBrowserWindowHook != nil {
s.reuseBrowserWindowHook <- loginURI
} else if s.OpenBrowser {
logrus.Infof("Opening browser to %s ", loginURI)
if err := util.OpenUrl(loginURI); err != nil {
logrus.Errorf("Failed to open url: %v", err)
}
} else {
// If s.OpenBrowser is false, tell the user what URL to open.
// This is useful when a user wants to use a different browser than the default one.
logrus.Infof("Open your browser to: %s ", loginURI)
}
// If httpSessionHook is not defined shutdown the server when done,
// otherwise keep it open for the httpSessionHook
// If httpSessionHook is set we handle both possible cases to ensure
// the server is shutdown:
// 1. We shut it down if an error occurs in the marshalToken handler
// 2. We shut it down if the marshalToken handler completes
if s.httpSessionHook == nil {
defer shutdownServer()
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-chErr:
if s.httpSessionHook != nil {
defer shutdownServer()
}
return nil, err
case retTokens := <-chTokens:
// retTokens is a zitadel/oidc struct. We turn it into our simpler token struct
return &simpleoidc.Tokens{
IDToken: []byte(retTokens.IDToken),
RefreshToken: []byte(retTokens.RefreshToken),
AccessToken: []byte(retTokens.AccessToken)}, nil
}
}
func (s *StandardOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) {
// Define our commitment as the hash of the client instance claims
cicHash, err := cic.Hash()
if err != nil {
return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err)
}
tokens, err := s.requestTokens(ctx, string(cicHash))
if err != nil {
return nil, err
}
if s.GQSign {
idToken := tokens.IDToken
if gqToken, err := CreateGQToken(ctx, idToken, s); err != nil {
return nil, err
} else {
tokens.IDToken = gqToken
return tokens, nil
}
}
return tokens, nil
}
func (s *StandardOpRefreshable) RefreshTokens(ctx context.Context, refreshToken []byte) (*simpleoidc.Tokens, error) {
cookieHandler, err := configCookieHandler()
if err != nil {
return nil, err
}
options := []rp.Option{
rp.WithCookieHandler(cookieHandler),
rp.WithVerifierOpts(
rp.WithIssuedAtOffset(s.IssuedAtOffset),
rp.WithNonce(nil), // disable nonce check
),
}
options = append(options, rp.WithPKCE(cookieHandler))
if s.HttpClient != nil {
options = append(options, rp.WithHTTPClient(s.HttpClient))
}
// The redirect URI is not sent in the refresh request so we set it to an empty string.
// According to the OIDC spec the only values send on a refresh request are:
// client_id, client_secret, grant_type, refresh_token, and scope.
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken
redirectURI := ""
relyingParty, err := rp.NewRelyingPartyOIDC(ctx, s.issuer, s.clientID,
s.ClientSecret, redirectURI, s.Scopes, options...)
if err != nil {
return nil, fmt.Errorf("failed to create RP to verify token: %w", err)
}
retTokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, relyingParty, string(refreshToken), "", "")
if err != nil {
return nil, err
}
if retTokens.RefreshToken == "" {
// Google does not rotate refresh tokens, the one you get at the
// beginning is the only one you'll ever get. This may not be true
// of OPs.
retTokens.RefreshToken = string(refreshToken)
}
return &simpleoidc.Tokens{
IDToken: []byte(retTokens.IDToken),
RefreshToken: []byte(retTokens.RefreshToken),
AccessToken: []byte(retTokens.AccessToken)}, nil
}
func (s *StandardOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) {
return s.publicKeyFinder.ByToken(ctx, s.issuer, token)
}
func (s *StandardOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) {
return s.publicKeyFinder.ByKeyID(ctx, s.issuer, keyID)
}
func (s *StandardOp) Issuer() string {
return s.issuer
}
func (s *StandardOp) ClientID() string {
return s.clientID
}
func (s *StandardOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error {
vp := NewProviderVerifier(
s.issuer,
ProviderVerifierOpts{
CommitType: CommitTypesEnum.NONCE_CLAIM,
ClientID: s.clientID,
DiscoverPublicKey: &s.publicKeyFinder,
})
return vp.VerifyIDToken(ctx, idt, cic)
}
func (s *StandardOpRefreshable) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error {
if err := simpleoidc.SameIdentity(origIdt, reIdt); err != nil {
return fmt.Errorf("refreshed ID Token is for different subject than original ID Token: %w", err)
}
if err := simpleoidc.RequireOlder(origIdt, reIdt); err != nil {
return fmt.Errorf("refreshed ID Token should not be issued before original ID Token: %w", err)
}
options := []rp.Option{}
if s.HttpClient != nil {
options = append(options, rp.WithHTTPClient(s.HttpClient))
}
redirectURI := ""
relyingParty, err := rp.NewRelyingPartyOIDC(ctx, s.issuer, s.clientID,
s.ClientSecret, redirectURI, s.Scopes, options...)
if err != nil {
return fmt.Errorf("failed to create RP to verify token: %w", err)
}
_, err = rp.VerifyIDToken[*oidc.IDTokenClaims](ctx, string(reIdt), relyingParty.IDTokenVerifier())
return err
}
// HookHTTPSession provides a means to hook the HTTP Server session resulting
// from the OpenID Provider sending an authcode to the OIDC client by
// redirecting the user's browser with the authcode supplied in the URI.
// If this hook is set, it will be called after the receiving the authcode
// but before send an HTTP response to the user. The code which sets this hook
// can choose what HTTP response to server to the user.
//
// We use this so that we can redirect the user web browser window to
// the MFA Cosigner URI after the user finishes the OIDC Auth flow. This
// method is only available to browser based providers.
func (s *StandardOp) HookHTTPSession(h http.HandlerFunc) {
s.httpSessionHook = h
}
// ReuseBrowserWindow is needed so that do not open more than one browser window.
// If we are using a web based OpenID Provider chooser, we have already opened one
// window on the user's browser. We should reuse that window here rather than
// opening a second browser window.
func (s *StandardOp) ReuseBrowserWindowHook(h chan string) {
s.reuseBrowserWindowHook = h
}
// GetBrowserWindowHook ris used by testing to trigger the redirect without
// calling out the OP. This is hidden by not including in the interface.
func (s *StandardOp) TriggerBrowserWindowHook(uri string) {
s.reuseBrowserWindowHook <- uri
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"testing"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
"github.com/openpubkey/openpubkey/util"
"github.com/stretchr/testify/require"
)
func GenCIC(t *testing.T) *clientinstance.Claims {
return GenCICExtra(t, map[string]any{})
}
func GenCICExtra(t *testing.T, extraClaims map[string]any) *clientinstance.Claims {
alg := jwa.ES256
signer, err := util.GenKeyPair(alg)
require.NoError(t, err)
jwkKey, err := jwk.PublicKeyOf(signer)
require.NoError(t, err)
err = jwkKey.Set(jwk.AlgorithmKey, alg)
require.NoError(t, err)
cic, err := clientinstance.NewClaims(jwkKey, extraClaims)
require.NoError(t, err)
return cic
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package providers
import (
"crypto/rand"
"fmt"
"io"
"net"
"net/url"
"strings"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
)
// FindAvailablePort attempts to open a listener on localhost until it finds one or runs out of redirectURIs to try
func FindAvailablePort(redirectURIs []string) (*url.URL, net.Listener, error) {
var ln net.Listener
var lnErr error
for _, v := range redirectURIs {
redirectURI, err := url.Parse(v)
if err != nil {
return nil, nil, fmt.Errorf("malformed redirectURI specified, redirectURI was %s", v)
}
if !(strings.HasPrefix(redirectURI.Host, "localhost") ||
strings.HasPrefix(redirectURI.Host, "127.0.0.1") ||
strings.HasPrefix(redirectURI.Host, "0:0:0:0:0:0:0:1") ||
strings.HasPrefix(redirectURI.Host, "::1")) {
return nil, nil, fmt.Errorf("redirectURI must be localhost, redirectURI was %s", redirectURI.Host)
}
lnStr := fmt.Sprintf("localhost:%s", redirectURI.Port())
ln, lnErr = net.Listen("tcp", lnStr)
if lnErr == nil {
return redirectURI, ln, nil
}
}
return nil, nil, fmt.Errorf("failed to start a listener for the callback from the OP, got %w", lnErr)
}
func configCookieHandler() (*httphelper.CookieHandler, error) {
// I've been unable to determine a scenario in which setting a hashKey and blockKey
// on the cookie provide protection in the localhost redirect URI case. However I
// see no harm in setting it.
hashKey := make([]byte, 64)
if _, err := io.ReadFull(rand.Reader, hashKey); err != nil {
return nil, fmt.Errorf("failed to generate random keys for cookie storage")
}
blockKey := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, blockKey); err != nil {
return nil, fmt.Errorf("failed to generate random keys for cookie storage")
}
// OpenPubkey uses a localhost redirect URI to receive the authcode
// from the OP. Localhost redirects use http not https. Thus, we should
// not set these cookies as secure-only. This should be changed if
// OpenPubkey added support for non-localhost redirect URIs.
// WithUnsecure() is equivalent to not setting the 'secure' attribute
// flag in an HTTP Set-Cookie header (see https://http.dev/set-cookie#secure)
return httphelper.NewCookieHandler(hashKey, blockKey, httphelper.WithUnsecure()), nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package util
import (
"encoding/base64"
)
var rawURLEncoding = base64.RawURLEncoding.Strict()
func Base64EncodeForJWT(decoded []byte) []byte {
return base64Encode(decoded, rawURLEncoding)
}
func Base64DecodeForJWT(encoded []byte) ([]byte, error) {
return base64Decode(encoded, rawURLEncoding)
}
func base64Encode(decoded []byte, encoding *base64.Encoding) []byte {
encoded := make([]byte, encoding.EncodedLen(len(decoded)))
encoding.Encode(encoded, decoded)
return encoded
}
func base64Decode(encoded []byte, encoding *base64.Encoding) ([]byte, error) {
decoded := make([]byte, encoding.DecodedLen(len(encoded)))
n, err := encoding.Decode(decoded, encoded)
if err != nil {
return nil, err
}
return decoded[:n], nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package util
import "bytes"
func JoinJWTSegments(segments ...[]byte) []byte {
return JoinBytes('.', segments...)
}
func JoinBytes(sep byte, things ...[]byte) []byte {
return bytes.Join(things, []byte{sep})
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package util
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"github.com/lestrrat-go/jwx/v2/jwa"
"golang.org/x/crypto/ed25519"
)
func SKToX509Bytes(sk *ecdsa.PrivateKey) ([]byte, error) {
x509Encoded, err := x509.MarshalECPrivateKey(sk)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509Encoded}), nil
}
func WriteSKFile(fpath string, sk *ecdsa.PrivateKey) error {
pemBytes, err := SKToX509Bytes(sk)
if err != nil {
return err
}
return os.WriteFile(fpath, pemBytes, 0600)
}
func ReadSKFile(fpath string) (*ecdsa.PrivateKey, error) {
pemBytes, err := os.ReadFile(fpath)
if err != nil {
return nil, err
}
block, _ := pem.Decode([]byte(pemBytes))
return x509.ParseECPrivateKey(block.Bytes)
}
func GenKeyPair(alg jwa.KeyAlgorithm) (crypto.Signer, error) {
switch alg {
case jwa.ES256:
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case jwa.RS256: // RSASSA-PKCS-v1.5 using SHA-256
return rsa.GenerateKey(rand.Reader, 2048)
case jwa.EdDSA:
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
return privateKey, err
default:
return nil, fmt.Errorf("unsupported algorithm: %s", alg.String())
}
}
func B64SHA3_256(msg []byte) []byte {
h := crypto.SHA3_256.New()
h.Write(msg)
image := h.Sum(nil)
return Base64EncodeForJWT(image)
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package util
import (
"os/exec"
"runtime"
)
// https://stackoverflow.com/questions/39320371/how-start-web-server-to-open-page-in-browser-in-golang
// open opens the specified URL in the default browser of the user.
func OpenUrl(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "cmd"
args = []string{"/c", "start"}
case "darwin":
cmd = "open"
default: // "linux", "freebsd", "openbsd", "netbsd"
cmd = "xdg-open"
}
args = append(args, url)
return exec.Command(cmd, args...).Start()
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package verifier
import (
"fmt"
"time"
"github.com/openpubkey/openpubkey/oidc"
"github.com/openpubkey/openpubkey/pktoken"
)
type ExpirationPolicy struct {
maxAge time.Duration
checkMaxAge bool
checkExpClaim bool
checkRefreshed bool
}
var ExpirationPolicies = struct {
OIDC ExpirationPolicy // This uses the OpenID Connect expiration claim
OIDC_REFRESHED ExpirationPolicy // This uses the OpenID Connect expiration claim on the ID Token, if that has expired. It checks the expiration on the refreshed ID Token, a.k.a., the fresh ID Token
MAX_AGE_12HOURS ExpirationPolicy // This replaces the OpenID Connect expiration claim with OpenPubkey 12 expiration
MAX_AGE_24HOURS ExpirationPolicy
MAX_AGE_48HOURS ExpirationPolicy
MAX_AGE_1WEEK ExpirationPolicy
NEVER_EXPIRE ExpirationPolicy // ID Token will never expire until the OpenID Provider rotates the ID Token
}{
OIDC: ExpirationPolicy{maxAge: 0, checkMaxAge: false, checkExpClaim: true},
OIDC_REFRESHED: ExpirationPolicy{maxAge: 0, checkMaxAge: false, checkExpClaim: false, checkRefreshed: true},
MAX_AGE_12HOURS: ExpirationPolicy{maxAge: 12 * time.Hour, checkMaxAge: true, checkExpClaim: false},
MAX_AGE_24HOURS: ExpirationPolicy{maxAge: 24 * time.Hour, checkMaxAge: true, checkExpClaim: false},
MAX_AGE_48HOURS: ExpirationPolicy{maxAge: 2 * 24 * time.Hour, checkMaxAge: true, checkExpClaim: false},
MAX_AGE_1WEEK: ExpirationPolicy{maxAge: 7 * 24 * time.Hour, checkMaxAge: true, checkExpClaim: false},
NEVER_EXPIRE: ExpirationPolicy{maxAge: 0, checkMaxAge: false, checkExpClaim: false},
}
// CheckExpiration checks the expiration of the PK Token against the expiration
// policy.
func (ep ExpirationPolicy) CheckExpiration(pkt *pktoken.PKToken) error {
idt, err := oidc.NewJwt(pkt.OpToken)
if err != nil {
return err
}
idtClaims := idt.GetClaims()
if ep.checkExpClaim {
_, err := verifyNotExpired(idtClaims.Expiration)
if err != nil {
return err
}
}
if ep.checkRefreshed {
expired, err := verifyNotExpired(idtClaims.Expiration)
// If the id token is expired, verify against the refreshed id token
if expired {
if pkt.FreshIDToken == nil {
return fmt.Errorf("ID token is expired and no refresh token found")
}
freshIdt, err := oidc.NewJwt(pkt.FreshIDToken)
if err != nil {
return err
}
_, err = verifyNotExpired(freshIdt.GetClaims().Expiration)
if err != nil {
return err
}
} else if err != nil { // an non-expiration error occurred
return err
}
}
if ep.checkMaxAge {
_, err := checkMaxAge(idtClaims.IssuedAt, int64(ep.maxAge.Seconds()))
if err != nil {
return err
}
}
return nil
}
// verifyNotExpired checks the expiration of the ID Token using the exp claim.
// If expired, returns true and set an error. If an error prevents checking
// expiration it return false and the error.
func verifyNotExpired(expiration int64) (bool, error) {
if expiration == 0 {
return false, fmt.Errorf("missing expiration claim")
}
if expiration < 0 {
return false, fmt.Errorf("expiration must be must be greater than zero (issuedAt = %v)", expiration)
}
// JWT expiration is "Seconds Since the Epoch"
// RFC-7519 -Section 2 https://www.rfc-editor.org/rfc/rfc7519#section-2
expirationTime := time.Unix(expiration, 0)
if time.Now().After(expirationTime) {
return true, fmt.Errorf("the ID token has expired (exp = %v)", expiration)
}
return false, nil
}
// checkMaxAge checks the max age of the ID Token using the issuedAt claim.
// If expired, returns true and set an error. If an error prevents checking
// expiration it return false and the error.
func checkMaxAge(issuedAt int64, maxAge int64) (bool, error) {
if issuedAt == 0 {
return false, fmt.Errorf("missing issuedAt claim")
}
if issuedAt < 0 {
return false, fmt.Errorf("issuedAt must be must be greater than zero (issuedAt = %v)", issuedAt)
}
if !(maxAge > 0) {
return false, fmt.Errorf("maxAge configuration must be greater than zero (maxAge = %v)", maxAge)
}
// Ensure we throw an error is something goes wrong and we get parameters so large they overflow
if (issuedAt + maxAge) < issuedAt {
return false, fmt.Errorf("invalid values (issuedAt = %v, maxAge = %v)", issuedAt, maxAge)
}
expirationTime := time.Unix(issuedAt+maxAge, 0)
if time.Now().After(expirationTime) {
return true, fmt.Errorf("the PK token has expired based on maxAge (issuedAt = %v, maxAge = %v, expiratedAt = %v)", issuedAt, maxAge, expirationTime)
}
return false, nil
}
// Copyright 2025 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package verifier
import (
"context"
"net/http"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
)
// UserInfoRequester enables the retrieval of user info from an OpenID Provider
// using the access token obtained during authentication. It uses the PK Token
// look up the issuer URI for the OpenID Provider and ensure that the subject
// (sub claim) in the ID token matches the subject in the access token.
type UserInfoRequester struct {
Issuer string
Subject string
AccessToken string
HttpClient *http.Client
}
func NewUserInfoRequester(pkt *pktoken.PKToken, accessToken string) (*UserInfoRequester, error) {
issuer, err := pkt.Issuer()
if err != nil {
return nil, err
}
sub, err := pkt.Subject()
if err != nil {
return nil, err
}
return &UserInfoRequester{
Issuer: issuer,
Subject: sub,
AccessToken: accessToken,
}, nil
}
// Request calls an OpenID Provider's user info endpoint using the provided access token.
// The access token must match subject (sub claim) in the ID token issued alongside that
// access token. This function returns the user info JSON as a string.
func (ui *UserInfoRequester) Request(ctx context.Context) (string, error) {
httpClient := http.DefaultClient
if ui.HttpClient != nil {
httpClient = ui.HttpClient
}
// We use zitadel/oidc to call the userinfo endpoint rather than calling
// the endpoint directly to take advantage of the zitadel's ability to use
// HTTP proxies in requests.
relyingParty, err := rp.NewRelyingPartyOIDC(ctx, ui.Issuer, "", "", "", nil, rp.WithHTTPClient(httpClient))
if err != nil {
return "", err
}
info, err := rp.Userinfo[*oidc.UserInfo](
ctx,
ui.AccessToken,
"Bearer",
ui.Subject,
relyingParty,
)
if err != nil {
return "", err
}
jsonInfo, err := info.MarshalJSON()
if err != nil {
// We should not reach this because rp.NewRelyingPartyOIDC already unmarshals the JSON to check the sub
return "", err
}
return string(jsonInfo), nil
}
// Copyright 2024 OpenPubkey
//
// 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.
//
// SPDX-License-Identifier: Apache-2.0
package verifier
import (
"context"
"fmt"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/openpubkey/openpubkey/cosigner"
"github.com/openpubkey/openpubkey/gq"
"github.com/openpubkey/openpubkey/pktoken"
"github.com/openpubkey/openpubkey/pktoken/clientinstance"
)
type ProviderVerifier interface {
// Returns the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com"
Issuer() string
VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error
}
type ProviderVerifierExpires struct {
ProviderVerifier
Expiration ExpirationPolicy
}
func (p ProviderVerifierExpires) ExpirationPolicy() ExpirationPolicy {
return p.Expiration
}
type RefreshableProviderVerifier interface {
VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error
}
type CosignerVerifier interface {
Issuer() string
Strict() bool // Whether or not a given cosigner MUST be present for successful verification
VerifyCosigner(ctx context.Context, pkt *pktoken.PKToken) error
}
type VerifierOpts func(*Verifier) error
// RequireRefreshedIDToken instructs the verifier to check that
// an unexpired, refreshed ID token is set on the PKToken.
func RequireRefreshedIDToken() VerifierOpts {
return func(v *Verifier) error {
v.requireRefreshedIDToken = true
return nil
}
}
func WithExpirationPolicy(expirationPolicy ExpirationPolicy) VerifierOpts {
return func(v *Verifier) error {
v.defaultExpirationPolicy = &expirationPolicy
return nil
}
}
func WithCosignerVerifiers(verifiers ...*cosigner.DefaultCosignerVerifier) VerifierOpts {
return func(v *Verifier) error {
for _, verifier := range verifiers {
if _, ok := v.cosigners[verifier.Issuer()]; ok {
return fmt.Errorf("cosigner verifier found with duplicate issuer: %s", verifier.Issuer())
}
v.cosigners[verifier.Issuer()] = verifier
}
return nil
}
}
type Check func(*Verifier, *pktoken.PKToken) error
func GQOnly() Check {
return func(_ *Verifier, pkt *pktoken.PKToken) error {
alg, ok := pkt.ProviderAlgorithm()
if !ok {
return fmt.Errorf("missing provider algorithm header")
}
if alg != gq.GQ256 {
return fmt.Errorf("non-GQ signatures are not supported")
}
return nil
}
}
type Verifier struct {
providers map[string]ProviderVerifier
cosigners map[string]CosignerVerifier
// Sets the default expiration policy to use
defaultExpirationPolicy *ExpirationPolicy
requireRefreshedIDToken bool
}
func New(verifier ProviderVerifier, options ...VerifierOpts) (*Verifier, error) {
return NewFromMany([]ProviderVerifier{verifier}, options...)
}
func NewFromMany(verifiers []ProviderVerifier, options ...VerifierOpts) (*Verifier, error) {
v := &Verifier{
providers: map[string]ProviderVerifier{},
cosigners: map[string]CosignerVerifier{},
// For user access we override the ID Token expiration claim
// and instead have tokens expire after 24 hours so that
// users don't have log back in every hour.
defaultExpirationPolicy: &ExpirationPolicies.MAX_AGE_24HOURS,
}
for _, verifier := range verifiers {
if _, ok := v.providers[verifier.Issuer()]; ok {
return nil, fmt.Errorf("provider verifier found with duplicate issuer: %s", verifier.Issuer())
}
v.providers[verifier.Issuer()] = verifier
}
for _, option := range options {
if err := option(v); err != nil {
return nil, err
}
}
if v.defaultExpirationPolicy == nil {
// Default to 24 hours if no expiration policy is set
v.defaultExpirationPolicy = &ExpirationPolicies.MAX_AGE_24HOURS
}
return v, nil
}
// Verifies whether a PK token is valid and matches all expected claims.
//
// extraChecks: Allows for optional specification of additional checks
func (v *Verifier) VerifyPKToken(
ctx context.Context,
pkt *pktoken.PKToken,
extraChecks ...Check,
) error {
// Don't even bother doing anything if the user's isn't valid
if err := verifyCicSignature(pkt); err != nil {
return fmt.Errorf("error verifying client signature on PK Token: %w", err)
}
issuer, err := pkt.Issuer()
if err != nil {
return err
}
providerVerifier, ok := v.providers[issuer]
if !ok {
var knownIssuers []string
for k := range v.providers {
knownIssuers = append(knownIssuers, k)
}
return fmt.Errorf("unrecognized issuer: %s, issuers known: %v", issuer, knownIssuers)
}
cic, err := pkt.GetCicValues()
if err != nil {
return err
}
if err := providerVerifier.VerifyIDToken(ctx, pkt.OpToken, cic); err != nil {
return err
}
// If expiration has been set for this provider verifier use it to check expiration
if providerVerifierExpires, ok := providerVerifier.(ProviderVerifierExpires); ok {
if err := providerVerifierExpires.ExpirationPolicy().CheckExpiration(pkt); err != nil {
return err
}
} else if err := v.defaultExpirationPolicy.CheckExpiration(pkt); err != nil {
// Otherwise use the default expiration policy
return err
}
if v.requireRefreshedIDToken {
if reProviderVerifier, ok := providerVerifier.(RefreshableProviderVerifier); !ok {
return fmt.Errorf("refreshed ID Token verification required but provider verifier (issuer=%s) does not support it", issuer)
} else {
if pkt.FreshIDToken == nil {
return fmt.Errorf("no refreshed ID Token set")
}
if err := reProviderVerifier.VerifyRefreshedIDToken(ctx, pkt.OpToken, pkt.FreshIDToken); err != nil {
return err
}
}
}
if len(v.cosigners) > 0 {
if pkt.Cos == nil {
// If there's no cosigner signature and any provided cosigner verifiers are strict, then return error
for _, cosignerVerifier := range v.cosigners {
if cosignerVerifier.Strict() {
return fmt.Errorf("missing required cosigner signature by %s", cosignerVerifier.Issuer())
}
}
} else {
cosignerClaims, err := pkt.ParseCosignerClaims()
if err != nil {
return err
}
cosignerVerifier, ok := v.cosigners[cosignerClaims.Issuer]
if !ok {
// If other cosigners are present, do we accept?
return fmt.Errorf("unrecognized cosigner %s", cosignerClaims.Issuer)
}
// Verify cosigner signature
if err := cosignerVerifier.VerifyCosigner(ctx, pkt); err != nil {
return err
}
// If any other cosigner verifiers are set to strict but aren't present, then return error
for _, cosignerVerifier := range v.cosigners {
if cosignerVerifier.Strict() && cosignerVerifier.Issuer() != cosignerClaims.Issuer {
return fmt.Errorf("missing required cosigner signature by %s", cosignerVerifier.Issuer())
}
}
}
}
// Cycles through any provided additional checks and returns the first error, if any.
for _, check := range extraChecks {
if err := check(v, pkt); err != nil {
return err
}
}
return nil
}
func verifyCicSignature(pkt *pktoken.PKToken) error {
cic, err := pkt.GetCicValues()
if err != nil {
return err
}
_, err = jws.Verify(pkt.CicToken, jws.WithKey(cic.PublicKey().Algorithm(), cic.PublicKey()))
return err
}