Skip to content

feat: add OAuth App device flow examples + restructure subdirs#6

Merged
jusuchin85 merged 14 commits into
mainfrom
jusuchin85/2026-06-16_add-oauth-app-mode
Jun 16, 2026
Merged

feat: add OAuth App device flow examples + restructure subdirs#6
jusuchin85 merged 14 commits into
mainfrom
jusuchin85/2026-06-16_add-oauth-app-mode

Conversation

@jusuchin85

@jusuchin85 jusuchin85 commented Jun 16, 2026

Copy link
Copy Markdown
Owner

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-flowgithub-device-flow-examples to reflect the broader scope. GitHub's auto-redirect handles inbound URLs from the old name.

Layout

Path Purpose
github-app/ GitHub App device flow — ghu_ tokens (existing scripts moved here)
oauth-app/ OAuth App device flow — gho_ tokens (new)
common-issues.md Cross-mode troubleshooting (was docs/common-issues.md, expanded)
README.md Reframed: top-level overview + comparison table + subdir links

Each subdir is fully self-contained — its own README.md, setup.md, four device_flow.{sh,py,js,go} scripts, four per-language guides, and a requirements.txt for 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_secret on the token exchange (OAuth Apps are confidential clients; GitHub Apps are public clients and don't use one)
  • scope parameter on the device-code request (vs GitHub Apps which use installation permissions)
  • Issues gho_ tokens (vs ghu_ for GitHub Apps)

oauth-app/setup.md documents 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 GHEC x-github-sso: required header 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

  • Browser auto-open + clipboard copy now work in all four languages on macOS (graceful no-op on Linux, Windows, headless CI, SSH sessions). Previously only oauth-app/device_flow.sh had this. Each language uses an idiomatic implementation — Python uses the cross-platform stdlib webbrowser.open(), Node and Go use platform-checked subprocess calls.
  • Token preview is now _*** (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.
  • Secret hygiene: GITHUB_CLIENT_SECRET is 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, ps output, and audit logs. The Client ID, which is public information, can still be passed either way.
  • Defensive validation in OAuth App scripts: trim whitespace from env vars (a common copy-paste foot-gun) and validate the Client ID shape against ^[A-Za-z0-9._-]+$ before hitting GitHub. Fails fast with a clear local message rather than the cryptic upstream {"error": "Not Found"}.

Commits (7)

SHA Subject
2560cd3 refactor: move scripts into github-app/ subdir
2409d38 feat: add oauth-app shell device flow
09e3bb5 feat: add oauth-app Python, Node.js, Go device flows
4e8337b refactor: tighten token preview in github-app
f458ad1 docs: add subdir READMEs and oauth-app setup guide
132fa5d docs: reframe README + add common-issues for both flows
bc5a003 feat: auto-open browser + copy code in all device flows

Tested

  • Live tested oauth-app/device_flow.sh and oauth-app/device_flow.py end-to-end against a real OAuth App on an EMU enterprise. Tokens issued, /user self-test passed, scopes confirmed (repo,read:org,user), /orgs//repos returned 200 with no SSO header.
  • All 8 scripts pass syntax checks (bash -n, ast.parse, node --check, go vet).
  • All inter-doc links resolve to existing files.
  • Dependabot configuration updated to track both github-app/requirements.txt and oauth-app/requirements.txt.

Breaking changes

Anyone who was running ./device_flow.sh (or python device_flow.py etc.) at the repo root will need to update their path to github-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

  • No CI / tests added (repo previously had none; demo repo).
  • No release workflow added.
  • Top-level 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.

# File(s) Finding Fix
1 github-app/{sh,js,go} + oauth-app/{js,go} Browser/clipboard helper comment mentions xdg-open but code only checks for open/pbcopy Updated comment in all 5 files to reflect macOS-only behaviour
2 oauth-app/device_flow.sh:132 Device-code POST built body via string concat — scope not URL-encoded, edge cases for : and other reserved chars Switched to curl --data-urlencode for both client_id and scope, verified via httpbin echo
3 oauth-app/device_flow.go:238 --scope and -s accepted with different values silently — inconsistent with --client-id/-c which errors on mismatch Mirrored the --client-id mismatch error path; smoke-tested all three branches
4 oauth-app/device_flow.go:310 tokenType printed raw — would be blank if GitHub API omitted the field Default to "bearer", matching Python/Node destructuring defaults and shell fallback

Plus a gofmt cleanup on github-app/device_flow.go const 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 in d6f303e.

# File(s) Finding Fix
1 common-issues.md:41 "four causes above" but the enumerated list has only 3 items — stale count after edits Changed "four" → "three" to match the list
2 oauth-app/device_flow.sh (lines 159-166) Under set -euo pipefail, a failing pbcopy would exit the script even though the block is meant to be a graceful no-op; success message printed regardless Wrapped pbcopy + open in success-gated if … then; fi; success messages now only print on 0 exit; smoke-tested no-PATH / fail / success scenarios
3 oauth-app/device_flow.py (lines 260-264) subprocess.run exit status not checked — "Code copied" printed even when pbcopy failed Capture result and gate print on result.returncode == 0. webbrowser.open() was already gated on its return value
4 oauth-app/device_flow.js (lines 244-247) spawnSync status not checked — "Code copied" printed even when pbcopy exited non-zero Capture r and gate console.log on r.status === 0
github-app/{sh,py,js} Same patterns flagged as low-confidence in summary Same fixes applied for consistency across the two subdirs

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) and open is effectively a fire-and-forget LaunchServices dispatch on macOS. Happy to switch to spawnSync for 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.

# File(s) Finding Fix
1 oauth-app/device_flow.js (lines 252-257) + github-app/device_flow.js (lines 197-202) console.log("🌐 Opening browser...") runs unconditionally after async spawn("open", ...) — claims success even when launch fails. Inconsistent with shell/Python/Go which all gate on zero exit status Switched async spawn + .unref() to spawnSync so we can synchronously check r.status === 0 before 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-unused spawn import in both files. Smoke-tested: nonexistent target → status: 1, log suppressed; valid URI → status: 0, log printed

R4

Copilot review — 2026-06-16, 5 surfaced findings + 1 suppressed (low-confidence — same Go pattern in github-app/). All addressed in c4bc668.

# File(s) Finding Fix
1 oauth-app/device_flow.js:152 (clientId flag) + :162 (scope flag) Flag-provided values used as-is; env-var path was already trimmed, leaving the two paths inconsistent. Trailing whitespace from copy/paste would fail CLIENT_ID_PATTERN Apply .trim() to flag values. Smoke-tested: " Iv1.abcdef… \n" → trimmed value passes pattern check
2 oauth-app/device_flow.py:206 (client_id flag) + :230 (scope flag) Same pattern in Python — flag values not stripped before validation client_id = (args.client_id or "").strip() (handles None defensively) and scope = args.scope.strip()
3 oauth-app/device_flow.go:294 exec.Command("open", …).Start() returns immediately without waiting for exit, so "🌐 Opening browser..." printed even on non-zero exit Switched to .Run() which waits for exit and returns the error; gates on err == nil. Matches the existing pbcopy .Run() pattern in the same file. Smoke-tested: open </nonexistent/path>exit status 1, success message correctly suppressed
github-app/{js,py,go} Same three patterns also present here (Go was the suppressed-low-confidence finding; JS/Python lacked the env-var trim entirely) Same fixes applied symmetrically. github-app/device_flow.js now also trims the env-var fallback (was missing); github-app/device_flow.py now strips the env-var default (was missing)

R5

Copilot review — 2026-06-16, 2 surfaced findings (no suppressed-low-confidence). Both addressed in da1652c.

# File(s) Finding Fix
1 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 downstream Added a trim() 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 preserved
2 oauth-app/device_flow.sh:177-182 Token-exchange POST uses -d "key=$VALUE" — values not URL-encoded, so reserved chars in client_secret or device_code would generate an invalid form body Switched all 4 fields to --data-urlencode, matching the device-code POST already fixed in R1
github-app/device_flow.sh (both POSTs) Same -d pattern (bot didn't flag — github-app's client_id is Iv1.… so URL-encoding is a no-op in practice) Switched both POSTs to --data-urlencode for consistency, matching the project-wide pattern

R6

Copilot review — 2026-06-16, 3 surfaced findings (no suppressed-low-confidence). All addressed in 3450879.

# File(s) Finding Fix
1 common-issues.md:41 Sentence claimed "the shell, Python, Node.js, and Go scripts all validate the Client ID with a regex" — only the OAuth App scripts actually do this; the GitHub App scripts have no regex guard. Misleads users troubleshooting Not Found in GitHub App mode Scoped the claim to the OAuth App scripts and added an explicit note that the GitHub App scripts don't have the guard yet, so users in GitHub App mode know to fall back to the three causes listed above
2 oauth-app/device_flow.sh (post-arg-parse) SCOPE not trimmed — JS/Python paths were trimmed in R4, the shell version was overlooked. A trailing newline in --scope would be encoded into the form body Added SCOPE="$(trim "$SCOPE")" alongside the existing CLIENT_ID and CLIENT_SECRET trims, using the same trim() helper added in R5 (leading/trailing only)
3 oauth-app/device_flow.go:243 Same gap — clientID and clientSecret were already going through strings.TrimSpace, but scope wasn't Added scope = strings.TrimSpace(scope) after the flag-merge / default fallback. gofmt -l clean, go build green

github-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.

# File(s) Finding Fix
1 oauth-app/device_flow.js:108 + github-app/device_flow.js:91 Poll-error switch only matched case undefined: — a GitHub response of {"error": null} or {"error": ""} fell through to the default branch and threw Unexpected error: null / Unexpected error: , which is misleading. The Python version already handles this via elif error is None: and the Go version via case "":, so JS was the outlier Extended the switch to fall through case undefined:case null:case "": to a single throw, matching Python and Go. Applied symmetrically to both subdirs. Smoke-tested all three falsy values plus authorization_pending, slow_down, and an unknown-error case — each hits the expected branch

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.
@jusuchin85 jusuchin85 requested a review from Copilot June 16, 2026 02:43
@jusuchin85 jusuchin85 self-assigned this Jun 16, 2026
@jusuchin85 jusuchin85 added the enhancement New feature or request label Jun 16, 2026

This comment was marked as outdated.

- 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.

This comment was marked as outdated.

- 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.
@jusuchin85 jusuchin85 requested a review from Copilot June 16, 2026 04:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

This comment was marked as outdated.

- 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.

This comment was marked as outdated.

- 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.

This comment was marked as outdated.

- 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.

This comment was marked as outdated.

- 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.

This comment was marked as outdated.

- 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 24/26 changed files
  • Comments generated: 0 new

@jusuchin85 jusuchin85 merged commit e8172ca into main Jun 16, 2026
5 checks passed
@jusuchin85 jusuchin85 deleted the jusuchin85/2026-06-16_add-oauth-app-mode branch June 16, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants