Compare commits

...

38 commits
v0.2.0 ... main

Author SHA1 Message Date
David Allen
e0a8d43421
Fixed token fetch from IDP 2024-07-01 12:29:31 -06:00
David Allen
a7e0e73e45
Added response body print to debug ID token 2024-07-01 12:29:31 -06:00
David Allen
8c01ba897f
Added verbose print to show ID and access tokens from IDP 2024-07-01 12:29:31 -06:00
David Allen
a0cca97e7d
Merge pull request #13 from opencube-horizon/bugfix/token-handler
server: fix error reporting and logic for /keys handler
2024-05-28 08:32:47 -06:00
Tiziano Müller
b304361ce9
server: fix error reporting and logic for /keys handler
restores proper error reporting to include the host dialed, and
fixes the tautological comparison `jwks == nil` in the recovery path
to properly refetch the server config and try again as intended
2024-05-27 10:28:53 +02:00
David J. Allen
e929fac09e
Fixed some minor issues 2024-04-30 16:03:23 -06:00
David J. Allen
7022801fe9
Implemented IDP registered clients and callbacks 2024-04-30 14:44:57 -06:00
David J. Allen
cbb3e6f851
Updated login page hint 2024-04-30 14:43:50 -06:00
David J. Allen
bc5e693425
Changed the IDP oauth endpoints to oauth2 2024-04-30 12:45:02 -06:00
David J. Allen
2edc624c01
Resetted the default IDP endpoint values 2024-04-30 12:28:19 -06:00
David J. Allen
e940dc2dd9
Fixed IDP endpoint overrides not working correctly 2024-04-30 11:32:28 -06:00
David J. Allen
67683e9fca
Fixed small issues with not building 2024-04-30 09:01:05 -06:00
David J. Allen
73e4e50d44
Made it possible to override certain example IDP endpoints 2024-04-29 18:45:30 -06:00
David J. Allen
f10a771db6
Added missing IDP function back 2024-04-29 14:58:51 -06:00
David J. Allen
c67c6f75a2
Added audience override for token sent to authorization server 2024-04-29 14:52:25 -06:00
20ba7bc735
Separated server and IDP code into different files 2024-04-28 13:23:25 -06:00
David J. Allen
447c9fb5e9
Reverted token fetch in server 2024-04-26 15:56:25 -06:00
David Allen
5825273f0c
Merge pull request #12 from OpenCHAMI/main
Update main
2024-04-26 15:44:02 -06:00
David Allen
5aefc2ffcf
Merge branch 'davidallendj:main' into main 2024-04-26 15:42:56 -06:00
David Allen
890a268e11
Merge pull request #11 from OpenCHAMI/login
Changed login button to use <a href> tags with no JS
2024-04-26 15:41:20 -06:00
David J. Allen
20206ea4c1
Changed login button to use <a href> tags with no JS 2024-04-26 15:37:09 -06:00
Alex Lovell-Troy
0dc5754b4d
Backing off arm64 build 2024-04-24 14:08:15 -04:00
Alex Lovell-Troy
78ef71e94c
Update build_release.yml
Removing broken attestation for the moment and adding a manual workflow trigger
2024-04-24 13:59:06 -04:00
David Allen
8d3e1085c8
Merge pull request #3 from synackd/add-arm64
Goreleaser: Add arm64 builds
2024-04-24 11:12:52 -06:00
Devon Bautista
bea426c47d Goreleaser: Add arm64 builds 2024-04-24 10:04:15 -06:00
David Allen
5650cf6985
Merge pull request #10 from davidallendj/refactor-login
Refactor login
2024-04-23 13:20:12 -06:00
David J. Allen
6d2f488a6b
Refactored login page and process 2024-04-23 13:17:41 -06:00
David J. Allen
61a35c165d
WIP refactoring login 2024-04-18 16:02:43 -06:00
David J. Allen
2e117bea36
Removed write headers for .well-known endpoints 2024-04-18 12:55:38 -06:00
David J. Allen
2762a95da5
Update README.md about internal IDP 2024-04-18 12:55:16 -06:00
David J. Allen
b45821e587
Changed .well-known endpoints to write status codes 2024-04-18 12:40:25 -06:00
David J. Allen
57166c5ee4
Added a login page for identity provider server 2024-04-17 17:27:03 -06:00
David J. Allen
0ca88e1a84
Added identity provider server 2024-04-17 17:26:29 -06:00
David J. Allen
059fb37aaf
Added serve command to start an identity provider server 2024-04-17 17:26:03 -06:00
David J. Allen
7529d2b7dd
Updated static web pages 2024-04-17 17:25:28 -06:00
David J. Allen
d7990807f0
Minor changes 2024-04-17 17:24:56 -06:00
David J. Allen
af79cfe254
Changed default expiration for token 2024-04-17 17:23:36 -06:00
David J. Allen
13a35081d7
Updated go deps 2024-04-17 17:19:11 -06:00
19 changed files with 731 additions and 227 deletions

View file

@ -4,6 +4,7 @@
name: Release with goreleaser name: Release with goreleaser
on: on:
workflow_dispatch:
push: push:
tags: tags:
- v* - v*
@ -52,7 +53,3 @@ jobs:
echo "fs.writeFileSync('digest.txt', firstNonNullDigest);" >> process.js echo "fs.writeFileSync('digest.txt', firstNonNullDigest);" >> process.js
node process.js node process.js
echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT
- name: Attest opaal binary
uses: github-early-access/generate-build-provenance@main
with:
subject-path: opaal

View file

@ -53,4 +53,4 @@ changelog:
# github: # github:
# name_template: "{{.Version}}" # name_template: "{{.Version}}"
# prerelease: auto # prerelease: auto
# mode: append # mode: append

View file

@ -28,12 +28,39 @@ These commands will create a default config, then start the login process. Maybe
- [Gitlab](https://about.gitlab.com/) - [Gitlab](https://about.gitlab.com/)
- [Forgejo](https://forgejo.org/) (fork of Gitea) - [Forgejo](https://forgejo.org/) (fork of Gitea)
The tool is now able to run an internal example identity provider using the `serve` subcommand.
```bash
./opaal serve --config config.yaml
```
This will start a server that allows you to login with `opaal` itself. Currently, it is only has one example user to use for log in. The username and password combination is `ochami:ochami`. It uses the same config file as before with additional parameters set in the config file:
```yaml
server:
...
issuer:
host: "127.0.0.1"
port: 3332
authentication:
clients:
- id: "ochami"
secret: "ochami"
name: "ochami"
issuer: "http://127.0.0.1:3332"
redirect-uris:
- "http://127.0.0.1:3333/oidc/callback"
```
See the [Configuration](#configuration) section for the entire config file.
### Authorization Code Flow ### Authorization Code Flow
`opaal` has the ability to completely execute the authorization code and return an access token from an authorization server using social sign-in. The process works as follows: `opaal` has the ability to completely execute the authorization code and return an access token from an authorization server using social sign-in. The process works as follows:
1. Click the authorization link or navigate to the hosted endpoint in your browser (127.0.0.1:3333 by default) 1. Click the authorization link or navigate to the hosted endpoint in your browser (127.0.0.1:3333 by default)
- Alternatively, you can use a link produced - Alternatively, you can use a link produced
2. Login using identity provider credentials 2. Login using identity provider credentials
3. Authorize application registered with IdP 3. Authorize application registered with IdP
4. IdP redirects to specified redirect URI 4. IdP redirects to specified redirect URI
@ -41,27 +68,29 @@ These commands will create a default config, then start the login process. Maybe
- ...verifying the authenticity of the ID token from identity provider with its JWKS - ...verifying the authenticity of the ID token from identity provider with its JWKS
- ...adds itself as a trusted issuer to the authorization server with it's own JWK - ...adds itself as a trusted issuer to the authorization server with it's own JWK
- ...creates a new signed JWT to send to the authorization server with the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type - ...creates a new signed JWT to send to the authorization server with the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type
- ... returns an access token that can be used by services protected by the authorization server - ... returns an access token that can be used by services protected by the authorization server
*After receiving the ID token, the rest of the flow requires the appropriate URLs to be set to continue. *After receiving the ID token, the rest of the flow requires the appropriate URLs to be set to continue.
### Client Credentials Flow ### Client Credentials Flow
`opaal` also has
## Configuration ## Configuration
Here is an example configuration file: Here is an example configuration file:
```yaml ```yaml
version: "0.0.1" version: "0.3.2"
server: server:
host: "127.0.0.1" host: "127.0.0.1"
port: 3333 port: 3333
callback: "/oidc/callback" callback: "/oidc/callback"
issuer:
host: "127.0.0.1"
port: 3332
providers: providers:
opaal: "https://127.0.0.1:3332"
forgejo: "http://127.0.0.1:3000" forgejo: "http://127.0.0.1:3000"
authentication: authentication:
@ -83,7 +112,17 @@ authentication:
client-credentials: client-credentials:
authorization: authorization:
urls: token:
forwarding: false
refresh: false
duration: 16h
scope:
- smd.read
key-path: ./keys
endpoints:
issuer: http://127.0.0.1:4444
config: http://127.0.0.1:4444/.well-known/openid-configuration
jwks: http://127.0.0.1:4444/.well-known/jwks.json
#identities: http://127.0.0.1:4434/admin/identities #identities: http://127.0.0.1:4434/admin/identities
trusted-issuers: http://127.0.0.1:4445/admin/trust/grants/jwt-bearer/issuers 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 login: http://127.0.0.1:4433/self-service/login/api
@ -91,17 +130,14 @@ authorization:
authorize: http://127.0.0.1:4444/oauth2/auth authorize: http://127.0.0.1:4444/oauth2/auth
register: http://127.0.0.1:4444/oauth2/register register: http://127.0.0.1:4444/oauth2/register
token: http://127.0.0.1:4444/oauth2/token token: http://127.0.0.1:4444/oauth2/token
clients:
- id: bss
secret: IAMBSS
options: options:
decode-id-token: true
decode-access-token: true
run-once: true run-once: true
open-browser: false open-browser: false
forward: false flow: authorization_code
cache-only: false
verbose: true
``` ```
## Troubleshooting ## Troubleshooting

View file

@ -2,12 +2,9 @@ package cmd
import ( import (
opaal "davidallendj/opaal/internal" opaal "davidallendj/opaal/internal"
cache "davidallendj/opaal/internal/cache/sqlite"
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc"
"fmt" "fmt"
"os" "os"
"slices"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -25,68 +22,68 @@ var loginCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
for { for {
// try and find client with valid identity provider config // try and find client with valid identity provider config
var provider *oidc.IdentityProvider // var provider *oidc.IdentityProvider
if target != "" { // if target != "" {
// only try to use client with name give // // only try to use client with name give
index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { // index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool {
return target == c.Name // return target == c.Name
}) // })
if index < 0 { // if index < 0 {
fmt.Printf("could not find the target client listed by name") // fmt.Printf("could not find the target client listed by name")
os.Exit(1) // os.Exit(1)
} // }
client := config.Authentication.Clients[index] // client := config.Authentication.Clients[index]
_, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) // _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer)
if err != nil { // if err != nil {
} // }
} else if targetIndex >= 0 { // } else if targetIndex >= 0 {
// only try to use client by index // // only try to use client by index
targetCount := len(config.Authentication.Clients) - 1 // targetCount := len(config.Authentication.Clients) - 1
if targetIndex > targetCount { // if targetIndex > targetCount {
fmt.Printf("target index out of range (found %d)", targetCount) // fmt.Printf("target index out of range (found %d)", targetCount)
} // }
client := config.Authentication.Clients[targetIndex] // client := config.Authentication.Clients[targetIndex]
_, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer) // _, err := cache.GetIdentityProvider(config.Options.CachePath, client.Issuer)
if err != nil { // if err != nil {
} // }
} else { // } else {
for _, c := range config.Authentication.Clients { // for _, c := range config.Authentication.Clients {
// try to get identity provider info locally first // // try to get identity provider info locally first
_, err := cache.GetIdentityProvider(config.Options.CachePath, c.Issuer) // _, err := cache.GetIdentityProvider(config.Options.CachePath, c.Issuer)
if err != nil && !config.Options.CacheOnly { // if err != nil && !config.Options.CacheOnly {
fmt.Printf("fetching config from issuer: %v\n", c.Issuer) // fmt.Printf("fetching config from issuer: %v\n", c.Issuer)
// try to get info remotely by fetching // // try to get info remotely by fetching
provider, err = oidc.FetchServerConfig(c.Issuer) // provider, err = oidc.FetchServerConfig(c.Issuer)
if err != nil { // if err != nil {
fmt.Printf("failed to fetch server config: %v\n", err) // fmt.Printf("failed to fetch server config: %v\n", err)
continue // continue
} // }
client = c // client = c
// fetch the provider's JWKS // // fetch the provider's JWKS
err := provider.FetchJwks() // err := provider.FetchJwks()
if err != nil { // if err != nil {
fmt.Printf("failed to fetch JWKS: %v\n", err) // fmt.Printf("failed to fetch JWKS: %v\n", err)
} // }
break // break
} // }
// only test the first if --run-all flag is not set // // only test the first if --run-all flag is not set
if !config.Authentication.TestAllClients { // if !config.Authentication.TestAllClients {
fmt.Printf("stopping after first test...\n\n\n") // fmt.Printf("stopping after first test...\n\n\n")
break // break
} // }
} // }
} // }
if provider == nil { // if provider == nil {
fmt.Printf("failed to retrieve provider config\n") // fmt.Printf("failed to retrieve provider config\n")
os.Exit(1) // os.Exit(1)
} // }
// start the listener // start the listener
err := opaal.Login(&config, &client, provider) err := opaal.Login(&config)
if err != nil { if err != nil {
fmt.Printf("%v\n", err) fmt.Printf("%v\n", err)
os.Exit(1) os.Exit(1)
@ -115,3 +112,13 @@ func init() {
loginCmd.MarkFlagsMutuallyExclusive("target.name", "target.index") loginCmd.MarkFlagsMutuallyExclusive("target.name", "target.index")
rootCmd.AddCommand(loginCmd) rootCmd.AddCommand(loginCmd)
} }
func MakeButton(url string, text string) string {
// check if we have http:// a
html := "<input type=\"button\" "
html += "class=\"button\" "
html += fmt.Sprintf("onclick=\"window.location.href='%s';\" ", url)
html += fmt.Sprintf("value=\"%s\"", text)
return html
// return "<a href=\"" + url + "\"> " + text + "</a>"
}

40
cmd/serve.go Normal file
View file

@ -0,0 +1,40 @@
package cmd
import (
opaal "davidallendj/opaal/internal"
"davidallendj/opaal/internal/oidc"
"errors"
"fmt"
"net/http"
"github.com/spf13/cobra"
)
var (
endpoints oidc.Endpoints
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start an simple, bare minimal identity provider server",
Long: "The built-in identity provider is not (nor meant to be) a complete OIDC implementation and behaves like an external IdP",
Run: func(cmd *cobra.Command, args []string) {
s := opaal.NewServerWithConfig(&config)
// FIXME: change how the server address is set with `NewServerWithConfig`
s.Server.Addr = fmt.Sprintf("%s:%d", s.Issuer.Host, s.Issuer.Port)
err := s.StartIdentityProvider()
if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Identity provider server closed.\n")
} else if err != nil {
fmt.Printf("failed to start server: %v", err)
}
},
}
func init() {
serveCmd.Flags().StringVar(&endpoints.Authorization, "endpoints.authorization", "", "set the authorization endpoint for the identity provider")
serveCmd.Flags().StringVar(&endpoints.Token, "endpoints.token", "", "set the token endpoint for the identity provider")
serveCmd.Flags().StringVar(&endpoints.JwksUri, "endpoints.jwks_uri", "", "set the JWKS endpoints for the identity provider")
rootCmd.AddCommand(serveCmd)
}

12
go.mod
View file

@ -3,7 +3,7 @@ module davidallendj/opaal
go 1.22.0 go 1.22.0
require ( require (
github.com/davidallendj/go-utils v0.0.0-20240310194826-5a1300f3bcbf github.com/davidallendj/go-utils v0.0.0-20240417195221-95765f3b9bad
github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/chi/v5 v5.0.12
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.3.5 github.com/jmoiron/sqlx v1.3.5
@ -11,12 +11,12 @@ require (
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.22
github.com/nikolalohinski/gonja/v2 v2.2.0 github.com/nikolalohinski/gonja/v2 v2.2.0
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
golang.org/x/net v0.22.0 golang.org/x/net v0.24.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logr/logr v1.3.0 // indirect github.com/go-logr/logr v1.3.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
@ -39,8 +39,8 @@ require (
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/crypto v0.21.0 // indirect golang.org/x/crypto v0.22.0 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
) )

28
go.sum
View file

@ -8,10 +8,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidallendj/go-utils v0.0.0-20240310194826-5a1300f3bcbf h1:gY89rDLnc+70S0JcyHoPGU+XFpMwY1iVYNQzdC/qAHc= github.com/davidallendj/go-utils v0.0.0-20240417195221-95765f3b9bad h1:WODRnqFS2CZfraXy7Nvh5qekM42/L5kvLoLMqNr50e8=
github.com/davidallendj/go-utils v0.0.0-20240310194826-5a1300f3bcbf/go.mod h1:kiv3jEnBbeueMNNJclaMMJULL/tjqJ6wc136d+uxqSs= github.com/davidallendj/go-utils v0.0.0-20240417195221-95765f3b9bad/go.mod h1:kiv3jEnBbeueMNNJclaMMJULL/tjqJ6wc136d+uxqSs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
@ -96,20 +96,20 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.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 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -2,6 +2,7 @@ package opaal
import ( import (
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@ -45,6 +46,7 @@ type TokenOptions struct {
Forwarding bool `yaml:"forwarding"` Forwarding bool `yaml:"forwarding"`
Refresh bool `yaml:"refresh"` Refresh bool `yaml:"refresh"`
Scope []string `yaml:"scope"` Scope []string `yaml:"scope"`
//TODO: allow specifying audience in returned token
} }
type Authentication struct { type Authentication struct {
@ -55,9 +57,10 @@ type Authentication struct {
} }
type Authorization struct { type Authorization struct {
Token TokenOptions `yaml:"token"`
Endpoints Endpoints `yaml:"endpoints"` Endpoints Endpoints `yaml:"endpoints"`
KeyPath string `yaml:"key-path"` KeyPath string `yaml:"key-path"`
Token TokenOptions `yaml:"token"` Audience []string `yaml:"audience"` // NOTE: overrides the "aud" claim in token sent to authorization server
} }
type Config struct { type Config struct {
@ -70,11 +73,18 @@ type Config struct {
} }
func NewConfig() Config { func NewConfig() Config {
return Config{ config := Config{
Version: goutil.GetCommit(), Version: goutil.GetCommit(),
Server: server.Server{ Server: server.Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 3333, Port: 3333,
Issuer: server.IdentityProviderServer{
Endpoints: oidc.Endpoints{
Authorization: "",
Token: "",
JwksUri: "",
},
},
}, },
Options: Options{ Options: Options{
RunOnce: true, RunOnce: true,
@ -97,6 +107,7 @@ func NewConfig() Config {
}, },
}, },
} }
return config
} }
func LoadConfig(path string) Config { func LoadConfig(path string) Config {

View file

@ -4,7 +4,6 @@ import (
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oauth"
"davidallendj/opaal/internal/oidc"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -19,14 +18,15 @@ import (
) )
type JwtBearerFlowParams struct { type JwtBearerFlowParams struct {
AccessToken string AccessToken string
IdToken string IdToken string
IdentityProvider *oidc.IdentityProvider // IdentityProvider *oidc.IdentityProvider
TrustedIssuer *oauth.TrustedIssuer TrustedIssuer *oauth.TrustedIssuer
Client *oauth.Client Client *oauth.Client
Refresh bool Audience []string
Verbose bool Refresh bool
KeyPath string Verbose bool
KeyPath string
} }
type JwtBearerFlowEndpoints struct { type JwtBearerFlowEndpoints struct {
@ -39,28 +39,36 @@ type JwtBearerFlowEndpoints struct {
func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (string, error) { func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (string, error) {
// 1. verify that the JWT from the issuer is valid using all keys // 1. verify that the JWT from the issuer is valid using all keys
var ( var (
idp = params.IdentityProvider // idp = params.IdentityProvider
accessToken = params.AccessToken accessToken = params.AccessToken
idToken = params.IdToken idToken = params.IdToken
client = params.Client client = params.Client
trustedIssuer = params.TrustedIssuer trustedIssuer = params.TrustedIssuer
verbose = params.Verbose verbose = params.Verbose
) )
// pre-condition checks to make sure certain variables are set
if client == nil {
return "", fmt.Errorf("invalid client (client is nil)")
}
if verbose {
fmt.Printf("ID token (IDP): %s\n access token (IDP): %s", accessToken, idToken)
}
if accessToken != "" { if accessToken != "" {
_, err := jws.Verify([]byte(accessToken), jws.WithKeySet(idp.KeySet), jws.WithValidateKey(true)) _, err := jws.Verify([]byte(accessToken), jws.WithKeySet(client.Provider.KeySet), jws.WithValidateKey(true))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to verify access token: %v", err) return "", fmt.Errorf("failed to verify access token: %v", err)
} }
} }
if idToken != "" { if idToken != "" {
_, err := jws.Verify([]byte(idToken), jws.WithKeySet(idp.KeySet), jws.WithValidateKey(true)) _, err := jws.Verify([]byte(idToken), jws.WithKeySet(client.Provider.KeySet), jws.WithValidateKey(true))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to verify ID token: %v", err) return "", fmt.Errorf("failed to verify ID token: %v", err)
} }
} }
// 2. Check if we are already registered as a trusted issuer with authorization server... // TODO: 2. Check if we are already registered as a trusted issuer with authorization server...
// 3.a if not, create a new JWKS (or just JWK) to be verified // 3.a if not, create a new JWKS (or just JWK) to be verified
var ( var (
@ -77,7 +85,7 @@ func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (s
if err != nil { if err != nil {
return "", fmt.Errorf("failed to generate new RSA key: %v", err) return "", fmt.Errorf("failed to generate new RSA key: %v", err)
} }
privateJwk, publicJwk, err = cryptox.GenerateJwkKeyPairFromPrivateKey(privateKey) privateJwk, publicJwk, err = GenerateJwkKeyPairFromPrivateKey(privateKey) // FIXME: needs to pull correct version from cryptox
if err != nil { if err != nil {
return "", fmt.Errorf("failed to generate JWK pair from private key: %v", err) return "", fmt.Errorf("failed to generate JWK pair from private key: %v", err)
} }
@ -126,18 +134,24 @@ func NewJwtBearerFlow(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) (s
// TODO: add trusted issuer to cache if successful // TODO: add trusted issuer to cache if successful
// 4. create a new JWT based on the claims from the identity provider and sign // 4. create a new JWT based on the claims from the identity provider and sign
parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKeySet(idp.KeySet)) parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKeySet(client.Provider.KeySet))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse ID token: %v", err) return "", fmt.Errorf("failed to parse ID token: %v", err)
} }
payload := parsedIdToken.PrivateClaims() payload := parsedIdToken.PrivateClaims()
payload["iss"] = trustedIssuer.Issuer payload["iss"] = trustedIssuer.Issuer
payload["aud"] = []string{eps.Token} payload["aud"] = []string{eps.Token}
payload["iat"] = time.Now().Unix() payload["iat"] = time.Now().Unix()
payload["nbf"] = time.Now().Unix() payload["nbf"] = time.Now().Unix()
payload["exp"] = time.Now().Add(time.Second * 3600).Unix() payload["exp"] = time.Now().Add(time.Second * 3600 * 16).Unix()
payload["sub"] = "opaal" payload["sub"] = "opaal"
// if an "audience" value is set, then override the token endpoint value
if len(params.Audience) > 0 {
payload["aud"] = params.Audience
}
// include the offline_access scope if refresh tokens are enabled // include the offline_access scope if refresh tokens are enabled
if params.Refresh { if params.Refresh {
v, ok := payload["scope"] v, ok := payload["scope"]
@ -241,7 +255,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
var ( var (
client = params.Client client = params.Client
idToken = params.IdToken idToken = params.IdToken
idp = params.IdentityProvider // idp = params.IdentityProvider
verbose = params.Verbose verbose = params.Verbose
) )
@ -249,7 +263,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
if verbose { if verbose {
fmt.Printf("Fetching JWKS from authentication server for verification...\n") fmt.Printf("Fetching JWKS from authentication server for verification...\n")
} }
err := idp.FetchJwks() err := client.Provider.FetchJwks()
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch JWK: %v", err) return fmt.Errorf("failed to fetch JWK: %v", err)
} else { } else {
@ -259,7 +273,7 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
} }
ti := &oauth.TrustedIssuer{ ti := &oauth.TrustedIssuer{
Issuer: idp.Issuer, Issuer: client.Provider.Issuer,
Subject: "1", Subject: "1",
ExpiresAt: time.Now().Add(time.Second * 3600), ExpiresAt: time.Now().Add(time.Second * 3600),
} }
@ -339,3 +353,15 @@ func ForwardToken(eps JwtBearerFlowEndpoints, params JwtBearerFlowParams) error
} }
return nil return nil
} }
func GenerateJwkKeyPairFromPrivateKey(privateKey *rsa.PrivateKey) (jwk.Key, jwk.Key, error) {
privateJwk, err := jwk.FromRaw(privateKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to create private JWK: %v", err)
}
publicJwk, err := jwk.PublicKeyOf(privateJwk)
if err != nil {
return nil, nil, fmt.Errorf("failed to create public JWK: %v", err)
}
return privateJwk, publicJwk, nil
}

View file

@ -12,19 +12,11 @@ import (
"time" "time"
) )
func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider) error { func Login(config *Config) error {
if config == nil { if config == nil {
return fmt.Errorf("invalid config") return fmt.Errorf("invalid config")
} }
if client == nil {
return fmt.Errorf("invalid client")
}
if provider == nil {
return fmt.Errorf("invalid identity provider")
}
// make cache if it's not where expect // make cache if it's not where expect
_, err := cache.CreateIdentityProvidersIfNotExists(config.Options.CachePath) _, err := cache.CreateIdentityProvidersIfNotExists(config.Options.CachePath)
if err != nil { if err != nil {
@ -39,25 +31,20 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
} }
// print the authorization URL for sharing // print the authorization URL for sharing
var authorizationUrl = client.BuildAuthorizationUrl(provider.Endpoints.Authorization, state)
s := NewServerWithConfig(config) s := NewServerWithConfig(config)
fmt.Printf("Login with identity provider:\n\n %s/login\n %s\n\n", s.State = state
s.GetListenAddr(), authorizationUrl,
)
var button = MakeButton(authorizationUrl, "Login with "+client.Name)
var authzClient = oauth.NewClient() var authzClient = oauth.NewClient()
authzClient.Scope = config.Authorization.Token.Scope authzClient.Scope = config.Authorization.Token.Scope
// authorize oauth client and listen for callback from provider
fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", s.GetListenAddr())
params := server.ServerParams{ params := server.ServerParams{
Verbose: config.Options.Verbose, Verbose: config.Options.Verbose,
AuthProvider: &oidc.IdentityProvider{ AuthProvider: &oidc.IdentityProvider{
Issuer: config.Authorization.Endpoints.Issuer, Issuer: config.Authorization.Endpoints.Issuer,
Endpoints: oidc.Endpoints{ Endpoints: oidc.Endpoints{
Config: config.Authorization.Endpoints.Config, Config: config.Authorization.Endpoints.Config,
JwksUri: config.Authorization.Endpoints.JwksUri, Authorization: config.Authorization.Endpoints.Authorize,
JwksUri: config.Authorization.Endpoints.JwksUri,
}, },
}, },
JwtBearerEndpoints: flows.JwtBearerFlowEndpoints{ JwtBearerEndpoints: flows.JwtBearerFlowEndpoints{
@ -66,8 +53,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
Register: config.Authorization.Endpoints.Register, Register: config.Authorization.Endpoints.Register,
}, },
JwtBearerParams: flows.JwtBearerFlowParams{ JwtBearerParams: flows.JwtBearerFlowParams{
Client: authzClient, Client: authzClient,
IdentityProvider: provider,
TrustedIssuer: &oauth.TrustedIssuer{ TrustedIssuer: &oauth.TrustedIssuer{
AllowAnySubject: false, AllowAnySubject: false,
Issuer: s.Addr, Issuer: s.Addr,
@ -75,8 +61,9 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
ExpiresAt: time.Now().Add(config.Authorization.Token.Duration), ExpiresAt: time.Now().Add(config.Authorization.Token.Duration),
Scope: []string{}, Scope: []string{},
}, },
Verbose: config.Options.Verbose, Verbose: config.Options.Verbose,
Refresh: config.Authorization.Token.Refresh, Refresh: config.Authorization.Token.Refresh,
Audience: config.Authorization.Audience,
}, },
ClientCredentialsEndpoints: flows.ClientCredentialsFlowEndpoints{ ClientCredentialsEndpoints: flows.ClientCredentialsFlowEndpoints{
Clients: config.Authorization.Endpoints.Clients, Clients: config.Authorization.Endpoints.Clients,
@ -87,7 +74,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
Client: authzClient, Client: authzClient,
}, },
} }
err = s.Start(button, provider, client, params) err = s.StartLogin(config.Authentication.Clients, params)
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n") fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n")
} else if err != nil { } else if err != nil {
@ -96,7 +83,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
} else if config.Options.FlowType == "client_credentials" { } else if config.Options.FlowType == "client_credentials" {
params := flows.ClientCredentialsFlowParams{ params := flows.ClientCredentialsFlowParams{
Client: client, Client: nil, // # FIXME: need to do something about this being nil I think
} }
_, err := NewClientCredentialsFlowWithConfig(config, params) _, err := NewClientCredentialsFlowWithConfig(config, params)
if err != nil { if err != nil {
@ -108,12 +95,3 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
return nil return nil
} }
func MakeButton(url string, text string) string {
html := "<input type=\"button\" "
html += "class=\"button\" "
html += fmt.Sprintf("onclick=\"window.location.href='%s';\" ", url)
html += fmt.Sprintf("value=\"%s\"", text)
return html
// return "<a href=\"" + url + "\"> " + text + "</a>"
}

View file

@ -29,7 +29,7 @@ func NewClientWithConfig(config *Config) *oauth.Client {
Id: clients[0].Id, Id: clients[0].Id,
Secret: clients[0].Secret, Secret: clients[0].Secret,
Name: clients[0].Name, Name: clients[0].Name,
Issuer: clients[0].Issuer, Provider: clients[0].Provider,
Scope: clients[0].Scope, Scope: clients[0].Scope,
RedirectUris: clients[0].RedirectUris, RedirectUris: clients[0].RedirectUris,
} }
@ -53,7 +53,7 @@ func NewClientWithConfigByName(config *Config, name string) *oauth.Client {
func NewClientWithConfigByProvider(config *Config, issuer string) *oauth.Client { func NewClientWithConfigByProvider(config *Config, issuer string) *oauth.Client {
index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool { index := slices.IndexFunc(config.Authentication.Clients, func(c oauth.Client) bool {
return c.Issuer == issuer return c.Provider.Issuer == issuer
}) })
if index >= 0 { if index >= 0 {
@ -90,6 +90,12 @@ func NewServerWithConfig(conf *Config) *server.Server {
}, },
Host: host, Host: host,
Port: port, Port: port,
Issuer: server.IdentityProviderServer{
Host: conf.Server.Issuer.Host,
Port: conf.Server.Issuer.Port,
Endpoints: conf.Server.Issuer.Endpoints,
Clients: conf.Server.Issuer.Clients,
},
} }
return server return server
} }

View file

@ -16,13 +16,15 @@ func (client *Client) IsFlowInitiated() bool {
return client.FlowId != "" return client.FlowId != ""
} }
func (client *Client) BuildAuthorizationUrl(issuer string, state string) string { func (client *Client) BuildAuthorizationUrl(state string) string {
return issuer + "?" + "client_id=" + client.Id + url := client.Provider.Endpoints.Authorization + "?client_id=" + client.Id +
"&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) + "&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) +
"&response_type=code" + // this has to be set to "code" "&response_type=code" + // this has to be set to "code"
"&state=" + state + "&scope=" + strings.Join(client.Scope, "+")
"&scope=" + strings.Join(client.Scope, "+") + if state != "" {
"&resource=" + url.QueryEscape("http://127.0.0.1:4444/oauth2/token") url += "&state=" + state
}
return url
} }
func (client *Client) InitiateLoginFlow(loginUrl string) error { func (client *Client) InitiateLoginFlow(loginUrl string) error {
@ -91,7 +93,7 @@ func (client *Client) FetchCSRFToken(flowUrl string) error {
return fmt.Errorf("failed to extract CSRF token: not found") return fmt.Errorf("failed to extract CSRF token: not found")
} }
func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) { func (client *Client) FetchTokenFromAuthenticationServer(code string, state string) ([]byte, error) {
body := url.Values{ body := url.Values{
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"client_id": {client.Id}, "client_id": {client.Id},
@ -105,14 +107,16 @@ func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl
if state != "" { if state != "" {
body["state"] = []string{state} body["state"] = []string{state}
} }
res, err := http.PostForm(remoteUrl, body) res, err := http.PostForm(client.Provider.Endpoints.Token, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ID token: %s", err) return nil, fmt.Errorf("failed to get ID token: %v", err)
} }
b, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}
fmt.Printf("%s\n", string(b))
defer res.Body.Close() defer res.Body.Close()
// domain, _ := url.Parse("http://127.0.0.1") return b, nil
// client.Jar.SetCookies(domain, res.Cookies())
return io.ReadAll(res.Body)
} }

View file

@ -1,6 +1,7 @@
package oauth package oauth
import ( import (
"davidallendj/opaal/internal/oidc"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -24,15 +25,15 @@ const (
type Client struct { type Client struct {
http.Client http.Client
Id string `db:"id" yaml:"id"` Id string `db:"id" yaml:"id"`
Secret string `db:"secret" yaml:"secret"` Secret string `db:"secret" yaml:"secret"`
Name string `db:"name" yaml:"name"` Name string `db:"name" yaml:"name"`
Description string `db:"description" yaml:"description"` Description string `db:"description" yaml:"description"`
Issuer string `db:"issuer" yaml:"issuer"` Provider oidc.IdentityProvider `db:"issuer" yaml:"provider"`
RegistrationAccessToken string `db:"registration_access_token" yaml:"registration-access-token"` RegistrationAccessToken string `db:"registration_access_token" yaml:"registration-access-token"`
RedirectUris []string `db:"redirect_uris" yaml:"redirect-uris"` RedirectUris []string `db:"redirect_uris" yaml:"redirect-uris"`
Scope []string `db:"scope" yaml:"scope"` Scope []string `db:"scope" yaml:"scope"`
Audience []string `db:"audience" yaml:"audience"` Audience []string `db:"audience" yaml:"audience"`
FlowId string FlowId string
CsrfToken string CsrfToken string
} }

View file

@ -111,26 +111,11 @@ func (p *IdentityProvider) LoadServerConfig(path string) error {
} }
func (p *IdentityProvider) FetchServerConfig() error { func (p *IdentityProvider) FetchServerConfig() error {
// make a request to a server's openid-configuration tmp, err := FetchServerConfig(p.Issuer)
req, err := http.NewRequest(http.MethodGet, p.Issuer+"/.well-known/openid-configuration", bytes.NewBuffer([]byte{}))
if err != nil { if err != nil {
return fmt.Errorf("failed to create a new request: %v", err) return 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)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %v", err)
}
err = p.ParseServerConfig(body)
if err != nil {
return fmt.Errorf("failed to parse server config: %v", err)
} }
p = tmp
return nil return nil
} }
@ -147,10 +132,15 @@ func FetchServerConfig(issuer string) (*IdentityProvider, error) {
return nil, fmt.Errorf("failed to do request: %v", err) return nil, fmt.Errorf("failed to do request: %v", err)
} }
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err) return nil, fmt.Errorf("failed to read response body: %v", err)
} }
var p IdentityProvider var p IdentityProvider
err = p.ParseServerConfig(body) err = p.ParseServerConfig(body)
if err != nil { if err != nil {
@ -174,3 +164,25 @@ func (p *IdentityProvider) FetchJwks() error {
return nil return nil
} }
func (p *IdentityProvider) UpdateEndpoints(other *IdentityProvider) {
UpdateEndpoints(&p.Endpoints, &other.Endpoints)
}
func UpdateEndpoints(eps *Endpoints, other *Endpoints) {
// only update endpoints that are not empty
var UpdateIfEmpty = func(ep *string, s string) {
if ep != nil {
if *ep == "" {
*ep = s
}
}
}
UpdateIfEmpty(&eps.Config, other.Config)
UpdateIfEmpty(&eps.Authorization, other.Authorization)
UpdateIfEmpty(&eps.Token, other.Token)
UpdateIfEmpty(&eps.Revocation, other.Revocation)
UpdateIfEmpty(&eps.Introspection, other.Introspection)
UpdateIfEmpty(&eps.UserInfo, other.UserInfo)
UpdateIfEmpty(&eps.JwksUri, other.JwksUri)
}

290
internal/server/idp.go Normal file
View file

@ -0,0 +1,290 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"davidallendj/opaal/internal/oidc"
"encoding/json"
"fmt"
"net/http"
"os"
"slices"
"strings"
"time"
"github.com/davidallendj/go-utils/cryptox"
"github.com/davidallendj/go-utils/util"
"github.com/go-chi/chi/v5"
"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: make this a completely separate server
type IdentityProviderServer struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Endpoints oidc.Endpoints `yaml:"endpoints"`
Clients []RegisteredClient `yaml:"clients"`
}
// NOTE: could we use a oauth.Client here instead??
type RegisteredClient struct {
Id string `yaml:"id"`
Secret string `yaml:"secret"`
Name string `yaml:"name"`
RedirectUris []string `yaml:"redirect-uris"`
}
func (s *Server) StartIdentityProvider() error {
// NOTE: this example does NOT implement CSRF tokens nor use them
// create an example identity provider
var (
r = chi.NewRouter()
// clients = []oauth.Client{}
activeCodes = []string{}
)
// update endpoints that have values set
defaultEps := oidc.Endpoints{
Authorization: "http://" + s.Addr + "/oauth2/authorize",
Token: "http://" + s.Addr + "/oauth2/token",
JwksUri: "http://" + s.Addr + "/.well-known/jwks.json",
}
oidc.UpdateEndpoints(&s.Issuer.Endpoints, &defaultEps)
// generate key pair used to sign JWKS and create JWTs
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate new RSA key: %v", err)
}
privateJwk, publicJwk, err := cryptox.GenerateJwkKeyPairFromPrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to generate JWK pair from private key: %v", err)
}
kid, _ := privateJwk.Get("kid")
publicJwk.Set("kid", kid)
publicJwk.Set("use", "sig")
publicJwk.Set("kty", "RSA")
publicJwk.Set("alg", "RS256")
if err := publicJwk.Validate(); err != nil {
return fmt.Errorf("failed to validate public JWK: %v", err)
}
// TODO: create .well-known JWKS endpoint with json
r.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
// TODO: generate new JWKs from a private key
jwks := map[string]any{
"keys": []jwk.Key{
publicJwk,
},
}
b, err := json.Marshal(jwks)
if err != nil {
return
}
w.Write(b)
})
r.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
// create config JSON to serve with GET request
config := map[string]any{
"issuer": "http://" + s.Addr,
"authorization_endpoint": s.Issuer.Endpoints.Authorization,
"token_endpoint": s.Issuer.Endpoints.Token,
"jwks_uri": s.Issuer.Endpoints.JwksUri,
"scopes_supported": []string{
"openid",
"profile",
"email",
},
"response_types_supported": []string{
"code",
},
"grant_types_supported": []string{
"authorization_code",
},
"id_token_signing_alg_values_supported": []string{
"RS256",
},
"claims_supported": []string{
"iss",
"sub",
"aud",
"exp",
"iat",
"name",
"email",
},
}
b, err := json.Marshal(config)
if err != nil {
return
}
w.Write(b)
})
r.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
// serve up a simple login page
})
r.HandleFunc("/consent", func(w http.ResponseWriter, r *http.Request) {
// give consent for app to use
})
r.HandleFunc("/browser/login", func(w http.ResponseWriter, r *http.Request) {
// serve up a login page for user creds
form, err := os.ReadFile("pages/login.html")
if err != nil {
fmt.Printf("failed to load login form: %v", err)
}
w.Write(form)
})
r.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
// check for example identity with POST request
r.ParseForm()
username := r.Form.Get("username")
password := r.Form.Get("password")
if len(s.Issuer.Clients) <= 0 {
fmt.Printf("no registered clients found with identity provider (add them in config)\n")
return
}
// example username and password so do simplified authorization code flow
if username == "openchami" && password == "openchami" {
client := s.Issuer.Clients[0]
// check if there are any redirect URIs supplied
if len(client.RedirectUris) <= 0 {
fmt.Printf("no redirect URIs found for client %s (ID: %s)\n", client.Name, client.Id)
return
}
for _, url := range client.RedirectUris {
// send an authorization code to each URI
code := util.RandomString(64)
activeCodes = append(activeCodes, code)
redirectUrl := fmt.Sprintf("%s?code=%s", url, code)
fmt.Printf("redirect URL: %s\n", redirectUrl)
http.Redirect(w, r, redirectUrl, http.StatusFound)
// _, _, err := httpx.MakeHttpRequest(fmt.Sprintf("%s?code=%s", url, code), http.MethodGet, nil, nil)
// if err != nil {
// fmt.Printf("failed to make request: %v\n", err)
// continue
// }
}
} else {
w.Write([]byte("error logging in"))
http.Redirect(w, r, "/browser/login", http.StatusUnauthorized)
}
})
r.HandleFunc("/oauth2/token", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
// check for authorization code and make sure it's valid
var code = r.Form.Get("code")
index := slices.IndexFunc(activeCodes, func(s string) bool { return s == code })
if index < 0 {
fmt.Printf("invalid authorization code: %s\n", code)
return
}
// now create and return a JWT that can be verified with by authorization server
iat := time.Now().Unix()
exp := time.Now().Add(time.Second * 3600 * 16).Unix()
t := jwt.New()
t.Set(jwt.IssuerKey, s.Addr)
t.Set(jwt.SubjectKey, "ochami")
t.Set(jwt.AudienceKey, "ochami")
t.Set(jwt.IssuedAtKey, iat)
t.Set(jwt.ExpirationKey, exp)
t.Set("name", "ochami")
t.Set("email", "example@ochami.org")
t.Set("email_verified", true)
t.Set("scope", []string{
"openid",
"profile",
"email",
"example",
})
// payload := map[string]any{}
// payload["iss"] = s.Addr
// payload["aud"] = "ochami"
// payload["iat"] = iat
// payload["nbf"] = iat
// payload["exp"] = exp
// payload["sub"] = "ochami"
// payload["name"] = "ochami"
// payload["email"] = "example@ochami.org"
// payload["email_verified"] = true
// payload["scope"] = []string{
// "openid",
// "profile",
// "email",
// "example",
// }
payloadJson, err := json.MarshalIndent(t, "", "\t")
if err != nil {
fmt.Printf("failed to marshal payload: %v", err)
return
}
signed, err := jws.Sign(payloadJson, jws.WithKey(jwa.RS256, privateJwk))
if err != nil {
fmt.Printf("failed to sign token: %v\n", err)
return
}
// construct the bearer token with required fields
scope, _ := t.Get("scope")
bearer := map[string]any{
"token_type": "Bearer",
"id_token": string(signed),
"expires_in": exp,
"created_at": iat,
"scope": strings.Join(scope.([]string), " "),
}
b, err := json.MarshalIndent(bearer, "", "\t")
if err != nil {
fmt.Printf("failed to marshal bearer token: %v\n", err)
return
}
fmt.Printf("bearer: %s\n", string(b))
w.Write(b)
})
r.HandleFunc("/oauth2/authorize", func(w http.ResponseWriter, r *http.Request) {
var (
responseType = r.URL.Query().Get("response_type")
clientId = r.URL.Query().Get("client_id")
redirectUris = r.URL.Query().Get("redirect_uri")
)
// check for required authorization code params
if responseType != "code" {
fmt.Printf("invalid response type\n")
return
}
// find a valid client
index := slices.IndexFunc(s.Issuer.Clients, func(c RegisteredClient) bool {
fmt.Printf("%s ? %s\n", c.Id, clientId)
return c.Id == clientId
})
if index < 0 {
fmt.Printf("no valid client found")
return
}
// TODO: check that our redirect URIs all match
for _, uri := range redirectUris {
_ = uri
}
// redirect to browser login since we don't do session management here
http.Redirect(w, r, "/browser/login", http.StatusFound)
})
s.Handler = r
return s.ListenAndServe()
}

View file

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"github.com/davidallendj/go-utils/httpx" "github.com/davidallendj/go-utils/httpx"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -17,10 +18,11 @@ import (
type Server struct { type Server struct {
*http.Server *http.Server
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
Callback string `yaml:"callback"` Callback string `yaml:"callback"`
State string `yaml:"state"` State string `yaml:"state"`
Issuer IdentityProviderServer `yaml:"issuer"`
} }
type ServerParams struct { type ServerParams struct {
@ -40,12 +42,39 @@ func (s *Server) GetListenAddr() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port) return fmt.Sprintf("%s:%d", s.Host, s.Port)
} }
func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *oauth.Client, params ServerParams) error { func (s *Server) StartLogin(clients []oauth.Client, params ServerParams) error {
var target = "" var (
target string
callback string
client *oauth.Client
sso string
)
// check if callback is set // check if callback is set
if s.Callback == "" { if s.Callback == "" {
s.Callback = "/oidc/callback" callback = "/oidc/callback"
}
// make the login page SSO buttons and authorization URLs to write to stdout
buttons := ""
fmt.Printf("Login with an identity provider: \n")
for i, client := range clients {
// fetch provider configuration before adding button
p, err := oidc.FetchServerConfig(client.Provider.Issuer)
if err != nil {
fmt.Printf("failed to fetch server config: %v\n", err)
continue
}
// if we're able to get the config, go ahead and try to fetch jwks too
if err = p.FetchJwks(); err != nil {
fmt.Printf("failed to fetch JWKS: %v\n", err)
continue
}
clients[i].Provider = *p
buttons += makeButton(fmt.Sprintf("/login?sso=%s", client.Id), client.Name)
fmt.Printf("\t%s: /login?sso=%s\n", client.Name, client.Id)
} }
var code string var code string
@ -72,7 +101,27 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
// add target if query exists // add target if query exists
if r != nil { if r != nil {
target = r.URL.Query().Get("target") target = r.URL.Query().Get("target")
sso = r.URL.Query().Get("sso")
// TODO: get client from list and build the authorization URL string
index := slices.IndexFunc(clients, func(c oauth.Client) bool {
return c.Id == sso
})
// TODO: redirect the user to authorization URL and return from func
foundClient := index >= 0
if foundClient {
client = &clients[index]
url := client.BuildAuthorizationUrl(s.State)
if params.Verbose {
fmt.Printf("Redirect URL: %s\n", url)
}
http.Redirect(w, r, url, http.StatusFound)
return
}
} }
// show login page with notice to redirect // show login page with notice to redirect
template, err := gonja.FromFile("pages/index.html") template, err := gonja.FromFile("pages/index.html")
if err != nil { if err != nil {
@ -92,47 +141,54 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
p = params.AuthProvider p = params.AuthProvider
jwks []byte jwks []byte
) )
// try and get the JWKS from param first
if p.Endpoints.JwksUri != "" { fetchAndMarshal := func() (err error) {
err := p.FetchJwks() err = p.FetchJwks()
if err != nil { if err != nil {
fmt.Printf("failed to fetch keys using JWKS url...trying to fetch config and try again...\n") fmt.Printf("failed to fetch keys: %v\n", err)
return
} }
jwks, err = json.Marshal(p.KeySet) jwks, err = json.Marshal(p.KeySet)
if err != nil { if err != nil {
fmt.Printf("failed to marshal JWKS: %v\n", err) fmt.Printf("failed to marshal JWKS: %v\n", err)
} }
} else if p.Endpoints.Config != "" && jwks == nil {
// otherwise, try and fetch the whole config and try again
err := p.FetchServerConfig()
if err != nil {
fmt.Printf("failed to fetch server config: %v\n", err)
http.Redirect(w, r, "/error", http.StatusInternalServerError)
return
}
err = p.FetchJwks()
if err != nil {
fmt.Printf("failed to fetch JWKS after fetching server config: %v\n", err)
http.Redirect(w, r, "/error", http.StatusInternalServerError)
return
}
}
// forward the JWKS from the authorization server
if jwks == nil {
fmt.Printf("no JWKS was fetched from authorization server\n")
http.Redirect(w, r, "/error", http.StatusInternalServerError)
return return
} }
w.Write(jwks)
// try and get the JWKS from param first
if p.Endpoints.JwksUri != "" {
if err := fetchAndMarshal(); err != nil {
w.Write(jwks)
return
}
}
// otherwise or if fetching the JWKS failed, try and fetch the whole config first and try again
if p.Endpoints.Config != "" {
if err := p.FetchServerConfig(); err != nil {
fmt.Printf("failed to fetch server config: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
} else {
fmt.Printf("getting JWKS from param failed and endpoints config unavailable\n")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := fetchAndMarshal(); err != nil {
fmt.Printf("failed to fetch and marshal JWKS after config update: %v\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Write(jwks)
}) })
r.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
// use refresh token provided to do a refresh token grant // use refresh token provided to do a refresh token grant
refreshToken := r.URL.Query().Get("refresh-token") refreshToken := r.URL.Query().Get("refresh-token")
if refreshToken != "" { if refreshToken != "" {
_, err := params.JwtBearerParams.Client.PerformRefreshTokenGrant(provider.Endpoints.Token, refreshToken) _, err := params.JwtBearerParams.Client.PerformRefreshTokenGrant(client.Provider.Endpoints.Token, refreshToken)
if err != nil { if err != nil {
fmt.Printf("failed to perform refresh token grant: %v\n", err) fmt.Printf("failed to perform refresh token grant: %v\n", err)
http.Redirect(w, r, "/error", http.StatusInternalServerError) http.Redirect(w, r, "/error", http.StatusInternalServerError)
@ -151,6 +207,8 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
return return
} }
} else { } else {
// FIXME: I think this probably needs to reworked or removed
// NOTE: this logic fetches a token for services to retrieve like BSS
// perform a client credentials grant and return a token // perform a client credentials grant and return a token
var err error var err error
accessToken, err = flows.NewClientCredentialsFlow(params.ClientCredentialsEndpoints, params.ClientCredentialsParams) accessToken, err = flows.NewClientCredentialsFlow(params.ClientCredentialsEndpoints, params.ClientCredentialsParams)
@ -162,7 +220,7 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
w.Write([]byte(accessToken)) w.Write([]byte(accessToken))
} }
}) })
r.HandleFunc(s.Callback, func(w http.ResponseWriter, r *http.Request) { r.HandleFunc(callback, func(w http.ResponseWriter, r *http.Request) {
// get the code from the OIDC provider // get the code from the OIDC provider
if r != nil { if r != nil {
code = r.URL.Query().Get("code") code = r.URL.Query().Get("code")
@ -170,10 +228,15 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
fmt.Printf("Authorization code: %v\n", code) fmt.Printf("Authorization code: %v\n", code)
} }
// make sure we have the correct client to use
if client == nil {
fmt.Printf("failed to find valid client")
return
}
// use code from response and exchange for bearer token (with ID token) // use code from response and exchange for bearer token (with ID token)
bearerToken, err := client.FetchTokenFromAuthenticationServer( bearerToken, err := client.FetchTokenFromAuthenticationServer(
code, code,
provider.Endpoints.Token,
s.State, s.State,
) )
if err != nil { if err != nil {
@ -203,6 +266,7 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
// complete JWT bearer flow to receive access token from authorization server // complete JWT bearer flow to receive access token from authorization server
// fmt.Printf("bearer: %v\n", string(bearerToken)) // fmt.Printf("bearer: %v\n", string(bearerToken))
params.JwtBearerParams.IdToken = data["id_token"].(string) params.JwtBearerParams.IdToken = data["id_token"].(string)
params.JwtBearerParams.Client = client
accessToken, err = flows.NewJwtBearerFlow(params.JwtBearerEndpoints, params.JwtBearerParams) accessToken, err = flows.NewJwtBearerFlow(params.JwtBearerEndpoints, params.JwtBearerParams)
if err != nil { if err != nil {
fmt.Printf("failed to complete JWT bearer flow: %v\n", err) fmt.Printf("failed to complete JWT bearer flow: %v\n", err)
@ -268,3 +332,15 @@ func (s *Server) Start(buttons string, provider *oidc.IdentityProvider, client *
s.Handler = r s.Handler = r
return s.ListenAndServe() return s.ListenAndServe()
} }
func makeButton(url string, text string) string {
// check if we have http:// a
// html := "<input type=\"button\" "
// html += fmt.Sprintf("onclick=\"window.location.href='%s';\" ", url)
// html += fmt.Sprintf("value=\"%s\">", text)
html := "<a "
html += "class=\"button\" "
html += fmt.Sprintf("href=\"%s\">%s", url, text)
html += "</a>"
return html
}

View file

@ -7,6 +7,7 @@
<div id="wrapper"> <div id="wrapper">
Log in using the option(s) below for an access token. </br></br> Log in using the option(s) below for an access token. </br></br>
{{loginForm}}
{{loginButtons}} {{loginButtons}}
</div> </div>
</html> </html>

13
pages/login.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<div class="log-form">
<h2>Login to your account</h2>
<form action="/api/login" method="post">
<input type="text" id="username" name="username" title="username" placeholder="Enter your username or email..." /><br/>
<input type="password" id="password" name="password" title="password" placeholder="Enter your password..." /><br/>
<button type="submit" class="btn">Login</button><br/>
<a class="forgot" href="#">Forgot Username?</a><br/>
<label>(hint: try 'openchami' for both username and password)</label>
</form>
</div><!--end log form -->
</html>

View file

@ -27,4 +27,10 @@ input[type=text] {
padding: 12px 20px; padding: 12px 20px;
margin: 8px 0; margin: 8px 0;
box-sizing: border-box; box-sizing: border-box;
}
input[type=password] {
width: 80%;
padding: 12px 20px;
margin: 8px 0;
box-sizing: border-box;
} }