Compare commits

...

39 commits
v0.1.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
David J. Allen
4f5930ad56
Fixed static pages not being included in container 2024-04-12 09:44:29 -06:00
20 changed files with 736 additions and 228 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

@ -23,6 +23,7 @@ dockers:
extra_files: extra_files:
- LICENSE.md - LICENSE.md
- README.md - README.md
- pages/
archives: archives:
- format: tar.gz - format: tar.gz
rlcp: true rlcp: true
@ -37,6 +38,7 @@ archives:
files: files:
- LICENSE.md - LICENSE.md
- README.md - README.md
- pages/
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
snapshot: snapshot:

View file

@ -2,7 +2,7 @@ FROM cgr.dev/chainguard/wolfi-base
RUN apk add --no-cache tini bash curl RUN apk add --no-cache tini bash curl
RUN mkdir /opaal RUN mkdir -p /opaal/pages/static/stylesheets
RUN chown 65534:65534 /opaal RUN chown 65534:65534 /opaal
WORKDIR /opaal WORKDIR /opaal
@ -10,6 +10,8 @@ WORKDIR /opaal
USER 65534:65534 USER 65534:65534
COPY opaal /opaal/opaal COPY opaal /opaal/opaal
COPY pages/* /opaal/pages/
CMD [ "/opaal/opaal" ] CMD [ "/opaal/opaal" ]

View file

@ -28,6 +28,33 @@ 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:
@ -47,21 +74,23 @@ These commands will create a default config, then start the login process. Maybe
### 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"
@ -21,9 +20,10 @@ 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
Audience []string
Refresh bool Refresh bool
Verbose bool Verbose bool
KeyPath string KeyPath string
@ -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,24 +31,19 @@ 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,
Authorization: config.Authorization.Endpoints.Authorize,
JwksUri: config.Authorization.Endpoints.JwksUri, JwksUri: config.Authorization.Endpoints.JwksUri,
}, },
}, },
@ -67,7 +54,6 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
}, },
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,
@ -77,6 +63,7 @@ func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider
}, },
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"
@ -28,7 +29,7 @@ type Client struct {
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"`

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"
@ -21,6 +22,7 @@ type Server struct {
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 return
} }
} // try and get the JWKS from param first
if p.Endpoints.JwksUri != "" {
// forward the JWKS from the authorization server if err := fetchAndMarshal(); err != nil {
if jwks == nil {
fmt.Printf("no JWKS was fetched from authorization server\n")
http.Redirect(w, r, "/error", http.StatusInternalServerError)
return
}
w.Write(jwks) 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

@ -28,3 +28,9 @@ input[type=text] {
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;
}