feat: add OAuth App device flow examples + restructure subdirs#6
Merged
Conversation
Move all existing scripts and docs into a github-app/ subdir
in preparation for adding an oauth-app/ subdir alongside.
This is a pure file move — no content changes — so existing
behaviour is preserved.
- device_flow.{sh,py,js,go} → github-app/
- requirements.txt → github-app/
- docs/{shell,python,nodejs,go}.md → github-app/
- docs/setup-github-app.md → github-app/setup.md (renamed)
- .github/dependabot.yml: directory '/' → '/github-app'
docs/common-issues.md stays for now; it gets restructured into
a top-level cross-mode issues file in a later commit.
Add a sibling oauth-app/ subdir alongside github-app/, starting with the shell script. The OAuth App flow differs from the GitHub App flow at three concrete points: - Requires client_secret on the token exchange (Apps are public clients; OAuth Apps are confidential) - Sends scope on the device-code request (Apps use installation permissions instead) - Issues gho_ tokens (vs ghu_) The script enforces secret hygiene — GITHUB_CLIENT_SECRET must come from the environment, never as a CLI flag, because flags leak to shell history, ps output, and audit logs. Adds two macOS-only conveniences (graceful no-ops elsewhere): the verification URL auto-opens via 'open', and the user code is copied to the clipboard via 'pbcopy'. Both are standalone — the script still prints the URL and code prominently for any platform where the system commands aren't available.
Mirror the oauth-app shell flow in the other three languages. Same protocol differences vs github-app: - Sends scope on the device-code request (default: repo,read:org) - Includes client_secret on the token exchange (OAuth Apps are confidential clients) - Issues gho_ tokens (vs ghu_) Same secret-hygiene contract as the shell version: GITHUB_CLIENT_SECRET must come from the environment, never as a CLI flag. Each script trims whitespace from env vars and validates the Client ID shape before hitting GitHub. The token preview now shows '<prefix>_***<last 8 chars>' instead of the first-20 + last-10 form — exposing only the prefix indicator (already public) plus enough suffix to identify the token in casual screen-sharing or logs. Adds an entry to .github/dependabot.yml so the new oauth-app requirements.txt is also tracked weekly.
Match the safer token preview format introduced in the oauth-app subdir: '<prefix>_***<last 8 chars>' instead of the previous first-20 + ... + last-10 form. Exposes only the prefix indicator (already public — ghu_ means GitHub App user token) plus enough suffix to identify the token in casual screen-shares or logs, without revealing 20 usable bytes. Same change applied to all 4 language scripts and their example-session blocks in shell.md, python.md, nodejs.md, and go.md so the github-app and oauth-app subdirs render identically (modulo the token prefix).
Each subdir is now fully self-contained with its own README listing prereqs, quick-starts in all 4 languages, the flag table, and links to per-language guides + setup. The two READMEs mirror each other so readers can compare the two flows. oauth-app/setup.md walks through creating an OAuth App on github.com or an EMU enterprise. Highlights the EMU-specific behaviour where tokens are SSO-authorised at issuance (so the classic GHEC 'x-github-sso: required' header does not appear), plus the more common EMU 403 causes (org ownership, restrict oauth app access policy, expired IdP session).
Top-level README is now the 'where do I go?' landing page —
GitHub App vs OAuth App comparison table, repo layout diagram,
secret-hygiene note, and links into both subdirs.
The old top-level README (github-app-only) and docs/common-issues.md
(github-app-only) are replaced. The new common-issues.md at root
is cross-mode and includes:
- Client ID format gotcha (Iv1 vs Ov23li, trailing-whitespace foot-gun)
- The 'Not Found' from /login/device/code triage tree
- EMU vs classic GHEC SSO behaviour
- Common EMU 403 causes (org ownership, restrict policy, IdP session)
- Token reference table covering both prefixes
Also fixes inter-doc links in github-app/{shell,python,nodejs,go}.md
that were left pointing at the old in-subdir common-issues.md
during phase 1 — they now correctly target ../common-issues.md.
Apply the macOS auto-open + clipboard polish (previously only on
oauth-app/device_flow.sh) to all 8 device-flow scripts. Linux,
Windows, and headless users see the URL and code in the terminal
as before — both features are graceful no-ops where pbcopy/open
aren't on PATH.
Implementations are language-idiomatic:
- Bash: command -v + open / pbcopy
- Python: webbrowser.open() (cross-platform stdlib) + shutil.which
+ subprocess.run(['pbcopy']) for clipboard
- Node: spawnSync('which', cmd) gate + spawn('open') / spawnSync('pbcopy')
- Go: exec.LookPath gate + exec.Command('open') / exec.Command('pbcopy')
Example sessions in all 7 affected docs (github-app/{shell,python,
nodejs,go}.md and oauth-app/{python,nodejs,go}.md) updated to show
the new lines.
Drops the now-misleading 'Bash usage with macOS auto-open + clipboard
polish' note from oauth-app/README.md — every language has it now.
- Drop misleading `xdg-open` mention from browser/clipboard helper comments in 5 files — code only checks for `open` and `pbcopy`. - oauth-app/device_flow.sh: switch device-code POST to --data-urlencode to match Python/Node/Go encoding. - oauth-app/device_flow.go: error if --scope and -s are both set with different values, mirroring the existing --client-id/-c handling. - oauth-app/device_flow.go: default token_type to "bearer" when the API response omits it, matching shell/Python/Node defaults. - Run gofmt on github-app/device_flow.go const block alignment.
- Shell scripts (github-app + oauth-app/device_flow.sh): wrap pbcopy and open in success-gated `if` blocks so we don't claim "copied" or "opening" when the command fails, and the script doesn't exit on pbcopy failure under `set -euo pipefail`. - Python scripts (github-app + oauth-app/device_flow.py): check `result.returncode == 0` before printing the clipboard success message. webbrowser.open() return value was already gated. - Node.js scripts (github-app + oauth-app/device_flow.js): check `spawnSync(...).status === 0` before printing the clipboard success message. - common-issues.md: fix stale count — "four causes above" → "three causes above" to match the 3-item enumerated list.
- oauth-app/device_flow.js + github-app/device_flow.js: switch async
spawn("open", ...) to spawnSync and gate the "🌐 Opening browser..."
log on r.status === 0, matching the pbcopy pattern. Drops the now-unused
spawn import.
The async + .unref() approach made it impossible to detect a failed
launch synchronously (the success message was already printed by the
time .on("error") would fire). spawnSync is fine here because open(1)
on macOS dispatches to LaunchServices and returns near-instantly — there
is no blocking wait for the browser to load.
Smoke-tested: open with a non-existent path returns status 1 and the
log is correctly suppressed; open with a valid URI returns 0 and the
message prints.
- oauth-app/device_flow.js + github-app/device_flow.js: trim flag-provided clientId/scope values, and trim the env var fallback in github-app/ that was previously missing. Brings flag handling in line with the existing oauth-app env var trim, addresses copy/paste trailing-whitespace foot-gun. - oauth-app/device_flow.py + github-app/device_flow.py: strip the flag-provided client_id/scope before validation. Also adds .strip() to the github-app env var default that was missing it. The empty- check after the strip handles the all-whitespace input correctly. - oauth-app/device_flow.go + github-app/device_flow.go: switch the open(1) launcher from cmd.Start() to cmd.Run() so the success log is gated on the actual zero exit status (Start returns immediately without waiting). Matches the pbcopy pattern in the same file and the synchronous-with-status-check pattern in JS/Python/shell. Smoke-tested: - JS: " Iv1.abcdef123456 \n" → trim → "Iv1.abcdef123456" → CLIENT_ID_PATTERN matches. - Python: same input → strip → matches. - Go: open </nonexistent/path> → exit status 1 → success message correctly suppressed.
- oauth-app shell: replace ${var//[[:space:]]/} (which silently
collapsed internal whitespace and bypassed the regex check) with a
proper trim helper that only strips leading/trailing whitespace.
- oauth-app shell: switch token-exchange POST from -d to
--data-urlencode for client_id, client_secret, device_code, and
grant_type, matching the device-code POST already fixed in R1.
- github-app shell: apply the same --data-urlencode switch to both
device-code and token-exchange POSTs for consistency.
- common-issues.md: scope the Client-ID-regex sentence to the OAuth App scripts (the GitHub App scripts don't have the regex guard yet), so users troubleshooting GitHub App mode aren't misled. - oauth-app/device_flow.sh: trim SCOPE alongside CLIENT_ID and CLIENT_SECRET, matching the JS/Python flag-trim parity already in place. - oauth-app/device_flow.go: strings.TrimSpace(scope) after the merge, matching the existing TrimSpace on clientID + clientSecret.
- oauth-app/device_flow.js + github-app/device_flow.js: extend the
poll-error switch to handle null and empty-string error values
alongside undefined. Previously a GitHub response of
{"error": null} or {"error": ""} fell through to the default
branch and threw 'Unexpected error: null', which is misleading.
The Python implementation already handles None via 'is None' and
the Go implementation already handles "" via 'case "":', so this
brings JS into line with the other two.
Smoke-tested all three falsy values plus authorization_pending,
slow_down, and an unknown error — each now hits the expected
branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The repo now covers both GitHub App device flow (
ghu_tokens) and OAuth App device flow (gho_tokens), with each flavour in its own self-contained subdir. All four languages (shell, Python, Node.js, Go) work in both modes.Repository name
The repo was renamed from
github-app-device-flow→github-device-flow-examplesto reflect the broader scope. GitHub's auto-redirect handles inbound URLs from the old name.Layout
github-app/ghu_tokens (existing scripts moved here)oauth-app/gho_tokens (new)common-issues.mddocs/common-issues.md, expanded)README.mdEach subdir is fully self-contained — its own
README.md,setup.md, fourdevice_flow.{sh,py,js,go}scripts, four per-language guides, and arequirements.txtfor the Python script.What's new
OAuth App device flow in all four languages, mirroring the existing GitHub App pattern but with the OAuth-specific protocol bits:
client_secreton the token exchange (OAuth Apps are confidential clients; GitHub Apps are public clients and don't use one)scopeparameter on the device-code request (vs GitHub Apps which use installation permissions)gho_tokens (vsghu_for GitHub Apps)oauth-app/setup.mddocuments creating an OAuth App on github.com or an EMU enterprise, including the EMU-specific behaviour: tokens are SSO-authorised at issuance, so the classic GHECx-github-sso: requiredheader doesn't appear on EMU. Common 403 causes on EMU (org ownership, Restrict OAuth app access policy, expired IdP session) are listed instead.What's improved across both modes
oauth-app/device_flow.shhad this. Each language uses an idiomatic implementation — Python uses the cross-platform stdlibwebbrowser.open(), Node and Go use platform-checked subprocess calls._***(e.g.gho_***abc12345) instead of the previous.... Smaller surface area in screenshares, logs, and screenshots — only the prefix indicator (which is already public knowledge) plus enough suffix to identify the token.GITHUB_CLIENT_SECRETis read from the environment variable only, never as a CLI flag. The scripts deliberately reject the secret-as-flag pattern because flags leak to shell history,psoutput, and audit logs. The Client ID, which is public information, can still be passed either way.^[A-Za-z0-9._-]+$before hitting GitHub. Fails fast with a clear local message rather than the cryptic upstream{"error": "Not Found"}.Commits (7)
2560cd32409d3809e3bb54e8337bf458ad1132fa5dbc5a003Tested
oauth-app/device_flow.shandoauth-app/device_flow.pyend-to-end against a real OAuth App on an EMU enterprise. Tokens issued,/userself-test passed, scopes confirmed (repo,read:org,user),/orgs//reposreturned 200 with no SSO header.bash -n,ast.parse,node --check,go vet).github-app/requirements.txtandoauth-app/requirements.txt.Breaking changes
Anyone who was running
./device_flow.sh(orpython device_flow.pyetc.) at the repo root will need to update their path togithub-app/device_flow.sh. The README has a clear pointer for both subdirs and a comparison table to help readers pick the right one.Out of scope
device_flow.*wrappers for backwards-compat were considered and rejected — clean break, since the repo is being renamed and reframed at the same time.R1
Copilot review — 2026-06-16, 5 surfaced findings + 3 suppressed (low-confidence). All addressed in
252c42c.github-app/{sh,js,go}+oauth-app/{js,go}xdg-openbut code only checks foropen/pbcopyoauth-app/device_flow.sh:132scopenot URL-encoded, edge cases for:and other reserved charscurl --data-urlencodefor bothclient_idandscope, verified via httpbin echooauth-app/device_flow.go:238--scopeand-saccepted with different values silently — inconsistent with--client-id/-cwhich errors on mismatch--client-idmismatch error path; smoke-tested all three branchesoauth-app/device_flow.go:310tokenTypeprinted raw — would be blank if GitHub API omitted the field"bearer", matching Python/Node destructuring defaults and shell fallbackPlus a
gofmtcleanup ongithub-app/device_flow.goconst block alignment that came in with the same commit.R2
Copilot review — 2026-06-16, 5 surfaced findings + 4 suppressed (low-confidence — same patterns in
github-app/). All addressed ind6f303e.common-issues.md:41oauth-app/device_flow.sh(lines 159-166)set -euo pipefail, a failingpbcopywould exit the script even though the block is meant to be a graceful no-op; success message printed regardlessif … then; fi; success messages now only print on0exit; smoke-tested no-PATH / fail / success scenariosoauth-app/device_flow.py(lines 260-264)subprocess.runexit status not checked — "Code copied" printed even when pbcopy failedresultand gateprintonresult.returncode == 0.webbrowser.open()was already gated on its return valueoauth-app/device_flow.js(lines 244-247)spawnSyncstatus not checked — "Code copied" printed even when pbcopy exited non-zerorand gateconsole.logonr.status === 0github-app/{sh,py,js}The async
spawn("open", ...)browser-launch path in JS is left as-is — the bot didn't flag it (success detection after.unref()is awkward) andopenis effectively a fire-and-forget LaunchServices dispatch on macOS. Happy to switch tospawnSyncfor the browser-open path too if reviewers prefer.R3
Copilot review — 2026-06-16, 1 surfaced finding (the JS browser-open follow-up I called out as a possible R3 item in my R2 reply). Addressed in
984c6ff.oauth-app/device_flow.js(lines 252-257) +github-app/device_flow.js(lines 197-202)console.log("🌐 Opening browser...")runs unconditionally after asyncspawn("open", ...)— claims success even when launch fails. Inconsistent with shell/Python/Go which all gate on zero exit statusspawn+.unref()tospawnSyncso we can synchronously checkr.status === 0before logging.open(1)on macOS dispatches to LaunchServices and returns near-instantly, so making it sync doesn't introduce a noticeable wait. Dropped the now-unusedspawnimport in both files. Smoke-tested: nonexistent target →status: 1, log suppressed; valid URI →status: 0, log printedR4
Copilot review — 2026-06-16, 5 surfaced findings + 1 suppressed (low-confidence — same Go pattern in
github-app/). All addressed inc4bc668.oauth-app/device_flow.js:152(clientId flag) +:162(scope flag)CLIENT_ID_PATTERN.trim()to flag values. Smoke-tested:" Iv1.abcdef… \n"→ trimmed value passes pattern checkoauth-app/device_flow.py:206(client_id flag) +:230(scope flag)client_id = (args.client_id or "").strip()(handlesNonedefensively) andscope = args.scope.strip()oauth-app/device_flow.go:294exec.Command("open", …).Start()returns immediately without waiting for exit, so "🌐 Opening browser..." printed even on non-zero exit.Run()which waits for exit and returns the error; gates onerr == nil. Matches the existing pbcopy.Run()pattern in the same file. Smoke-tested:open </nonexistent/path>→exit status 1, success message correctly suppressedgithub-app/{js,py,go}github-app/device_flow.jsnow also trims the env-var fallback (was missing);github-app/device_flow.pynow strips the env-var default (was missing)R5
Copilot review — 2026-06-16, 2 surfaced findings (no suppressed-low-confidence). Both addressed in
da1652c.oauth-app/device_flow.sh:82,84${var//[[:space:]]/}strips ALL whitespace — silently rewrites"abc def"→"abcdef"and bypasses the^[A-Za-z0-9._-]+$regex check downstreamtrim()helper that strips leading/trailing whitespace only; internal spaces preserved so the regex still fires with a clear error. Smoke-tested 8 inputs (embedded space, trailing newline, all-whitespace, mixed tabs/newlines): copy-paste foot-guns still trimmed, internal whitespace correctly preservedoauth-app/device_flow.sh:177-182-d "key=$VALUE"— values not URL-encoded, so reserved chars inclient_secretordevice_codewould generate an invalid form body--data-urlencode, matching the device-code POST already fixed in R1github-app/device_flow.sh(both POSTs)-dpattern (bot didn't flag — github-app'sclient_idisIv1.…so URL-encoding is a no-op in practice)--data-urlencodefor consistency, matching the project-wide patternR6
Copilot review — 2026-06-16, 3 surfaced findings (no suppressed-low-confidence). All addressed in
3450879.common-issues.md:41Not Foundin GitHub App modeoauth-app/device_flow.sh(post-arg-parse)SCOPEnot trimmed — JS/Python paths were trimmed in R4, the shell version was overlooked. A trailing newline in--scopewould be encoded into the form bodySCOPE="$(trim "$SCOPE")"alongside the existingCLIENT_IDandCLIENT_SECRETtrims, using the sametrim()helper added in R5 (leading/trailing only)oauth-app/device_flow.go:243clientIDandclientSecretwere already going throughstrings.TrimSpace, butscopewasn'tscope = strings.TrimSpace(scope)after the flag-merge / default fallback.gofmt -lclean,go buildgreengithub-app/*scripts were not touched for this round — they have no scope-as-input concept (scope is read from the token response only, since GitHub Apps configure permissions in their settings rather than at request time), so there's no symmetric gap to close.R7
Copilot review — 2026-06-16, 1 surfaced finding (no suppressed-low-confidence). Addressed in
070e382.oauth-app/device_flow.js:108+github-app/device_flow.js:91case undefined:— a GitHub response of{"error": null}or{"error": ""}fell through to the default branch and threwUnexpected error: null/Unexpected error:, which is misleading. The Python version already handles this viaelif error is None:and the Go version viacase "":, so JS was the outliercase undefined:→case null:→case "":to a single throw, matching Python and Go. Applied symmetrically to both subdirs. Smoke-tested all three falsy values plusauthorization_pending,slow_down, and an unknown-error case — each hits the expected branch