Merge pull request #4 from davidallendj/dev

Complete implementation of authorization code flow
This commit is contained in:
David Allen 2024-03-05 20:54:54 -07:00 committed by GitHub
commit 0888a64a3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1453 additions and 789 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
*.json
*.db
*.code-workspace
opaal

View file

@ -1,8 +1,8 @@
# OIDC Provider Authentication/Authorization Login (OPAAL)
This is a small, simple, experimental OIDC login helper tool that automates the authorization code lohin flow defined by [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1) for social sign-in with identity providers (IdP) like Google, Facebook, or GitHub. This tool is made to work when your identity provider is separate from your authorization server, and we only need the IdP to receive an ID token. In this document, the identity provider (or authentication server) is strictly the OIDC implementation that identifies the resource owner (ID token) whereas the resource provider (or authorization server) is the OIDC implementation that grants access to a resource (access token). This tool is tested with Ory Kratos and Hydra for user identity and session management and OAuth2/OIDC implementation respectively.
This is a small, simple, experimental OIDC login helper tool that automates the authorization flows defined by [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1) for social sign-in with identity providers (IdP) like Google, Facebook, or GitHub. This tool is made to work when your identity provider is separate from your authorization server, and we only need the IdP to receive an ID token. In this document, the identity provider (or authentication server) is strictly the OIDC implementation that identifies the resource owner (ID token) whereas the resource provider (or authorization server) is the OIDC implementation that grants access to a resource (access token). OPAAL assumes that the authentication server is external and the authorization server is owned. This tool is tested with Ory Kratos and Hydra for user identity and session management and OAuth2/OIDC implementation respectively.
Note: This tool acts as an OAuth client, contains client secrets, and is not to be exposed to users!
Note: This tool acts as an OAuth client, contains client secrets, and should not to be exposed to users! It would probably also be a good idea to use a reverse proxy and firewall to protect admin endpoints.
## Build and Usage

View file

@ -2,9 +2,10 @@ package cmd
import (
opaal "davidallendj/opaal/internal"
"davidallendj/opaal/internal/util"
"fmt"
"github.com/davidallendj/go-utils/pathx"
"github.com/spf13/cobra"
)
@ -15,7 +16,7 @@ var configCmd = &cobra.Command{
// create a new config at all args (paths)
for _, path := range args {
// check and make sure something doesn't exist first
if exists, err := util.PathExists(path); exists || err != nil {
if exists, err := pathx.PathExists(path); exists || err != nil {
fmt.Printf("file or directory exists\n")
continue
}

View file

@ -2,22 +2,56 @@ package cmd
import (
opaal "davidallendj/opaal/internal"
"davidallendj/opaal/internal/db"
"davidallendj/opaal/internal/oidc"
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
client opaal.Client
)
var loginCmd = &cobra.Command{
Use: "login",
Short: "Start the login flow",
Run: func(cmd *cobra.Command, args []string) {
for {
err := opaal.Login(&config)
// try and find client with valid identity provider config
var provider *oidc.IdentityProvider
for _, c := range config.Authentication.Clients {
// try to get identity provider info locally first
_, err := db.GetIdentityProvider(config.Options.CachePath, c.Issuer)
if err != nil && !config.Options.LocalOnly {
fmt.Printf("fetching config from issuer: %v\n", c.Issuer)
// try to get info remotely by fetching
provider, err = oidc.FetchServerConfig(c.Issuer)
if err != nil {
fmt.Printf("failed to fetch server config: %v\n", err)
continue
}
client = c
// fetch the provider's JWKS
err := provider.FetchJwks()
if err != nil {
fmt.Printf("failed to fetch JWKS: %v\n", err)
}
break
}
}
if provider == nil {
fmt.Printf("failed to retrieve provider config\n")
os.Exit(1)
}
err := opaal.Login(&config, &client, provider)
if err != nil {
fmt.Printf("%v\n", err)
os.Exit(1)
} else if config.RunOnce {
} else if config.Options.RunOnce {
break
}
}
@ -25,17 +59,17 @@ var loginCmd = &cobra.Command{
}
func init() {
loginCmd.Flags().StringVar(&config.Client.Id, "client.id", config.Client.Id, "set the client ID")
loginCmd.Flags().StringVar(&config.Client.Secret, "client.secret", config.Client.Secret, "set the client secret")
loginCmd.Flags().StringSliceVar(&config.Client.RedirectUris, "redirect-uri", config.Client.RedirectUris, "set the redirect URI")
loginCmd.Flags().StringVar(&config.ResponseType, "response-type", config.ResponseType, "set the response-type")
loginCmd.Flags().StringSliceVar(&config.Scope, "scope", config.Scope, "set the scopes")
loginCmd.Flags().StringVar(&config.State, "state", config.State, "set the state")
loginCmd.Flags().StringVar(&config.Server.Host, "host", config.Server.Host, "set the listening host")
loginCmd.Flags().IntVar(&config.Server.Port, "port", config.Server.Port, "set the listening port")
loginCmd.Flags().BoolVar(&config.OpenBrowser, "open-browser", config.OpenBrowser, "automatically open link in browser")
loginCmd.Flags().BoolVar(&config.DecodeIdToken, "decode-id-token", config.DecodeIdToken, "decode and print ID token from identity provider")
loginCmd.Flags().BoolVar(&config.DecodeAccessToken, "decore-access-token", config.DecodeAccessToken, "decode and print access token from authorization server")
loginCmd.Flags().BoolVar(&config.RunOnce, "once", config.RunOnce, "set whether to run login once and exit")
loginCmd.Flags().StringVar(&client.Id, "client.id", client.Id, "set the client ID")
loginCmd.Flags().StringVar(&client.Secret, "client.secret", client.Secret, "set the client secret")
loginCmd.Flags().StringSliceVar(&client.RedirectUris, "client.redirect-uris", client.RedirectUris, "set the redirect URI")
loginCmd.Flags().StringSliceVar(&client.Scope, "client.scope", client.Scope, "set the scopes")
loginCmd.Flags().StringVar(&config.Server.Host, "server.host", config.Server.Host, "set the listening host")
loginCmd.Flags().IntVar(&config.Server.Port, "server.port", config.Server.Port, "set the listening port")
loginCmd.Flags().BoolVar(&config.Options.OpenBrowser, "open-browser", config.Options.OpenBrowser, "automatically open link in browser")
loginCmd.Flags().BoolVar(&config.Options.DecodeIdToken, "decode-id-token", config.Options.DecodeIdToken, "decode and print ID token from identity provider")
loginCmd.Flags().BoolVar(&config.Options.DecodeAccessToken, "decore-access-token", config.Options.DecodeAccessToken, "decode and print access token from authorization server")
loginCmd.Flags().BoolVar(&config.Options.RunOnce, "once", config.Options.RunOnce, "set whether to run login once and exit")
loginCmd.Flags().StringVar(&config.Options.FlowType, "flow", config.Options.FlowType, "set the grant-type/authorization flow")
loginCmd.Flags().BoolVar(&config.Options.LocalOnly, "local", config.Options.LocalOnly, "only fetch identity provider configs stored locally")
rootCmd.AddCommand(loginCmd)
}

View file

@ -2,15 +2,15 @@ package cmd
import (
opaal "davidallendj/opaal/internal"
"davidallendj/opaal/internal/util"
"fmt"
"os"
"github.com/davidallendj/go-utils/pathx"
"github.com/spf13/cobra"
)
var (
configPath = ""
confPath = ""
config opaal.Config
)
var rootCmd = &cobra.Command{
@ -21,21 +21,6 @@ var rootCmd = &cobra.Command{
},
}
func initConfig() {
// load config if found or create a new one
if configPath != "" {
exists, err := util.PathExists(configPath)
if err != nil {
fmt.Printf("failed to load config")
os.Exit(1)
} else if exists {
config = opaal.LoadConfig(configPath)
} else {
config = opaal.NewConfig()
}
}
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "failed to start CLI: %s", err)
@ -45,5 +30,21 @@ func Execute() {
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "set the config path")
rootCmd.PersistentFlags().StringVar(&confPath, "config", "", "set the config path")
rootCmd.PersistentFlags().StringVar(&config.Options.CachePath, "cache", "", "set the cache path")
}
func initConfig() {
// load config if found or create a new one
if confPath != "" {
exists, err := pathx.PathExists(confPath)
if err != nil {
fmt.Printf("failed to load config")
os.Exit(1)
} else if exists {
config = opaal.LoadConfig(confPath)
} else {
config = opaal.NewConfig()
}
}
}

56
config.yaml Executable file
View file

@ -0,0 +1,56 @@
version: "0.0.1"
server:
host: "127.0.0.1"
port: 3333
callback: "/oidc/callback"
providers:
facebook: "http://facebook.com"
forgejo: "http://git.towk.local:3000"
gitlab: "https://gitlab.newmexicoconsortium.org"
github: "https://github.com"
authentication:
clients:
- id: "7527e7b4-c96a-4df0-8fc5-00fde18bb65d"
secret: "gto_cc5uvpb5lsdczkwnbarvwmbpv5kcjwg7nhbc75zt65yrfh2ldenq"
name: "forgejo"
issuer: "http://git.towk.local:3000"
scope:
- "openid"
- "profile"
- "read"
- "email"
redirect-uris:
- "http://127.0.0.1:3333/oidc/callback"
- id: "7c0fab1153674a258a705976fcb9468350df3addd91de4ec622fc9ed24bfbcdd"
secret: "a9a8bc55b0cd99236756093adc00ab17855fa507ce106b8038e7f9390ef2ad99"
name: "gitlab"
issuer: "http://gitlab.newmexicoconsortium.org"
scope:
- "openid"
- "profile"
- "email"
redirect-uris:
- "http://127.0.0.1:3333/oidc/callback"
flows:
authorization-code:
state: ""
client-credentials:
authorization:
urls:
#identities: http://127.0.0.1:4434/admin/identities
trusted-issuers: http://127.0.0.1:4445/admin/trust/grants/jwt-bearer/issuers
login: http://127.0.0.1:4433/self-service/login/api
clients: http://127.0.0.1:4445/admin/clients
authorize: http://127.0.0.1:4444/oauth2/auth
register: http://127.0.0.1:4444/oauth2/register
token: http://127.0.0.1:4444/oauth2/token
options:
decode-id-token: true
decode-access-token: true
run-once: true
open-browser: false

View file

@ -18,6 +18,8 @@ secrets:
- youReallyNeedToChangeThis
oidc:
dynamic_client_registration:
enabled: true
subject_identifiers:
supported_types:
- pairwise
@ -34,3 +36,6 @@ oauth2:
log:
leak_sensitive_values: true
strategies:
access_token: jwt

26
go.mod
View file

@ -3,25 +3,41 @@ module davidallendj/opaal
go 1.22.0
require (
github.com/davidallendj/go-utils v0.0.0-20240302194916-fe292bcf24a4
github.com/go-chi/chi v1.5.5
github.com/go-chi/chi/v5 v5.0.12
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/lestrrat-go/jwx v1.2.28
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.3.5
github.com/lestrrat-go/jwx/v2 v2.0.20
github.com/mattn/go-sqlite3 v1.14.6
github.com/nikolalohinski/gonja/v2 v2.2.0
github.com/spf13/cobra v1.8.0
golang.org/x/net v0.10.0
golang.org/x/net v0.21.0
gopkg.in/yaml.v2 v2.4.0
k8s.io/apimachinery v0.29.2
)
require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

120
go.sum
View file

@ -1,94 +1,116 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/davidallendj/go-utils v0.0.0-20240302194916-fe292bcf24a4 h1:6LeOczLfpq27cDfu4r6bRU3zGeBER9fy+iecHG5dDSA=
github.com/davidallendj/go-utils v0.0.0-20240302194916-fe292bcf24a4/go.mod h1:/hcpHd4um12taX6iLuMmwxosoyN6E2Ws8QxDpnY07oo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx v1.2.28 h1:uadI6o0WpOVrBSf498tRXZIwPpEtLnR9CvqPFXeI5sA=
github.com/lestrrat-go/jwx v1.2.28/go.mod h1:nF+91HEMh/MYFVwKPl5HHsBGMPscqbQb+8IDQdIazP8=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/jwx/v2 v2.0.20 h1:sAgXuWS/t8ykxS9Bi2Qtn5Qhpakw1wrcjxChudjolCc=
github.com/lestrrat-go/jwx/v2 v2.0.20/go.mod h1:UlCSmKqw+agm5BsOBfEAbTvKsEApaGNqHAEUTv5PJC4=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nikolalohinski/gonja/v2 v2.2.0 h1:tAs3BDHNjvPj48F2BL5t7iVhN32HhgeldAl3EmdsLh8=
github.com/nikolalohinski/gonja/v2 v2.2.0/go.mod h1:l9DuWJvT/BddBr2SsmEimESD6msSqRw7u5HzI2Um+sc=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ=
golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8=
k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU=

118
internal/authenticate.go Normal file
View file

@ -0,0 +1,118 @@
package opaal
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/davidallendj/go-utils/httpx"
)
func (client *Client) IsFlowInitiated() bool {
return client.FlowId != ""
}
func (client *Client) BuildAuthorizationUrl(issuer string, state string) string {
return issuer + "?" + "client_id=" + client.Id +
"&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) +
"&response_type=code" + // this has to be set to "code"
"&state=" + state +
"&scope=" + strings.Join(client.Scope, "+") +
"&resource=" + url.QueryEscape("http://127.0.0.1:4444/oauth2/token")
}
func (client *Client) InitiateLoginFlow(loginUrl string) error {
// kratos: GET /self-service/login/api
req, err := http.NewRequest("GET", loginUrl, bytes.NewBuffer([]byte{}))
if err != nil {
return fmt.Errorf("failed to make request: %v", err)
}
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
// get the flow ID from response
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %v", err)
}
var flowData map[string]any
err = json.Unmarshal(body, &flowData)
if err != nil {
return fmt.Errorf("failed to unmarshal flow data: %v\n%v", err, string(body))
} else {
client.FlowId = flowData["id"].(string)
}
return nil
}
func (client *Client) FetchFlowData(url string) (map[string]any, error) {
//kratos: GET /self-service/login/flows?id={flowId}
// replace {id} in string with actual value
url = strings.ReplaceAll(url, "{id}", client.FlowId)
_, b, err := httpx.MakeHttpRequest(url, http.MethodGet, nil, nil)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
var flowData map[string]any
err = json.Unmarshal(b, &flowData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal flow data: %v", err)
}
return flowData, nil
}
func (client *Client) FetchCSRFToken(flowUrl string) error {
data, err := client.FetchFlowData(flowUrl)
if err != nil {
return fmt.Errorf("failed to fetch flow data: %v", err)
}
// iterate through nodes and extract the CSRF token attribute from the flow data
ui := data["ui"].(map[string]any)
nodes := ui["nodes"].([]any)
for _, node := range nodes {
attrs := node.(map[string]any)["attributes"].(map[string]any)
name := attrs["name"].(string)
if name == "csrf_token" {
client.CsrfToken = attrs["value"].(string)
return nil
}
}
return fmt.Errorf("failed to extract CSRF token: not found")
}
func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) {
body := url.Values{
"grant_type": {"authorization_code"},
"client_id": {client.Id},
"client_secret": {client.Secret},
"redirect_uri": {strings.Join(client.RedirectUris, ",")},
}
// add optional params if valid
if code != "" {
body["code"] = []string{code}
}
if state != "" {
body["state"] = []string{state}
}
res, err := http.PostForm(remoteUrl, body)
if err != nil {
return nil, fmt.Errorf("failed to get ID token: %s", err)
}
defer res.Body.Close()
// domain, _ := url.Parse("http://127.0.0.1")
// client.Jar.SetCookies(domain, res.Cookies())
return io.ReadAll(res.Body)
}

View file

@ -0,0 +1,424 @@
package opaal
import (
"crypto/rand"
"crypto/rsa"
"davidallendj/opaal/internal/oidc"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"time"
"github.com/davidallendj/go-utils/util"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
)
// TODO: change authorization code flow to use these instead
type AuthorizationCodeFlowEndpoints struct {
Login string
Token string
Identities string
TrustedIssuer string
Register string
}
func AuthorizationCodeWithConfig(config *Config, server *Server, client *Client, idp *oidc.IdentityProvider) error {
// check preconditions are met
err := verifyParams(config, server, client, idp)
if err != nil {
return err
}
// build the authorization URL to redirect user for social sign-in
state := config.Authentication.Flows["authorization_code"]["state"]
var authorizationUrl = client.BuildAuthorizationUrl(idp.Endpoints.Authorization, state)
// print the authorization URL for sharing
fmt.Printf("Login with identity provider:\n\n %s/login\n %s\n\n",
server.GetListenAddr(), authorizationUrl,
)
// automatically open browser to initiate login flow (only useful for testing and debugging)
if config.Options.OpenBrowser {
util.OpenUrl(authorizationUrl)
}
// authorize oauth client and listen for callback from provider
fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", server.GetListenAddr())
code, err := server.WaitForAuthorizationCode(authorizationUrl, "")
if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n")
} else if err != nil {
return fmt.Errorf("failed to start server: %s", err)
}
// start up another server in background to listen for success or failures
d := StartListener(server)
// use code from response and exchange for bearer token (with ID token)
bearerToken, err := client.FetchTokenFromAuthenticationServer(
code,
idp.Endpoints.Token,
state,
)
if err != nil {
return fmt.Errorf("failed to fetch token from issuer: %v", err)
}
// fmt.Printf("%v\n", string(bearerToken))
// unmarshal data to get id_token and access_token
var data map[string]any
err = json.Unmarshal([]byte(bearerToken), &data)
if err != nil || data == nil {
return fmt.Errorf("failed to unmarshal token: %v", err)
}
// make sure we have an ID token
if data["id_token"] == nil {
return fmt.Errorf("no ID token found...aborting")
}
// extract ID token from bearer as JSON string for easy consumption
idToken := data["id_token"].(string)
idJwtSegments, err := util.DecodeJwt(idToken)
if err != nil {
fmt.Printf("failed to parse ID token: %v\n", err)
} else {
fmt.Printf("id_token: %v\n", idToken)
if config.Options.DecodeIdToken {
if err != nil {
fmt.Printf("failed to decode JWT: %v\n", err)
} else {
for i, segment := range idJwtSegments {
// don't print last segment (signatures)
if i == len(idJwtSegments)-1 {
break
}
fmt.Printf("%s\n", string(segment))
}
}
}
fmt.Println()
}
// extract the access token to get the scopes
accessToken := data["access_token"].(string)
accessJwtSegments, err := util.DecodeJwt(accessToken)
if err != nil || len(accessJwtSegments) <= 0 {
fmt.Printf("failed to parse access token: %v\n", err)
} else {
fmt.Printf("access_token (from identity provider): %v\n", accessToken)
if config.Options.DecodeIdToken {
if err != nil {
fmt.Printf("failed to decode JWT: %v\n", err)
} else {
for i, segment := range accessJwtSegments {
// don't print last segment (signatures)
if i == len(accessJwtSegments)-1 {
break
}
fmt.Printf("%s\n", string(segment))
}
}
}
fmt.Println()
}
if !config.Options.ForwardToken {
// TODO: implement our own JWT to send to Hydra
// 1. verify that the JWT from the issuer is valid
key, ok := idp.Jwks.Key(0)
if !ok {
return fmt.Errorf("no key found in key set")
}
parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKey(jwa.RS256, key))
if err != nil {
return fmt.Errorf("failed to parse ID token: %v", err)
}
_, err = jwt.ParseString(accessToken, jwt.WithKeySet(idp.Jwks))
if err != nil {
return fmt.Errorf("failed to parse access token: %v", err)
}
_, err = jws.Verify([]byte(idToken), jws.WithKeySet(idp.Jwks), jws.WithValidateKey(true))
if err != nil {
return fmt.Errorf("failed to verify JWT: %v", err)
}
// 2. create a new JWKS (or just JWK) to be verified
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate private RSA k-ey: %v", err)
}
privateJwk, err := jwk.FromRaw(privateKey)
if err != nil {
return fmt.Errorf("failed to create private JWK: %v", err)
}
publicJwk, err := jwk.PublicKeyOf(privateJwk)
if err != nil {
return fmt.Errorf("failed to create public JWK: %v", err)
}
publicJwk.Set("kid", uuid.New().String())
// 3. add opaal's server host as a trusted issuer with JWK
fmt.Printf("Attempting to add issuer to authorization server...\n")
res, err := client.AddTrustedIssuer(
config.Authorization.RequestUrls.TrustedIssuers,
server.Addr,
publicJwk,
"1",
time.Second*3600,
)
if err != nil {
return fmt.Errorf("failed to add trusted issuer: %v", err)
}
fmt.Printf("%v\n", string(res))
// 4. create a new JWT based on the claims from the identity provider and sign
payload := parsedIdToken.PrivateClaims()
payload["iss"] = server.Addr
payload["aud"] = []string{config.Authorization.RequestUrls.Token}
payload["iat"] = time.Now().Unix()
payload["nbf"] = time.Now().Unix()
payload["exp"] = time.Now().Add(time.Second * 3600).Unix()
payload["sub"] = "1"
payloadJson, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
newToken, err := jws.Sign(payloadJson, jws.WithJSON(), jws.WithKey(jwa.RS256, privateJwk))
if err != nil {
return fmt.Errorf("failed to sign token: %v", err)
}
// sig = rsasha256(b64urlencode(header) + "." + b64urlencode(payload))
// signature := util.EncodeBase64() + util.EncodeBase64() +
// 5. dynamically register new OAuth client and authorize it
fmt.Printf("Registering new OAuth2 client with authorization server...\n")
res, err = client.RegisterOAuthClient(config.Authorization.RequestUrls.Register, []string{})
if err != nil {
return fmt.Errorf("failed to register client: %v", err)
}
fmt.Printf("%v\n", string(res))
// extract the client info from response
var clientData map[string]any
err = json.Unmarshal(res, &clientData)
if err != nil {
return fmt.Errorf("failed to unmarshal client data: %v", err)
} else {
// check for error first
errJson := clientData["error"]
if errJson == nil {
client.Id = clientData["client_id"].(string)
client.Secret = clientData["client_secret"].(string)
} else {
// delete client and create again
fmt.Printf("Attempting to delete client...\n")
err := client.DeleteOAuthClient(config.Authorization.RequestUrls.Clients)
if err != nil {
return fmt.Errorf("failed to delete OAuth client: %v", err)
}
fmt.Printf("Attempting to re-create client...\n")
res, err := client.CreateOAuthClient(config.Authorization.RequestUrls.Clients, []string{})
if err != nil {
return fmt.Errorf("failed to register client: %v", err)
}
fmt.Printf("%v\n", string(res))
}
}
// authorize the client
// fmt.Printf("Attempting to authorize client...\n")
// res, err = client.AuthorizeOAuthClient(config.Authorization.RequestUrls.Authorize)
// if err != nil {
// return fmt.Errorf("failed to authorize client: %v", err)
// }
// fmt.Printf("%v\n", string(res))
// 6. send JWT to authorization server and receive a access token
if config.Authorization.RequestUrls.Token != "" {
fmt.Printf("Fetching access token from authorization server...\n")
res, err := client.PerformTokenGrant(config.Authorization.RequestUrls.Token, string(newToken))
if err != nil {
return fmt.Errorf("failed to fetch access token: %v", err)
}
fmt.Printf("%s\n", res)
}
} else {
// extract the scope from access token claims
// var scope []string
// var accessJsonPayload map[string]any
// var accessJwtPayload []byte = accessJwtSegments[1]
// if accessJsonPayload != nil {
// err := json.Unmarshal(accessJwtPayload, &accessJsonPayload)
// if err != nil {
// return fmt.Errorf("failed to unmarshal JWT: %v", err)
// }
// scope = idJsonPayload["scope"].([]string)
// }
// create a new identity with identity and session manager if url is provided
// if config.RequestUrls.Identities != "" {
// fmt.Printf("Attempting to create a new identity...\n")
// err := client.CreateIdentity(config.RequestUrls.Identities, idToken)
// if err != nil {
// return fmt.Errorf("failed to create new identity: %v", err)
// }
// _, err = client.FetchIdentities(config.RequestUrls.Identities)
// if err != nil {
// return fmt.Errorf("failed to fetch identities: %v", err)
// }
// fmt.Printf("Created new identity successfully.\n\n")
// }
// extract the subject from ID token claims
var subject string
var audience []string
var idJsonPayload map[string]any
var idJwtPayload []byte = idJwtSegments[1]
if idJwtPayload != nil {
err := json.Unmarshal(idJwtPayload, &idJsonPayload)
if err != nil {
return fmt.Errorf("failed to unmarshal JWT: %v", err)
}
subject = idJsonPayload["sub"].(string)
audType := reflect.ValueOf(idJsonPayload["aud"])
switch audType.Kind() {
case reflect.String:
audience = append(audience, idJsonPayload["aud"].(string))
case reflect.Array:
audience = idJsonPayload["aud"].([]string)
}
} else {
return fmt.Errorf("failed to extract subject from ID token claims")
}
// fetch JWKS and add issuer to authentication server to submit ID token
fmt.Printf("Fetching JWKS from authentication server for verification...\n")
err = idp.FetchJwks()
if err != nil {
return fmt.Errorf("failed to fetch JWK: %v", err)
} else {
fmt.Printf("Successfully retrieved JWK from authentication server.\n\n")
fmt.Printf("Attempting to add issuer to authorization server...\n")
res, err := client.AddTrustedIssuerWithIdentityProvider(
config.Authorization.RequestUrls.TrustedIssuers,
idp,
subject,
time.Duration(1000),
)
if err != nil {
return fmt.Errorf("failed to add trusted issuer: %v", err)
}
fmt.Printf("%v\n", string(res))
}
// add client ID to audience
audience = append(audience, client.Id)
audience = append(audience, "http://127.0.0.1:4444/oauth2/token")
// try and register a new client with authorization server
fmt.Printf("Registering new OAuth2 client with authorization server...\n")
res, err := client.RegisterOAuthClient(config.Authorization.RequestUrls.Register, audience)
if err != nil {
return fmt.Errorf("failed to register client: %v", err)
}
fmt.Printf("%v\n", string(res))
// extract the client info from response
var clientData map[string]any
err = json.Unmarshal(res, &clientData)
if err != nil {
return fmt.Errorf("failed to unmarshal client data: %v", err)
} else {
// check for error first
errJson := clientData["error"]
if errJson == nil {
client.Id = clientData["client_id"].(string)
client.Secret = clientData["client_secret"].(string)
} else {
// delete client and create again
fmt.Printf("Attempting to delete client...\n")
err := client.DeleteOAuthClient(config.Authorization.RequestUrls.Clients)
if err != nil {
return fmt.Errorf("failed to delete OAuth client: %v", err)
}
fmt.Printf("Attempting to re-create client...\n")
res, err := client.CreateOAuthClient(config.Authorization.RequestUrls.Clients, audience)
if err != nil {
return fmt.Errorf("failed to register client: %v", err)
}
fmt.Printf("%v\n", string(res))
}
}
// authorize the client
// fmt.Printf("Attempting to authorize client...\n")
// res, err = client.AuthorizeOAuthClient(config.Authorization.RequestUrls.Authorize)
// if err != nil {
// return fmt.Errorf("failed to authorize client: %v", err)
// }
// fmt.Printf("%v\n", string(res))
// use ID token/user info to fetch access token from authentication server
if config.Authorization.RequestUrls.Token != "" {
fmt.Printf("Fetching access token from authorization server...\n")
res, err := client.PerformTokenGrant(config.Authorization.RequestUrls.Token, idToken)
if err != nil {
return fmt.Errorf("failed to fetch access token: %v", err)
}
fmt.Printf("%s\n", res)
}
}
var access_token []byte
d <- access_token
return nil
}
func verifyParams(config *Config, server *Server, client *Client, idp *oidc.IdentityProvider) error {
// make sure we have a valid server and client
if server == nil {
return fmt.Errorf("server not initialized or valid (server == nil)")
}
if client == nil {
return fmt.Errorf("client not initialized or valid (client == nil)")
}
if idp == nil {
return fmt.Errorf("identity provider not initialized or valid (idp == nil)")
}
// check if all appropriate parameters are set in config
if !HasRequiredConfigParams(config) {
return fmt.Errorf("required params not set correctly or missing")
}
return nil
}
func StartListener(server *Server) chan []byte {
d := make(chan []byte)
quit := make(chan bool)
go server.Serve(d)
go func() {
select {
case <-d:
fmt.Printf("got access token")
quit <- true
case <-quit:
close(d)
close(quit)
return
default:
}
}()
return d
}

248
internal/authorize.go Normal file
View file

@ -0,0 +1,248 @@
package opaal
import (
"bytes"
"davidallendj/opaal/internal/oidc"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/davidallendj/go-utils/httpx"
"github.com/davidallendj/go-utils/util"
"github.com/lestrrat-go/jwx/v2/jwk"
)
func (client *Client) AddTrustedIssuer(url string, issuer string, key jwk.Key, subject string, expires time.Duration) ([]byte, error) {
// hydra endpoint: POST /admin/trust/grants/jwt-bearer/issuers
quotedScopes := make([]string, len(client.Scope))
for i, s := range client.Scope {
quotedScopes[i] = fmt.Sprintf("\"%s\"", s)
}
jwkstr, err := json.Marshal(key)
if err != nil {
return nil, fmt.Errorf("failed to marshal JWK: %v", err)
}
// NOTE: Can also include "jwks_uri" instead
data := []byte(fmt.Sprintf("{"+
"\"allow_any_subject\": false,"+
"\"issuer\": \"%s\","+
"\"subject\": \"%s\","+
"\"expires_at\": \"%v\","+
"\"jwk\": %v,"+
"\"scope\": [ %s ]"+
"}", issuer, subject, time.Now().Add(expires).Format(time.RFC3339), string(jwkstr), strings.Join(quotedScopes, ",")))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
// req.Header.Add("X-CSRF-Token", client.CsrfToken.Value)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
req.Header.Add("Content-Type", "application/json")
// req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken))
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
return io.ReadAll(res.Body)
}
func (client *Client) AddTrustedIssuerWithIdentityProvider(url string, idp *oidc.IdentityProvider, subject string, expires time.Duration) ([]byte, error) {
// hydra endpoint: POST /admin/trust/grants/jwt-bearer/issuers
key, ok := idp.Jwks.Key(0)
if !ok {
return nil, fmt.Errorf("no keys found in key set")
}
return client.AddTrustedIssuer(url, idp.Issuer, key, subject, expires)
}
func (client *Client) IsOAuthClientRegistered(clientUrl string) (bool, error) {
_, _, err := httpx.MakeHttpRequest(clientUrl, http.MethodGet, nil, nil)
if err != nil {
return false, fmt.Errorf("failed to make request: %v", err)
}
// TODO: need to check contents of actual response
return true, nil
}
func (client *Client) GetOAuthClient(clientUrl string) error {
_, b, err := httpx.MakeHttpRequest(clientUrl, http.MethodGet, nil, nil)
if err != nil {
return fmt.Errorf("failed to make request: %v", err)
}
fmt.Printf("GetOAuthClient: %v\n", string(b))
var data []map[string]any
err = json.Unmarshal(b, &data)
if err != nil {
return fmt.Errorf("failed to unmarshal JSON: %v", err)
}
index := slices.IndexFunc(data, func(c map[string]any) bool {
if c["client_id"] == nil {
return false
}
return c["client_id"].(string) == client.Id
})
if index < 0 {
return fmt.Errorf("client not found")
}
// cast the redirect_uris from []any to []string and extract registration token
foundClient := data[index]
for _, uri := range foundClient["redirect_uris"].([]any) {
client.RedirectUris = append(client.RedirectUris, uri.(string))
}
if foundClient["registration-access-token"] != nil {
client.RegistrationAccessToken = foundClient["registration-access-token"].(string)
}
return nil
}
func (client *Client) CreateOAuthClient(registerUrl string, audience []string) ([]byte, error) {
// hydra endpoint: POST /clients
audience = util.QuoteArrayStrings(audience)
body := httpx.Body(fmt.Sprintf(`{
"client_id": "%s",
"client_name": "%s",
"client_secret": "%s",
"token_endpoint_auth_method": "client_secret_post",
"scope": "%s",
"grant_types": ["urn:ietf:params:oauth:grant-type:jwt-bearer"],
"response_types": ["token"],
"redirect_uris": ["http://127.0.0.1:3333/callback"],
"state": 12345678910,
"audience": [%s]
}`, client.Id, client.Id, client.Secret, strings.Join(client.Scope, " "), strings.Join(audience, ","),
))
headers := httpx.Headers{
"Content-Type": "application/json",
}
_, b, err := httpx.MakeHttpRequest(registerUrl, http.MethodPost, []byte(body), headers)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
var rjson map[string]any
err = json.Unmarshal(b, &rjson)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %v", err)
}
// check for error first
errJson := rjson["error"]
if errJson == nil {
// set the client ID and secret of registered client
client.Id = rjson["client_id"].(string)
client.Secret = rjson["client_secret"].(string)
client.RegistrationAccessToken = rjson["registration_access_token"].(string)
} else {
return b, nil
}
return b, err
}
func (client *Client) RegisterOAuthClient(registerUrl string, audience []string) ([]byte, error) {
// hydra endpoint: POST /oauth2/register
audience = util.QuoteArrayStrings(audience)
body := httpx.Body(fmt.Sprintf(`{
"client_name": "opaal",
"token_endpoint_auth_method": "client_secret_post",
"scope": "%s",
"grant_types": ["urn:ietf:params:oauth:grant-type:jwt-bearer"],
"response_types": ["token"],
"redirect_uris": ["http://127.0.0.1:3333/callback"],
"state": 12345678910,
"audience": [%s]
}`, strings.Join(client.Scope, " "), strings.Join(audience, ","),
))
headers := httpx.Headers{
"Content-Type": "application/json",
}
_, b, err := httpx.MakeHttpRequest(registerUrl, http.MethodPost, body, headers)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
var rjson map[string]any
err = json.Unmarshal(b, &rjson)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %v", err)
}
// check for error first
errJson := rjson["error"]
if errJson == nil {
// set the client ID and secret of registered client
client.Id = rjson["client_id"].(string)
client.Secret = rjson["client_secret"].(string)
client.RegistrationAccessToken = rjson["registration_access_token"].(string)
} else {
return b, nil
}
return b, err
}
func (client *Client) AuthorizeOAuthClient(authorizeUrl string) ([]byte, error) {
// set the authorization header
body := []byte("grant_type=" + url.QueryEscape("urn:ietf:params:oauth:grant-type:jwt-bearer") +
"&scope=" + strings.Join(client.Scope, "+") +
"&client_id=" + client.Id +
"&client_secret=" + client.Secret +
"&redirect_uri=" + url.QueryEscape("http://127.0.0.1:3333/callback") + // FIXME: needs to not be hardcorded
"&response_type=token" +
"&state=12345678910",
)
headers := httpx.Headers{
"Authorization": "Bearer " + client.RegistrationAccessToken,
"Content-Type": "application/x-www-form-urlencoded",
}
_, b, err := httpx.MakeHttpRequest(authorizeUrl, http.MethodPost, body, headers)
if err != nil {
return nil, fmt.Errorf("failed to make HTTP request: %v", err)
}
return b, nil
}
func (client *Client) PerformTokenGrant(clientUrl string, encodedJwt string) ([]byte, error) {
// hydra endpoint: /oauth/token
body := "grant_type=" + url.QueryEscape("urn:ietf:params:oauth:grant-type:jwt-bearer") +
"&client_id=" + client.Id +
"&client_secret=" + client.Secret +
"&redirect_uri=" + url.QueryEscape("http://127.0.0.1:3333/callback")
// add optional params if valid
if encodedJwt != "" {
body += "&assertion=" + encodedJwt
}
if client.Scope != nil || len(client.Scope) > 0 {
body += "&scope=" + strings.Join(client.Scope, "+")
}
headers := httpx.Headers{
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Bearer " + client.RegistrationAccessToken,
}
_, b, err := httpx.MakeHttpRequest(clientUrl, http.MethodPost, []byte(body), headers)
// set flow ID back to empty string to indicate a completed flow
client.FlowId = ""
return b, err
}
func (client *Client) DeleteOAuthClient(clientUrl string) error {
_, _, err := httpx.MakeHttpRequest(clientUrl+"/"+client.Id, http.MethodDelete, nil, nil)
if err != nil {
return fmt.Errorf("failed to make request: %v", err)
}
return nil
}

View file

@ -1,18 +1,11 @@
package opaal
import (
"bytes"
"davidallendj/opaal/internal/oidc"
"davidallendj/opaal/internal/util"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"slices"
"github.com/davidallendj/go-utils/mathx"
"golang.org/x/net/publicsuffix"
)
@ -20,258 +13,78 @@ type Client struct {
http.Client
Id string `yaml:"id"`
Secret string `yaml:"secret"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Issuer string `yaml:"issuer"`
RegistrationAccessToken string `yaml:"registration-access-token"`
RedirectUris []string `yaml:"redirect-uris"`
Scope []string `yaml:"scope"`
FlowId string
CsrfToken string
}
func NewClient() *Client {
return &Client{}
}
func NewClientWithConfig(config *Config) *Client {
jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
// make sure config is valid
if config == nil {
return nil
}
// make sure we have at least one client
clients := config.Authentication.Clients
if len(clients) <= 0 {
return nil
}
// use the first client found by default
return &Client{
Id: config.Client.Id,
Secret: config.Client.Secret,
RedirectUris: config.Client.RedirectUris,
Client: http.Client{Jar: jar},
Id: clients[0].Id,
Secret: clients[0].Secret,
Name: clients[0].Name,
Issuer: clients[0].Issuer,
Scope: clients[0].Scope,
RedirectUris: clients[0].RedirectUris,
}
}
func (client *Client) IsFlowInitiated() bool {
return client.FlowId != ""
func NewClientWithConfigByIndex(config *Config, index int) *Client {
size := len(config.Authentication.Clients)
index = mathx.Clamp(index, 0, size)
return nil
}
func (client *Client) BuildAuthorizationUrl(authEndpoint string, state string, responseType string, scope []string) string {
return authEndpoint + "?" + "client_id=" + client.Id +
"&redirect_uri=" + util.URLEscape(strings.Join(client.RedirectUris, ",")) +
"&response_type=" + responseType +
"&state=" + state +
"&scope=" + strings.Join(scope, "+") +
"&audience=http://127.0.0.1:4444/oauth2/token"
}
func (client *Client) InitiateLoginFlow(loginUrl string) error {
// kratos: GET /self-service/login/api
req, err := http.NewRequest("GET", loginUrl, bytes.NewBuffer([]byte{}))
if err != nil {
return fmt.Errorf("failed to make request: %v", err)
}
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
// get the flow ID from response
body, err := io.ReadAll(res.Body)
var flowData map[string]any
err = json.Unmarshal(body, &flowData)
if err != nil {
return fmt.Errorf("failed to unmarshal flow data: %v\n%v", err, string(body))
} else {
client.FlowId = flowData["id"].(string)
func NewClientWithConfigByName(config *Config, name string) *Client {
index := slices.IndexFunc(config.Authentication.Clients, func(c Client) bool {
return c.Name == name
})
if index >= 0 {
return &config.Authentication.Clients[index]
}
return nil
}
func (client *Client) FetchFlowData(flowUrl string) (map[string]any, error) {
//kratos: GET /self-service/login/flows?id={flowId}
func NewClientWithConfigByProvider(config *Config, issuer string) *Client {
index := slices.IndexFunc(config.Authentication.Clients, func(c Client) bool {
return c.Issuer == issuer
})
// replace {id} in string with actual value
flowUrl = strings.ReplaceAll(flowUrl, "{id}", client.FlowId)
req, err := http.NewRequest("GET", flowUrl, nil)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
if index >= 0 {
return &config.Authentication.Clients[index]
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
// get the flow data from response
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}
var flowData map[string]any
err = json.Unmarshal(body, &flowData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal flow data: %v", err)
}
return flowData, nil
}
func (client *Client) FetchCSRFToken(flowUrl string) error {
data, err := client.FetchFlowData(flowUrl)
if err != nil {
return fmt.Errorf("failed to fetch flow data: %v", err)
}
// iterate through nodes and extract the CSRF token attribute from the flow data
ui := data["ui"].(map[string]any)
nodes := ui["nodes"].([]any)
for _, node := range nodes {
attrs := node.(map[string]any)["attributes"].(map[string]any)
name := attrs["name"].(string)
if name == "csrf_token" {
client.CsrfToken = attrs["value"].(string)
return nil
}
}
return fmt.Errorf("failed to extract CSRF token: not found")
}
func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {client.Id},
"client_secret": {client.Secret},
"state": {state},
"redirect_uri": {strings.Join(client.RedirectUris, ",")},
func NewClientWithConfigById(config *Config, id string) *Client {
index := slices.IndexFunc(config.Authentication.Clients, func(c Client) bool {
return c.Id == id
})
if index >= 0 {
return &config.Authentication.Clients[index]
}
res, err := http.PostForm(remoteUrl, data)
if err != nil {
return nil, fmt.Errorf("failed to get ID token: %s", err)
}
defer res.Body.Close()
domain, _ := url.Parse("http://127.0.0.1")
client.Jar.SetCookies(domain, res.Cookies())
return io.ReadAll(res.Body)
}
func (client *Client) FetchTokenFromAuthorizationServer(remoteUrl string, jwt string, scope []string) ([]byte, error) {
// hydra endpoint: /oauth/token
data := "grant_type=" + util.URLEscape("urn:ietf:params:oauth:grant-type:jwt-bearer") +
"&client_id=" + client.Id +
"&client_secret=" + client.Secret +
"&scope=" + strings.Join(scope, "+") +
"&assertion=" + jwt
fmt.Printf("encoded params: %v\n\n", data)
req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer([]byte(data)))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
if err != nil {
return nil, fmt.Errorf("failed to make request: %s", err)
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
// set flow ID back to empty string to indicate a completed flow
client.FlowId = ""
return io.ReadAll(res.Body)
}
func (client *Client) AddTrustedIssuer(remoteUrl string, idp *oidc.IdentityProvider, subject string, duration time.Duration, scope []string) ([]byte, error) {
// hydra endpoint: POST /admin/trust/grants/jwt-bearer/issuers
if idp == nil {
return nil, fmt.Errorf("identity provided is nil")
}
jwkstr, err := json.Marshal(idp.Key)
if err != nil {
return nil, fmt.Errorf("failed to marshal JWK: %v", err)
}
quotedScopes := make([]string, len(scope))
for i, s := range scope {
quotedScopes[i] = fmt.Sprintf("\"%s\"", s)
}
// NOTE: Can also include "jwks_uri" instead
data := []byte(fmt.Sprintf("{"+
"\"allow_any_subject\": false,"+
"\"issuer\": \"%s\","+
"\"subject\": \"%s\","+
"\"expires_at\": \"%v\","+
"\"jwk\": %v,"+
"\"scope\": [ %s ]"+
"}", idp.Issuer, subject, time.Now().Add(duration).Format(time.RFC3339), string(jwkstr), strings.Join(quotedScopes, ",")))
fmt.Printf("%v\n", string(data))
req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer(data))
// req.Header.Add("X-CSRF-Token", client.CsrfToken.Value)
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
req.Header.Add("Content-Type", "application/json")
// req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken))
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
return io.ReadAll(res.Body)
}
func (client *Client) RegisterOAuthClient(registerUrl string, audience []string) ([]byte, error) {
// hydra endpoint: POST /clients
audience = util.QuoteArrayStrings(audience)
data := []byte(fmt.Sprintf(`{
"client_name": "%s",
"client_secret": "%s",
"token_endpoint_auth_method": "client_secret_post",
"scope": "openid email profile",
"grant_types": ["client_credentials", "urn:ietf:params:oauth:grant-type:jwt-bearer"],
"response_types": ["token"],
"audience": [%s]
}`, client.Id, client.Secret, strings.Join(audience, ",")))
req, err := http.NewRequest("POST", registerUrl, bytes.NewBuffer(data))
if err != nil {
return nil, fmt.Errorf("failed to make request: %v", err)
}
req.Header.Add("Content-Type", "application/json")
// req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken))
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
defer res.Body.Close()
return io.ReadAll(res.Body)
}
func (client *Client) CreateIdentity(remoteUrl string, idToken string) ([]byte, error) {
// kratos endpoint: /admin/identities
data := []byte(`{
"schema_id": "preset://email",
"traits": {
"email": "docs@example.org"
}
}`)
req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer(data))
if err != nil {
return nil, fmt.Errorf("failed to create a new request: %v", err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken))
// req.Header.Add("X-CSRF-Token", client.CsrfToken.Value)
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
return io.ReadAll(res.Body)
}
func (client *Client) FetchIdentities(remoteUrl string) ([]byte, error) {
req, err := http.NewRequest("GET", remoteUrl, bytes.NewBuffer([]byte{}))
if err != nil {
return nil, fmt.Errorf("failed to create a new request: %v", err)
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to do request: %v", err)
}
return io.ReadAll(res.Body)
return nil
}
func (client *Client) ClearCookies() {

View file

@ -0,0 +1,48 @@
package opaal
import (
"fmt"
)
type ClientCredentialsFlowParams struct {
State string `yaml:"state"`
ResponseType string `yaml:"response-type"`
}
type ClientCredentialsFlowEndpoints struct {
Create string
Authorize string
Token string
}
func ClientCredentials(eps ClientCredentialsFlowEndpoints, client *Client) error {
// register a new OAuth 2 client with authorization srever
_, err := client.CreateOAuthClient(eps.Create, nil)
if err != nil {
return fmt.Errorf("failed to register OAuth client: %v", err)
}
// authorize the client
_, err = client.AuthorizeOAuthClient(eps.Authorize)
if err != nil {
return fmt.Errorf("failed to authorize client: %v", err)
}
// request a token from the authorization server
res, err := client.PerformTokenGrant(eps.Token, "")
if err != nil {
return fmt.Errorf("failed to fetch token from authorization server: %v", err)
}
fmt.Printf("token: %v\n", string(res))
return nil
}
func ClientCredentialsWithConfig(config *Config, client *Client) error {
eps := ClientCredentialsFlowEndpoints{
Create: config.Authorization.RequestUrls.Clients,
Authorize: config.Authorization.RequestUrls.Authorize,
Token: config.Authorization.RequestUrls.Token,
}
return ClientCredentials(eps, client)
}

View file

@ -1,56 +1,77 @@
package opaal
import (
"davidallendj/opaal/internal/oidc"
"davidallendj/opaal/internal/util"
"log"
"os"
"path/filepath"
goutil "github.com/davidallendj/go-utils/util"
"gopkg.in/yaml.v2"
)
type FlowOptions map[string]string
type Flows map[string]FlowOptions
type Providers map[string]string
type Options struct {
DecodeIdToken bool `yaml:"decode-id-token"`
DecodeAccessToken bool `yaml:"decode-access-token"`
RunOnce bool `yaml:"run-once"`
OpenBrowser bool `yaml:"open-browser"`
FlowType string `yaml:"flow"`
CachePath string `yaml:"cache"`
LocalOnly bool `yaml:"local-only"`
ForwardToken bool `yaml:"forward-token"`
}
type RequestUrls struct {
Identities string `yaml:"identities"`
TrustedIssuers string `yaml:"trusted-issuers"`
Login string `yaml:"login"`
Clients string `yaml:"clients"`
Token string `yaml:"token"`
Authorize string `yaml:"authorize"`
Register string `yaml:"register"`
}
type Authentication struct {
Clients []Client `yaml:"clients"`
Flows Flows `yaml:"flows"`
}
type Authorization struct {
RequestUrls RequestUrls `yaml:"urls"`
}
type Config struct {
Version string `yaml:"version"`
Server Server `yaml:"server"`
Client Client `yaml:"client"`
IdentityProvider oidc.IdentityProvider `yaml:"oidc"`
State string `yaml:"state"`
ResponseType string `yaml:"response-type"`
Scope []string `yaml:"scope"`
ActionUrls ActionUrls `yaml:"urls"`
OpenBrowser bool `yaml:"open-browser"`
DecodeIdToken bool `yaml:"decode-id-token"`
DecodeAccessToken bool `yaml:"decode-access-token"`
RunOnce bool `yaml:"run-once"`
Providers Providers `yaml:"providers"`
Options Options `yaml:"options"`
Authentication Authentication `yaml:"authentication"`
Authorization Authorization `yaml:"authorization"`
}
func NewConfig() Config {
return Config{
Version: util.GetCommit(),
Version: goutil.GetCommit(),
Server: Server{
Host: "127.0.0.1",
Port: 3333,
},
Client: Client{
Id: "",
Secret: "",
RedirectUris: []string{""},
},
IdentityProvider: *oidc.NewIdentityProvider(),
State: util.RandomString(20),
ResponseType: "code",
Scope: []string{"openid", "profile", "email"},
ActionUrls: ActionUrls{
Identities: "",
AccessToken: "",
TrustedIssuers: "",
ServerConfig: "",
},
OpenBrowser: false,
DecodeIdToken: false,
DecodeAccessToken: false,
Options: Options{
DecodeIdToken: true,
DecodeAccessToken: true,
RunOnce: true,
OpenBrowser: false,
CachePath: "opaal.db",
FlowType: "authorization_code",
LocalOnly: false,
ForwardToken: false,
},
Authentication: Authentication{},
Authorization: Authorization{},
}
}
@ -86,3 +107,15 @@ func SaveDefaultConfig(path string) {
return
}
}
func HasRequiredConfigParams(config *Config) bool {
// must have athe requirements to perform login
hasClients := len(config.Authentication.Clients) > 0
hasServer := config.Server.Host != "" && config.Server.Port != 0 && config.Server.Callback != ""
hasEndpoints := config.Authorization.RequestUrls.TrustedIssuers != "" &&
config.Authorization.RequestUrls.Login != "" &&
config.Authorization.RequestUrls.Clients != "" &&
config.Authorization.RequestUrls.Authorize != "" &&
config.Authorization.RequestUrls.Token != ""
return hasClients && hasServer && hasEndpoints
}

156
internal/db/sqlite.go Normal file
View file

@ -0,0 +1,156 @@
package db
import (
"davidallendj/opaal/internal/oidc"
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
func CreateIdentityProvidersIfNotExists(path string) (*sqlx.DB, error) {
schema := `
CREATE TABLE IF NOT EXISTS identity_providers (
issuer TEXT NOT NULL,
authorization_endpoint TEXT,
token_endpoint TEXT,
revocation_endpoint TEXT,
introspection_endpoint TEXT,
userinfo_endpoint TEXT,
jwks_uri TEXT,
response_types_supported TEXT,
response_modes_supported TEXT,
grant_types_supported TEXT,
token_endpoint_auth_methods_supported TEXT,
subject_types_supported TEXT,
id_token_signing_alg_values_supported TEXT,
claim_types_supported TEXT,
claims_supported TEXT,
jwks TEXT,
PRIMARY KEY (issuer)
);
`
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("could not open database: %v", err)
}
db.MustExec(schema)
return db, nil
}
func InsertIdentityProviders(path string, providers *[]oidc.IdentityProvider) error {
if providers == nil {
return fmt.Errorf("states == nil")
}
// create database if it doesn't already exist
db, err := CreateIdentityProvidersIfNotExists(path)
if err != nil {
return err
}
// insert all probe states into db
tx := db.MustBegin()
for _, state := range *providers {
sql := `INSERT OR REPLACE INTO identity_providers
(
issuer,
authorization_endpoint,
token_endpoint,
revocation_endpoint,
introspection_endpoint,
userinfo_endpoint,
jwks_uri,
response_types_supported,
response_modes_supported,
grant_types_supported,
token_endpoint_auth_methods_supported,
subject_types_supported,
id_token_signing_alg_values_supported,
claim_types_supported,
claims_supported,
jwks
)
VALUES
(
:issuer,
:authorization_endpoint,
:token_endpoint,
:revocation_endpoint,
:introspection_endpoint,
:userinfo_endpoint,
:jwks_uri,
:response_types_supported,
:response_modes_supported,
:grant_types_supported,
:token_endpoint_auth_methods_supported,
:subject_types_supported,
:id_token_signing_alg_values_supported,
:claim_types_supported,
:claims_supported,
:jwks
);`
_, err := tx.NamedExec(sql, &state)
if err != nil {
fmt.Printf("could not execute transaction: %v\n", err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("could not commit transaction: %v", err)
}
return nil
}
func GetIdentityProvider(path string, issuer string) (*oidc.IdentityProvider, error) {
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("could not open database: %v", err)
}
results := &oidc.IdentityProvider{}
err = db.Select(&results, "SELECT * FROM magellan_scanned_ports ORDER BY host ASC, port ASC LIMIT 1;")
if err != nil {
return nil, fmt.Errorf("could not retrieve probes: %v", err)
}
return results, nil
}
func GetIdentityProviders(path string) ([]oidc.IdentityProvider, error) {
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("could not open database: %v", err)
}
results := []oidc.IdentityProvider{}
err = db.Select(&results, "SELECT * FROM magellan_scanned_ports ORDER BY host ASC, port ASC;")
if err != nil {
return nil, fmt.Errorf("could not retrieve probes: %v", err)
}
return results, nil
}
func DeleteIdentityProviders(path string, results *[]oidc.IdentityProvider) error {
if results == nil {
return fmt.Errorf("no probe results found")
}
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return fmt.Errorf("could not open database: %v", err)
}
tx := db.MustBegin()
for _, state := range *results {
sql := `DELETE FROM identity_providers WHERE host = :issuer;`
_, err := tx.NamedExec(sql, &state)
if err != nil {
fmt.Printf("could not execute transaction: %v\n", err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("could not commit transaction: %v", err)
}
return nil
}

32
internal/identities.go Normal file
View file

@ -0,0 +1,32 @@
package opaal
import (
"fmt"
"net/http"
"github.com/davidallendj/go-utils/httpx"
)
func (client *Client) CreateIdentity(url string, idToken string) error {
// kratos endpoint: /admin/identities
body := []byte(`{
"schema_id": "preset://email",
"traits": {
"email": "docs@example.org"
}
}`)
headers := httpx.Headers{
"Content-Type": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", idToken),
}
_, _, err := httpx.MakeHttpRequest(url, http.MethodPost, body, headers)
if err != nil {
return fmt.Errorf("failed to read response body: %v", err)
}
return nil
}
func (client *Client) FetchIdentities(remoteUrl string) ([]byte, error) {
_, b, err := httpx.MakeHttpRequest(remoteUrl, http.MethodGet, []byte{}, httpx.Headers{})
return b, err
}

View file

@ -1,260 +1,37 @@
package opaal
import (
"davidallendj/opaal/internal/db"
"davidallendj/opaal/internal/oidc"
"davidallendj/opaal/internal/util"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"time"
)
func Login(config *Config) error {
func Login(config *Config, client *Client, provider *oidc.IdentityProvider) error {
if config == nil {
return fmt.Errorf("config is not valid")
}
// initialize client that will be used throughout login flow
// make cache if it's not where expect
_, err := db.CreateIdentityProvidersIfNotExists(config.Options.CachePath)
if err != nil {
fmt.Printf("failed to create cache: %v\n", err)
}
if config.Options.FlowType == "authorization_code" {
// create a server if doing authorization code flow
server := NewServerWithConfig(config)
client := NewClientWithConfig(config)
// initiate the login flow and get a flow ID and CSRF token
{
err := client.InitiateLoginFlow(config.ActionUrls.Login)
err := AuthorizationCodeWithConfig(config, server, client, provider)
if err != nil {
return fmt.Errorf("failed to initiate login flow: %v", err)
fmt.Printf("failed to complete authorization code flow: %v\n", err)
}
err = client.FetchCSRFToken(config.ActionUrls.LoginFlowId)
} else if config.Options.FlowType == "client_credentials" {
err := ClientCredentialsWithConfig(config, client)
if err != nil {
return fmt.Errorf("failed to fetch CSRF token: %v", err)
}
}
// try and fetch server configuration if provided URL
idp := oidc.NewIdentityProvider()
if config.ActionUrls.ServerConfig != "" {
fmt.Printf("Fetching server configuration: %s\n", config.ActionUrls.ServerConfig)
err := idp.FetchServerConfig(config.ActionUrls.ServerConfig)
if err != nil {
return fmt.Errorf("failed to fetch server config: %v", err)
fmt.Printf("failed to complete client credentials flow: %v", err)
}
} else {
// otherwise, use what's provided in config file
idp.Issuer = config.IdentityProvider.Issuer
idp.Endpoints = config.IdentityProvider.Endpoints
idp.Supported = config.IdentityProvider.Supported
return fmt.Errorf("invalid grant type (options: authorization_code, client_credentials)")
}
// check if all appropriate parameters are set in config
if !HasRequiredParams(config) {
return fmt.Errorf("client ID must be set")
}
// build the authorization URL to redirect user for social sign-in
var authorizationUrl = client.BuildAuthorizationUrl(
idp.Endpoints.Authorization,
config.State,
config.ResponseType,
config.Scope,
)
// print the authorization URL for sharing
fmt.Printf("Login with identity provider:\n\n %s/login\n %s\n\n",
server.GetListenAddr(), authorizationUrl,
)
// automatically open browser to initiate login flow (only useful for testing)
if config.OpenBrowser {
util.OpenUrl(authorizationUrl)
}
// authorize oauth client and listen for callback from provider
fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", server.GetListenAddr())
code, err := server.WaitForAuthorizationCode(authorizationUrl)
if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n")
} else if err != nil {
return fmt.Errorf("failed to start server: %s", err)
}
if client == nil {
fmt.Printf("client did not initialize\n")
}
// start up another serve in background to listen for success or failures
d := make(chan []byte)
quit := make(chan bool)
var access_token []byte
go server.Serve(d)
go func() {
select {
case <-d:
fmt.Printf("got access token")
quit <- true
case <-quit:
close(d)
close(quit)
return
default:
}
}()
// use code from response and exchange for bearer token (with ID token)
bearerToken, err := client.FetchTokenFromAuthenticationServer(
code,
idp.Endpoints.Token,
config.State,
)
if err != nil {
return fmt.Errorf("failed to fetch token from issuer: %v", err)
}
// unmarshal data to get id_token and access_token
var data map[string]any
err = json.Unmarshal([]byte(bearerToken), &data)
if err != nil || data == nil {
return fmt.Errorf("failed to unmarshal token: %v", err)
}
// extract ID token from bearer as JSON string for easy consumption
idToken := data["id_token"].(string)
idJwtSegments, err := util.DecodeJwt(idToken)
if err != nil {
fmt.Printf("failed to parse ID token: %v\n", err)
} else {
fmt.Printf("id_token: %v\n", idToken)
if config.DecodeIdToken {
if err != nil {
fmt.Printf("failed to decode JWT: %v\n", err)
} else {
for i, segment := range idJwtSegments {
// don't print last segment (signatures)
if i == len(idJwtSegments)-1 {
break
}
fmt.Printf("%s\n", string(segment))
}
}
}
fmt.Println()
}
// extract the access token to get the scopes
accessToken := data["access_token"].(string)
accessJwtSegments, err := util.DecodeJwt(accessToken)
if err != nil || len(accessJwtSegments) <= 0 {
fmt.Printf("failed to parse access token: %v\n", err)
} else {
fmt.Printf("access_token: %v\n", accessToken)
if config.DecodeIdToken {
if err != nil {
fmt.Printf("failed to decode JWT: %v\n", err)
} else {
for i, segment := range accessJwtSegments {
// don't print last segment (signatures)
if i == len(accessJwtSegments)-1 {
break
}
fmt.Printf("%s\n", string(segment))
}
}
}
fmt.Println()
}
// extract the scope from access token claims
// var scope []string
// var accessJsonPayload map[string]any
// var accessJwtPayload []byte = accessJwtSegments[1]
// if accessJsonPayload != nil {
// err := json.Unmarshal(accessJwtPayload, &accessJsonPayload)
// if err != nil {
// return fmt.Errorf("failed to unmarshal JWT: %v", err)
// }
// scope = idJsonPayload["scope"].([]string)
// }
// create a new identity with identity and session manager if url is provided
if config.ActionUrls.Identities != "" {
fmt.Printf("Attempting to create a new identity...\n")
_, err := client.CreateIdentity(config.ActionUrls.Identities, idToken)
if err != nil {
return fmt.Errorf("failed to create new identity: %v", err)
}
_, err = client.FetchIdentities(config.ActionUrls.Identities)
if err != nil {
return fmt.Errorf("failed to fetch identities: %v", err)
}
fmt.Printf("Created new identity successfully.\n\n")
}
// extract the subject from ID token claims
var subject string
var audience []string
var idJsonPayload map[string]any
var idJwtPayload []byte = idJwtSegments[1]
if idJwtPayload != nil {
err := json.Unmarshal(idJwtPayload, &idJsonPayload)
if err != nil {
return fmt.Errorf("failed to unmarshal JWT: %v", err)
}
subject = idJsonPayload["sub"].(string)
audType := reflect.ValueOf(idJsonPayload["aud"])
switch audType.Kind() {
case reflect.String:
audience = append(audience, idJsonPayload["aud"].(string))
case reflect.Array:
audience = idJsonPayload["aud"].([]string)
}
} else {
return fmt.Errorf("failed to extract subject from ID token claims")
}
// fetch JWKS and add issuer to authentication server to submit ID token
fmt.Printf("Fetching JWKS from authentication server for verification...\n")
err = idp.FetchJwk(config.ActionUrls.JwksUri)
if err != nil {
return fmt.Errorf("failed to fetch JWK: %v", err)
} else {
fmt.Printf("Successfully retrieved JWK from authentication server.\n\n")
fmt.Printf("Attempting to add issuer to authorization server...\n")
res, err := client.AddTrustedIssuer(config.ActionUrls.TrustedIssuers, idp, subject, time.Duration(1000), config.Scope)
if err != nil {
return fmt.Errorf("failed to add trusted issuer: %v", err)
}
fmt.Printf("%v\n", string(res))
}
// try and register a new client with authorization server
fmt.Printf("Registering new OAuth2 client with authorization server...\n")
res, err := client.RegisterOAuthClient("http://127.0.0.1:4445/clients", audience)
if err != nil {
return fmt.Errorf("failed to register client: %v", err)
}
fmt.Printf("%v\n", string(res))
// extract the client info from response
var clientData map[string]any
err = json.Unmarshal(res, &clientData)
if err != nil {
return fmt.Errorf("failed to unmarshal client data: %v", err)
} else {
client.Id = clientData["client_id"].(string)
client.Secret = clientData["client_secret"].(string)
}
// use ID token/user info to fetch access token from authentication server
if config.ActionUrls.AccessToken != "" {
fmt.Printf("Fetching access token from authorization server...\n")
res, err := client.FetchTokenFromAuthorizationServer(config.ActionUrls.AccessToken, idToken, config.Scope)
if err != nil {
return fmt.Errorf("failed to fetch access token: %v", err)
}
fmt.Printf("%s\n", res)
}
d <- access_token
return nil
}

View file

@ -9,33 +9,33 @@ import (
"net/http"
"os"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/v2/jwk"
)
type IdentityProvider struct {
Issuer string `json:"issuer" yaml:"issuer"`
Endpoints Endpoints `json:"endpoints" yaml:"endpoints"`
Supported Supported `json:"supported" yaml:"supported"`
Key jwk.Key
Issuer string `db:"issuer" json:"issuer" yaml:"issuer"`
Endpoints Endpoints `db:"endpoints" json:"endpoints" yaml:"endpoints"`
Supported Supported `db:"supported" json:"supported" yaml:"supported"`
Jwks jwk.Set
}
type Endpoints struct {
Authorization string `json:"authorization_endpoint" yaml:"authorization"`
Token string `json:"token_endpoint" yaml:"token"`
Revocation string `json:"revocation_endpoint" yaml:"revocation"`
Introspection string `json:"introspection_endpoint" yaml:"introspection"`
UserInfo string `json:"userinfo_endpoint" yaml:"userinfo"`
Jwks string `json:"jwks_uri" yaml:"jwks_uri"`
Authorization string `db:"authorization_endpoint" json:"authorization_endpoint" yaml:"authorization"`
Token string `db:"token_endpoint" json:"token_endpoint" yaml:"token"`
Revocation string `db:"revocation_endpoint" json:"revocation_endpoint" yaml:"revocation"`
Introspection string `db:"introspection_endpoint" json:"introspection_endpoint" yaml:"introspection"`
UserInfo string `db:"userinfo_endpoint" json:"userinfo_endpoint" yaml:"userinfo"`
JwksUri string `db:"jwks_uri" json:"jwks_uri" yaml:"jwks_uri"`
}
type Supported struct {
ResponseTypes []string `json:"response_types_supported"`
ResponseModes []string `json:"response_modes_supported"`
GrantTypes []string `json:"grant_types_supported"`
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
SubjectTypes []string `json:"subject_types_supported"`
IdTokenSigningAlgValues []string `json:"id_token_signing_alg_values_supported"`
ClaimTypes []string `json:"claim_types_supported"`
Claims []string `json:"claims_supported"`
ResponseTypes []string `db:"response_types_supported" json:"response_types_supported"`
ResponseModes []string `db:"response_modes_supported" json:"response_modes_supported"`
GrantTypes []string `db:"grant_types_supported" json:"grant_types_supported"`
TokenEndpointAuthMethods []string `db:"token_endpoint_auth_methods_supported" json:"token_endpoint_auth_methods_supported"`
SubjectTypes []string `db:"subject_types_supported" json:"subject_types_supported"`
IdTokenSigningAlgValues []string `db:"id_token_signing_alg_values_supported" json:"id_token_signing_alg_values_supported"`
ClaimTypes []string `db:"claim_types_supported" json:"claim_types_supported"`
Claims []string `db:"claims_supported" json:"claims_supported"`
}
func NewIdentityProvider() *IdentityProvider {
@ -46,7 +46,7 @@ func NewIdentityProvider() *IdentityProvider {
Revocation: p.Issuer + "/oauth/revocation",
Introspection: p.Issuer + "/oauth/introspect",
UserInfo: p.Issuer + "/oauth/userinfo",
Jwks: p.Issuer + "/oauth/discovery/keys",
JwksUri: p.Issuer + "/oauth/discovery/keys",
}
p.Supported = Supported{
ResponseTypes: []string{"code"},
@ -109,55 +109,43 @@ func (p *IdentityProvider) LoadServerConfig(path string) error {
return nil
}
func (p *IdentityProvider) FetchServerConfig(url string) error {
func FetchServerConfig(issuer string) (*IdentityProvider, error) {
// make a request to a server's openid-configuration
req, err := http.NewRequest("GET", url, bytes.NewBuffer([]byte{}))
req, err := http.NewRequest(http.MethodGet, issuer+"/.well-known/openid-configuration", bytes.NewBuffer([]byte{}))
if err != nil {
return fmt.Errorf("failed to create a new request: %v", err)
return nil, fmt.Errorf("failed to create a new request: %v", err)
}
client := &http.Client{} // temp client to get info and not used in flow
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to do request: %v", err)
return nil, fmt.Errorf("failed to do request: %v", err)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %v", err)
return nil, fmt.Errorf("failed to read response body: %v", err)
}
var p IdentityProvider
err = p.ParseServerConfig(body)
if err != nil {
return fmt.Errorf("failed to parse server config: %v", err)
return nil, fmt.Errorf("failed to parse server config: %v", err)
}
return nil
return &p, nil
}
func (p *IdentityProvider) FetchJwk(url string) error {
if url == "" {
url = p.Endpoints.Jwks
func (p *IdentityProvider) FetchJwks() error {
if p.Endpoints.JwksUri == "" {
return fmt.Errorf("JWKS endpoint not set")
}
// fetch JWKS from identity provider
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
set, err := jwk.Fetch(ctx, url)
var err error
p.Jwks, err = jwk.Fetch(ctx, p.Endpoints.JwksUri)
if err != nil {
return fmt.Errorf("%v", err)
return fmt.Errorf("failed to fetch JWKS: %v", err)
}
// get the first JWK from set
for it := set.Iterate(context.Background()); it.Next(context.Background()); {
pair := it.Pair()
p.Key = pair.Value.(jwk.Key)
return nil
}
return fmt.Errorf("failed to load public key: %v", err)
}
func (p *IdentityProvider) GetRawJwk() (any, error) {
var rawkey any
if err := p.Key.Raw(&rawkey); err != nil {
return nil, fmt.Errorf("failed to get raw key: %v", err)
}
return rawkey, nil
}

View file

@ -1,15 +0,0 @@
package opaal
type ActionUrls struct {
Identities string `yaml:"identities"`
TrustedIssuers string `yaml:"trusted-issuers"`
AccessToken string `yaml:"access-token"`
ServerConfig string `yaml:"server-config"`
JwksUri string `yaml:"jwks_uri"`
Login string `yaml:"login"`
LoginFlowId string `yaml:"login-flow-id"`
}
func HasRequiredParams(config *Config) bool {
return config.Client.Id != "" && config.Client.Secret != ""
}

View file

@ -8,17 +8,20 @@ import (
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/nikolalohinski/gonja/v2"
"github.com/nikolalohinski/gonja/v2/exec"
)
type Server struct {
*http.Server
Host string `yaml:"host"`
Port int `yaml:"port"`
Callback string `yaml:"callback"`
}
func NewServerWithConfig(config *Config) *Server {
host := config.Server.Host
port := config.Server.Port
func NewServerWithConfig(conf *Config) *Server {
host := conf.Server.Host
port := conf.Server.Port
server := &Server{
Server: &http.Server{
Addr: fmt.Sprintf("%s:%d", host, port),
@ -37,7 +40,12 @@ func (s *Server) GetListenAddr() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port)
}
func (s *Server) WaitForAuthorizationCode(loginUrl string) (string, error) {
func (s *Server) WaitForAuthorizationCode(loginUrl string, callback string) (string, error) {
// check if callback is set
if callback == "" {
callback = "/oidc/callback"
}
var code string
r := chi.NewRouter()
r.Use(middleware.RedirectSlashes)
@ -46,14 +54,20 @@ func (s *Server) WaitForAuthorizationCode(loginUrl string) (string, error) {
})
r.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
// show login page with notice to redirect
loginPage, err := os.ReadFile("pages/index.html")
template, err := gonja.FromFile("pages/index.html")
if err != nil {
fmt.Printf("failed to load login page: %v\n", err)
panic(err)
}
loginPage = []byte(strings.ReplaceAll(string(loginPage), "{{loginUrl}}", loginUrl))
w.Write(loginPage)
data := exec.NewContext(map[string]interface{}{
"loginUrl": loginUrl,
})
r.HandleFunc("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
if err = template.Execute(w, data); err != nil { // Prints: Hello Bob!
panic(err)
}
})
r.HandleFunc(callback, func(w http.ResponseWriter, r *http.Request) {
// get the code from the OIDC provider
if r != nil {
code = r.URL.Query().Get("code")

View file

@ -1,109 +0,0 @@
package util
import (
"encoding/base64"
"math/rand"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"github.com/golang-jwt/jwt"
)
func RandomString(n int) string {
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
b := make([]byte, n)
// A rand.Int63() generates 63 random bits, enough for letterIdxMax letters!
for i, cache, remain := n-1, rand.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = rand.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}
func URLEscape(s string) string {
return url.QueryEscape(s)
}
func EncodeBase64(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
func DecodeJwt(encoded string) ([][]byte, error) {
// split the string into 3 segments and decode
segments := strings.Split(encoded, ".")
decoded := [][]byte{}
for _, segment := range segments {
bytes, _ := jwt.DecodeSegment(segment)
decoded = append(decoded, bytes)
}
return decoded, nil
}
func PathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// 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()
}
func GetCommit() string {
bytes, err := exec.Command("git", "rev --parse HEAD").Output()
if err != nil {
return ""
}
return string(bytes)
}
func Tokenize(s string) map[string]any {
tokens := make(map[string]any)
// find token enclosed in curly brackets
return tokens
}
func QuoteArrayStrings(arr []string) []string {
for i, v := range arr {
arr[i] = "\"" + v + "\""
}
return arr
}