diff --git a/main.go b/main.go index 0f4240dc0d1ab48969c9aab39a907c608344d64d..2932e4f5d32cfd9a01cb1fb109dd5124556b0937 100644 --- a/main.go +++ b/main.go @@ -3,14 +3,11 @@ import ( "context" "crypto/rand" - "embed" "encoding/base64" "encoding/json" "flag" "fmt" - "html/template" "io" - "io/fs" "log" "net/http" "os" @@ -22,15 +19,6 @@ "github.com/peterbourgon/ff/v3" "golang.org/x/oauth2" ) -var ( - //go:embed static/* - _staticFiles embed.FS - staticFiles, _ = fs.Sub(_staticFiles, "static") - //go:embed static/index.gotmpl - indexFile string - indexTmpl = template.Must(template.New("").Parse(indexFile)) -) - func randString(nByte int) (string, error) { b := make([]byte, nByte) if _, err := io.ReadFull(rand.Reader, b); err != nil { @@ -56,265 +44,127 @@ clientID string clientSecret string port int scopes string - origin string } -func (a args) redirect() string { - return strings.TrimSuffix(a.origin, "/") + "/callback" -} - -type OIDCConfig struct { - ClientProvider string `json:"client_provider"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - Scopes string `json:"scopes"` -} - -type Server struct { - args args - - issuer string - scopes string - provider *oidc.Provider - verifier *oidc.IDTokenVerifier - oauth2Config *oauth2.Config -} - -type IndexContext struct { - Issuer string - ClientID string - ClientSecret string - Scopes string - RedirectURI string - - Results bool - TokenClaims string - UserInfo string - TokenResponse string -} - -func (s *Server) updateConfig(ctx context.Context, config *OIDCConfig) error { - if config.ClientProvider == "" || config.ClientID == "" { - return fmt.Errorf("client provider and client ID are required") +func main() { + var args args + fs := flag.NewFlagSet("oidc", flag.ExitOnError) + fs.StringVar(&args.clientProvider, "client-provider", "", "Client provider (e.g. https://accounts.google.com)") + fs.StringVar(&args.clientID, "client-id", "", "Client ID") + fs.StringVar(&args.clientSecret, "client-secret", "", "Client secret") + fs.IntVar(&args.port, "port", 8000, "Port to run on") + fs.StringVar(&args.scopes, "scopes", "profile,email", "Comma-delimited scopes") + fs.String("config", ".env", "Env config") + if err := ff.Parse(fs, os.Args[1:], + ff.WithEnvVarPrefix("OIDC"), + ff.WithConfigFileFlag("config"), + ff.WithAllowMissingConfigFile(true), + ff.WithConfigFileParser(ff.EnvParser), + ); err != nil { + log.Fatal(err) } + ctx := context.Background() + scopes := strings.Split(args.scopes, ",") - provider, err := oidc.NewProvider(ctx, config.ClientProvider) + provider, err := oidc.NewProvider(ctx, args.clientProvider) if err != nil { - return fmt.Errorf("failed to create OIDC provider: %v", err) + log.Fatal(err) } - oidcConfig := &oidc.Config{ - ClientID: config.ClientID, + ClientID: args.clientID, } verifier := provider.Verifier(oidcConfig) - scopes := strings.Fields(config.Scopes) - if len(scopes) == 0 { - scopes = []string{"profile", "email"} - } - - oauth2Config := &oauth2.Config{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, + config := oauth2.Config{ + ClientID: args.clientID, + ClientSecret: args.clientSecret, Endpoint: provider.Endpoint(), - RedirectURL: s.args.redirect(), + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", args.port), Scopes: append([]string{oidc.ScopeOpenID}, scopes...), } - s.issuer = config.ClientProvider - s.scopes = config.Scopes - s.provider = provider - s.verifier = verifier - s.oauth2Config = oauth2Config - - return nil -} - -func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - config := OIDCConfig{ - ClientProvider: r.FormValue("client_provider"), - ClientID: r.FormValue("client_id"), - ClientSecret: r.FormValue("client_secret"), - Scopes: r.FormValue("scopes"), - } - - if config.ClientProvider == "" || config.ClientID == "" { - http.Error(w, "Client provider and client ID are required", http.StatusBadRequest) + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + state, err := randString(16) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) return } - - if err := s.updateConfig(r.Context(), &config); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + nonce, err := randString(16) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) return } - - http.Redirect(w, r, "/auth", http.StatusFound) - return - } + setCallbackCookie(w, r, "state", state) + setCallbackCookie(w, r, "nonce", nonce) - indexTmpl.Execute(w, IndexContext{ - Issuer: s.args.clientProvider, - ClientID: s.args.clientID, - ClientSecret: s.args.clientSecret, - Scopes: s.args.scopes, - RedirectURI: s.args.redirect(), + http.Redirect(w, r, config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) }) -} -func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { - config := s.oauth2Config - - if config == nil { - http.Error(w, "OIDC not configured", http.StatusBadRequest) - return - } - - state, err := randString(16) - if err != nil { - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - nonce, err := randString(16) - if err != nil { - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - setCallbackCookie(w, r, "state", state) - setCallbackCookie(w, r, "nonce", nonce) - - http.Redirect(w, r, config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) -} - -func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { - config := s.oauth2Config - provider := s.provider - verifier := s.verifier - - if config == nil || provider == nil || verifier == nil { - http.Error(w, "OIDC not configured", http.StatusBadRequest) - return - } - - state, err := r.Cookie("state") - if err != nil { - http.Error(w, "state not found", http.StatusBadRequest) - return - } - if r.URL.Query().Get("state") != state.Value { - http.Error(w, "state did not match", http.StatusBadRequest) - return - } - - oauth2Token, err := config.Exchange(r.Context(), r.URL.Query().Get("code")) - if err != nil { - http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) - return - } - rawIDToken, ok := oauth2Token.Extra("id_token").(string) - if !ok { - http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError) - return - } - idToken, err := verifier.Verify(r.Context(), rawIDToken) - if err != nil { - http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError) - return - } - - nonce, err := r.Cookie("nonce") - if err != nil { - http.Error(w, "nonce not found", http.StatusBadRequest) - return - } - if idToken.Nonce != nonce.Value { - http.Error(w, "nonce did not match", http.StatusBadRequest) - return - } - - userInfo, err := provider.UserInfo(r.Context(), oauth2.StaticTokenSource(oauth2Token)) - if err != nil { - http.Error(w, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError) - return - } - - var tokenClaims json.RawMessage - if err := idToken.Claims(&tokenClaims); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - tokenClaimsJSON, err := json.MarshalIndent(tokenClaims, "", "\t") - if err != nil { - http.Error(w, "Could not marshal token claims JSON: "+err.Error(), http.StatusInternalServerError) - return - } - - userInfoJSON, err := json.MarshalIndent(userInfo, "", "\t") - if err != nil { - http.Error(w, "Could not marshal userinfo JSON: "+err.Error(), http.StatusInternalServerError) - return - } + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + state, err := r.Cookie("state") + if err != nil { + http.Error(w, "state not found", http.StatusBadRequest) + return + } + if r.URL.Query().Get("state") != state.Value { + http.Error(w, "state did not match", http.StatusBadRequest) + return + } - tokenResponseJSON, err := json.MarshalIndent(oauth2Token, "", "\t") - if err != nil { - http.Error(w, "Could not marshal oauth2 token response JSON: "+err.Error(), http.StatusInternalServerError) - return - } + oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) + return + } + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError) + return + } + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError) + return + } - indexTmpl.Execute(w, IndexContext{ - Issuer: s.issuer, - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - Scopes: s.scopes, - RedirectURI: config.RedirectURL, - Results: true, - TokenClaims: string(tokenClaimsJSON), - UserInfo: string(userInfoJSON), - TokenResponse: string(tokenResponseJSON), - }) -} + nonce, err := r.Cookie("nonce") + if err != nil { + http.Error(w, "nonce not found", http.StatusBadRequest) + return + } + if idToken.Nonce != nonce.Value { + http.Error(w, "nonce did not match", http.StatusBadRequest) + return + } -func main() { - var args args - fs := flag.NewFlagSet("oidc", flag.ExitOnError) - fs.StringVar(&args.clientProvider, "client-provider", "", "Default client provider (e.g. https://accounts.google.com)") - fs.StringVar(&args.clientID, "client-id", "", "Default client ID") - fs.StringVar(&args.clientSecret, "client-secret", "", "Default client secret") - fs.IntVar(&args.port, "port", 8000, "Port to run on") - fs.StringVar(&args.scopes, "scopes", "profile email", "Default scopes") - fs.StringVar(&args.origin, "origin", "http://localhost:8000", "Web origin") - fs.String("config", ".env", "Env config") - if err := ff.Parse(fs, os.Args[1:], - ff.WithEnvVarPrefix("OIDC"), - ff.WithConfigFileFlag("config"), - ff.WithAllowMissingConfigFile(true), - ff.WithConfigFileParser(ff.EnvParser), - ); err != nil { - log.Fatal(err) - } + userInfo, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + http.Error(w, "Failed to get userinfo: "+err.Error(), http.StatusInternalServerError) + return + } - server := &Server{ - args: args, - } + resp := struct { + OAuth2Token *oauth2.Token `json:"oauth2_token"` + IDTokenClaims *json.RawMessage `json:"claims"` + UserInfo *oidc.UserInfo `json:"user_info"` + }{ + OAuth2Token: oauth2Token, + IDTokenClaims: new(json.RawMessage), + UserInfo: userInfo, + } - if args.clientProvider != "" && args.clientID != "" { - defaultConfig := &OIDCConfig{ - ClientProvider: args.clientProvider, - ClientID: args.clientID, - ClientSecret: args.clientSecret, - Scopes: args.scopes, + if err := idToken.Claims(&resp.IDTokenClaims); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - if err := server.updateConfig(context.Background(), defaultConfig); err != nil { - log.Printf("Warning: Failed to initialize default config: %v", err) + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", "\t") + if err := enc.Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } - } - - mux := http.NewServeMux() - mux.HandleFunc("/", server.handleIndex) - mux.Handle("/_/", http.StripPrefix("/_/", http.FileServerFS(staticFiles))) - mux.HandleFunc("/auth", server.handleAuth) - mux.HandleFunc("/callback", server.handleCallback) + }) bind := fmt.Sprintf(":%d", args.port) log.Println("listening on http://localhost" + bind) diff --git a/static/index.gotmpl b/static/index.gotmpl deleted file mode 100644 index c979f0f983701a229901dfe5f8630819b107178e..0000000000000000000000000000000000000000 --- a/static/index.gotmpl +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - OIDC Playground - - - - - -
-

OIDC Playground

- -
-

Configuration

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
-
- - {{ if .Results }} -
-

Results

- -
-

Token Claims

-
{{ .TokenClaims }}
-
- -
-

User Info

-
{{ .UserInfo }}
-
- -
-

Token Response

-
{{ .TokenResponse }}
-
-
- -
-

Instructions

-
    -
  1. Fill in your OIDC provider details above
  2. -
  3. Make sure your redirect URI is registered with your OIDC provider
  4. -
  5. Click "Start OIDC Flow" to begin authentication
  6. -
  7. After successful authentication, view the tokens and claims below
  8. -
- -
-
- {{ end }} - - - - - - diff --git a/static/prism.css b/static/prism.css deleted file mode 100644 index d5c6a03f0f415086ee0c4cd9a8dbe86ff009f9bd..0000000000000000000000000000000000000000 --- a/static/prism.css +++ /dev/null @@ -1,137 +0,0 @@ -/* PrismJS 1.30.0 -https://prismjs.com/download#themes=prism&languages=json */ -code[class*=language-], -pre[class*=language-] { - color: #000; - background: 0 0; - text-shadow: 0 1px #fff; - font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; - font-size: 1em; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - line-height: 1.5; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - -webkit-hyphens: none; - -moz-hyphens: none; - -ms-hyphens: none; - hyphens: none -} - -code[class*=language-] ::-moz-selection, -code[class*=language-]::-moz-selection, -pre[class*=language-] ::-moz-selection, -pre[class*=language-]::-moz-selection { - text-shadow: none; - background: #b3d4fc -} - -code[class*=language-] ::selection, -code[class*=language-]::selection, -pre[class*=language-] ::selection, -pre[class*=language-]::selection { - text-shadow: none; - background: #b3d4fc -} - -@media print { - - code[class*=language-], - pre[class*=language-] { - text-shadow: none - } -} - -pre[class*=language-] { - padding: 1em; - margin: .5em 0; - overflow: auto -} - -:not(pre)>code[class*=language-], -pre[class*=language-] { - background: #f5f2f0 -} - -:not(pre)>code[class*=language-] { - padding: .1em; - border-radius: .3em; - white-space: normal -} - -.token.cdata, -.token.comment, -.token.doctype, -.token.prolog { - color: #708090 -} - -.token.punctuation { - color: #999 -} - -.token.namespace { - opacity: .7 -} - -.token.boolean, -.token.constant, -.token.deleted, -.token.number, -.token.property, -.token.symbol, -.token.tag { - color: #905 -} - -.token.attr-name, -.token.builtin, -.token.char, -.token.inserted, -.token.selector, -.token.string { - color: #690 -} - -.language-css .token.string, -.style .token.string, -.token.entity, -.token.operator, -.token.url { - color: #9a6e3a; - background: hsla(0, 0%, 100%, .5) -} - -.token.atrule, -.token.attr-value, -.token.keyword { - color: #07a -} - -.token.class-name, -.token.function { - color: #dd4a68 -} - -.token.important, -.token.regex, -.token.variable { - color: #e90 -} - -.token.bold, -.token.important { - font-weight: 700 -} - -.token.italic { - font-style: italic -} - -.token.entity { - cursor: help -} \ No newline at end of file diff --git a/static/prism.js b/static/prism.js deleted file mode 100644 index 18e6d92482ab804bdf02d0343a0afddb0911394b..0000000000000000000000000000000000000000 --- a/static/prism.js +++ /dev/null @@ -1,7 +0,0 @@ - - -/* PrismJS 1.30.0 -https://prismjs.com/download#themes=prism&languages=json */ -var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var P=w.value;if(n.length>e.length)return;if(!(P instanceof i)){var E,S=1;if(y){if(!(E=l(b,A,e,m))||E.index>=e.length)break;var L=E.index,O=E.index+E[0].length,C=A;for(C+=w.value.length;L>=C;)C+=(w=w.next).value.length;if(A=C-=w.value.length,w.value instanceof i)continue;for(var j=w;j!==n.tail&&(Cg.reach&&(g.reach=W);var I=w.prev;if(_&&(I=u(n,I,_),A+=_.length),c(n,I,S),w=u(n,I,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),S>1){var T={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,T),g&&T.reach>g.reach&&(g.reach=T.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); -Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; - diff --git a/static/script.js b/static/script.js deleted file mode 100644 index 62d4dd54d7237df67ed72e151c64350702b68d56..0000000000000000000000000000000000000000 --- a/static/script.js +++ /dev/null @@ -1,3 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - document.getElementById('results-section').scrollIntoView({ behavior: 'smooth' }); -}); diff --git a/static/styles.css b/static/styles.css deleted file mode 100644 index bb26677a92597f0db520c85e7ce3eed564dd29ed..0000000000000000000000000000000000000000 --- a/static/styles.css +++ /dev/null @@ -1,140 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - line-height: 1.6; - color: #333; - background-color: #f5f5f5; -} - -.container { - max-width: 800px; - margin: 0 auto; - padding: 20px; -} - -h1 { - text-align: center; - margin-bottom: 30px; - color: #2c3e50; -} - -.section { - background: white; - margin-bottom: 20px; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.section h2 { - margin-bottom: 15px; - color: #34495e; - border-bottom: 2px solid #3498db; - padding-bottom: 5px; -} - -.form-group { - margin-bottom: 15px; -} - -.form-group label { - display: block; - margin-bottom: 5px; - font-weight: 600; - color: #555; -} - -.form-group input { - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; -} - -.form-group input:focus { - outline: none; - border-color: #3498db; - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); -} - -.form-group input[readonly] { - background-color: #f8f9fa; - color: #6c757d; -} - - -button { - background-color: #3498db; - color: white; - padding: 12px 24px; - border: none; - border-radius: 4px; - font-size: 16px; - cursor: pointer; - transition: background-color 0.3s; -} - -button:hover { - background-color: #2980b9; -} - -button:disabled { - background-color: #bdc3c7; - cursor: not-allowed; -} - -.result-group { - margin-bottom: 20px; -} - -.result-group h3 { - margin-bottom: 10px; - color: #2c3e50; -} - -.result-group textarea { - width: 100%; - min-height: 100px; - padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-family: 'Courier New', monospace; - font-size: 12px; - resize: vertical; -} - -.result-group pre { - background-color: #f8f9fa; - padding: 15px; - border-radius: 4px; - border: 1px solid #e9ecef; - font-family: 'Courier New', monospace; - font-size: 12px; - overflow-x: auto; - white-space: pre-wrap; - word-wrap: break-word; -} - -ol { - padding-left: 20px; -} - -ol li { - margin-bottom: 5px; -} - -@media (max-width: 600px) { - .container { - padding: 10px; - } - - .section { - padding: 15px; - } -} \ No newline at end of file