From 9861e2699d4eca6a012d5fcd83d5ea2ecde4002e Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Thu, 13 Aug 2026 06:19:15 -0500 Subject: [PATCH] Add OAuth 2.0 PKCE login for native apps and optional GitHub login. CLI tools can complete a loopback authorization-code flow instead of posting a registry password; GitHub login is available when an OAuth App is configured. Co-authored-by: Cursor --- README.md | 21 + api-docs/oauth.md | 88 ++++ source/app.d | 8 +- source/dubregistry/config.d | 5 + source/dubregistry/oauth.d | 778 ++++++++++++++++++++++++++++++++ source/dubregistry/oauthflags.d | 8 + source/dubregistry/oauthstore.d | 109 +++++ source/dubregistry/web.d | 16 +- views/oauth.authorize.dt | 35 ++ views/oauth.error.dt | 11 + views/userman.login.dt | 9 + 11 files changed, 1084 insertions(+), 4 deletions(-) create mode 100644 api-docs/oauth.md create mode 100644 source/dubregistry/oauth.d create mode 100644 source/dubregistry/oauthflags.d create mode 100644 source/dubregistry/oauthstore.d create mode 100644 views/oauth.authorize.dt create mode 100644 views/oauth.error.dt diff --git a/README.md b/README.md index d42c7470..f8403a59 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,27 @@ It's recommended to create a separate account for the DUB registry GitHub authen It's absolutely recommended to create a personal access token without any extra permissions for your GitHub account instead of entering your password plain text into the settings file. You can generate an access token at https://github.com/settings/tokens (Settings -> Developer Settings -> Personal access tokens) +Native app / CLI login (OAuth) +------------------------------ + +Package-management tools (for example a local `dub-publish` CLI) can log in with +the OAuth 2.0 authorization-code + PKCE flow and a loopback callback instead of +posting a password. See [api-docs/oauth.md](api-docs/oauth.md). + +Optional GitHub login for the website (and thus for that authorize step) uses a +GitHub OAuth App. Add the app's credentials to `settings.json` and set the +callback URL to `https:///login/github/callback`: + +```json +{ + "github-oauth-client-id": "", + "github-oauth-client-secret": "" +} +``` + +This is separate from `github-auth`, which is only a personal access token for +GitHub API rate limits when polling packages. + ### SECURITY NOTICE Development versions prior to 2.3.0 were leaking the GitLab private token in error messages shown to the user. Please make sure to use the latest version along with a freshly generated token. diff --git a/api-docs/oauth.md b/api-docs/oauth.md new file mode 100644 index 00000000..dc99b6a2 --- /dev/null +++ b/api-docs/oauth.md @@ -0,0 +1,88 @@ +# Native app OAuth (authorization code + PKCE) + +The registry issues access tokens to local CLI/GUI tools so they can manage +packages without posting a password. This is OAuth 2.0 authorization code with +PKCE (RFC 7636) and loopback redirects (RFC 8252). + +Website login with GitHub is separate: `GET /login/github` → GitHub → +`GET /login/github/callback`. It is enabled only when `github-oauth-client-id` +and `github-oauth-client-secret` are set. + +## Public clients + +No client registration. `client_id` is an optional label (`native`, +`dub-publish`, `dubx`, …). Every client is public: PKCE `S256` is required, and +`redirect_uri` must be `http://127.0.0.1`, `http://localhost`, or `http://[::1]` +(any port and path). + +Scope: `packages` (manage packages for the logged-in user). + +## `GET /oauth/authorize` + +Query: + +| Param | Required | Notes | +| --- | --- | --- | +| `response_type` | yes | `code` | +| `client_id` | no | default `native` | +| `redirect_uri` | yes | loopback HTTP URI | +| `state` | recommended | echoed back | +| `code_challenge` | yes | S256 | +| `code_challenge_method` | yes | `S256` | +| `scope` | no | default `packages` | + +If the browser has no session, the user is sent to `/login?redirect=…` (password +and, when configured, GitHub). After login they see a consent page. Authorize +redirects to `redirect_uri?code=…&state=…`. Deny uses `error=access_denied`. + +Invalid `redirect_uri` is **not** redirected (error page instead). + +## `POST /oauth/token` + +`application/x-www-form-urlencoded` or `application/json`: + +| Field | Required | +| --- | --- | +| `grant_type` | `authorization_code` | +| `code` | from the callback | +| `redirect_uri` | exact match of the authorize request | +| `code_verifier` | PKCE verifier | +| `client_id` | same as authorize | + +Success: + +```json +{ + "access_token": "…", + "token_type": "Bearer", + "expires_in": 7776000, + "scope": "packages" +} +``` + +Authorization codes are single-use and expire in 5 minutes. Access tokens expire +in 90 days. Codes and tokens are stored as SHA-256 hashes. + +Errors use OAuth JSON: `{"error":"invalid_grant","error_description":"…"}`. + +## `POST /oauth/revoke` + +Form/JSON field `token`. Always returns 200 if the request is well-formed. + +## Calling authenticated routes + +Send the token on existing owner routes (register package, my_packages, …): + +``` +Authorization: Bearer +``` + +Session cookies still work for the browser. + +## CLI sketch + +1. Listen on `http://127.0.0.1:/callback`. +2. Create a PKCE verifier and S256 challenge. +3. Open `/oauth/authorize?response_type=code&client_id=dub-publish&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256`. +4. On the callback, `POST /oauth/token` with the code and verifier. +5. Store the access token; send `Authorization: Bearer` on later requests. diff --git a/source/app.d b/source/app.d index ae5f741f..ec96baa5 100644 --- a/source/app.d +++ b/source/app.d @@ -16,6 +16,8 @@ import dubregistry.registry; import dubregistry.web; import dubregistry.api; import dubregistry.config; +import dubregistry.oauth; +import dubregistry.oauthstore; import std.algorithm : sort; import std.process : environment; @@ -155,6 +157,7 @@ void main() s_registry = new DubRegistry(regsettings); UserManController userdb; + OAuthStore oauthStore; if (!s_mirror.length) { // user management @@ -184,6 +187,7 @@ void main() } userdb = createUserManController(udbsettings); + oauthStore = new OAuthStore(databaseName); } if (noServe) { @@ -193,7 +197,9 @@ void main() } // web front end - s_web = router.registerDubRegistryWebFrontend(s_registry, userdb); + if (userdb && oauthStore) + registerDubRegistryOAuth(router, userdb, oauthStore, appConfig); + s_web = router.registerDubRegistryWebFrontend(s_registry, userdb, oauthStore); router.registerDubRegistryAPI(s_registry); // check whether dummy data should be loaded diff --git a/source/dubregistry/config.d b/source/dubregistry/config.d index ef06a830..46069f71 100644 --- a/source/dubregistry/config.d +++ b/source/dubregistry/config.d @@ -20,6 +20,10 @@ public struct AppConfig { @Name("github-auth") @Optional string ghauth; + @Name("github-oauth-client-id") @Optional + string ghoauthid; + @Name("github-oauth-client-secret") @Optional + string ghoauthsecret; @Name("gitlab-url") @Optional string glurl; @Name("gitlab-auth") @Optional @@ -116,6 +120,7 @@ unittest `; auto conf = AppConfig.readString(str); assert(conf.ghauth == "foo"); + assert(conf.ghoauthid.length == 0); assert(conf.glauth.length == 0); assert(conf.enforceCertificateTrust == true); assert(conf.mailConnectionType == SMTPConnectionType.startTLS); diff --git a/source/dubregistry/oauth.d b/source/dubregistry/oauth.d new file mode 100644 index 00000000..7c26ef32 --- /dev/null +++ b/source/dubregistry/oauth.d @@ -0,0 +1,778 @@ +/** + OAuth 2.0 authorization-code + PKCE for native/CLI apps (RFC 8252) + and optional GitHub OAuth login for the website. + + Copyright: © 2013-2026 rejectedsoftware e.K. + License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. +*/ +module dubregistry.oauth; + +import dubregistry.config; +import dubregistry.internal.utils : generateRandomHash; +import dubregistry.oauthflags; +import dubregistry.oauthstore; + +import core.time; +import std.algorithm : canFind, startsWith; +import std.array : appender; +import std.base64 : Base64URLNoPadding; +import std.conv : to; +import std.datetime.systime; +import std.datetime.timezone; +import std.digest : toHexString; +import std.digest.sha : sha256Of; +import std.exception : enforce; +import std.string : icmp, indexOf, strip, toLower; +import std.typecons : Nullable; + +import userman.api; +import userman.db.controller : UserManController; +import userman.web : createLocalUserManAPI; + +import vibe.core.log; +import vibe.data.json; +import vibe.http.client; +import vibe.http.router; +import vibe.http.server; +import vibe.http.status; +import vibe.inet.url; +import vibe.stream.operations : readAllUTF8; +import vibe.textfilter.urlencode; + + +enum oauthAccessTokenTTL = 90.days; +enum oauthAuthCodeTTL = 5.minutes; +enum oauthDefaultClientId = "native"; +enum oauthDefaultScope = "packages"; + +private __gshared string g_githubClientId; +private __gshared string g_githubClientSecret; +private __gshared string g_serviceURL; + +/** Registers native-app OAuth and optional GitHub login routes. + + Must be called before the static-file catch-all. +*/ +void registerDubRegistryOAuth(URLRouter router, UserManController userman, + OAuthStore store, AppConfig appConfig) +{ + g_githubClientId = appConfig.ghoauthid; + g_githubClientSecret = appConfig.ghoauthsecret; + g_serviceURL = appConfig.serviceURL; + githubOAuthEnabled = g_githubClientId.length > 0 && g_githubClientSecret.length > 0; + + auto oauth = new DubRegistryOAuth(userman, store); + router.get("/oauth/authorize", (req, res) @trusted { oauth.getAuthorize(req, res); }); + router.post("/oauth/authorize", (req, res) @trusted { oauth.postAuthorize(req, res); }); + router.post("/oauth/token", (req, res) @trusted { oauth.postToken(req, res); }); + router.post("/oauth/revoke", (req, res) @trusted { oauth.postRevoke(req, res); }); + router.get("/login/github", (req, res) @trusted { oauth.getGitHubLogin(req, res); }); + router.get("/login/github/callback", (req, res) @trusted { oauth.getGitHubCallback(req, res); }); +} + +/** Returns the logged-in user for a valid Bearer token, or `Nullable.init`. */ +Nullable!User tryBearerAuth(HTTPServerRequest req, UserManController userman, OAuthStore store) +{ + Nullable!User none; + if (!store || !userman) + return none; + auto token = extractBearerToken(req); + if (!token.length) + return none; + auto rec = store.findTokenHash(sha256Hex(token)); + if (rec.isNull) + return none; + try { + auto api = createLocalUserManAPI(userman); + auto user = api.users.get(User.ID.fromString(rec.get.userId)); + if (!user.active || user.banned) + return none; + none = user; + return none; + } catch (Exception e) { + logDiagnostic("Bearer token user lookup failed: %s", e.msg); + return Nullable!User.init; + } +} + +string extractBearerToken(HTTPServerRequest req) +@safe { + return extractBearerToken(req.headers.get("Authorization", "")); +} + +string extractBearerToken(string h) +@safe { + if (h.length < 8) + return null; + auto space = h.indexOf(' '); + if (space <= 0) + return null; + if (icmp(h[0 .. space], "Bearer") != 0) + return null; + auto tok = strip(h[space + 1 .. $]); + return tok.length ? tok : null; +} + +struct AuthorizeRequest { + string responseType; + string clientId = oauthDefaultClientId; + string redirectUri; + string state; + string codeChallenge; + string codeChallengeMethod; + string scopeName = oauthDefaultScope; +} + +AuthorizeRequest parseAuthorizeParams(scope HTTPServerRequest req) +@safe { + string get(string key, string def = "") + { + if (req.method == HTTPMethod.POST) { + auto fv = req.form.get(key, ""); + if (fv.length) + return fv; + } + auto qv = req.query.get(key, def); + return qv.length ? qv : def; + } + + AuthorizeRequest ar; + ar.responseType = get("response_type"); + ar.clientId = get("client_id", oauthDefaultClientId); + ar.redirectUri = get("redirect_uri"); + ar.state = get("state"); + ar.codeChallenge = get("code_challenge"); + ar.codeChallengeMethod = get("code_challenge_method"); + ar.scopeName = get("scope", oauthDefaultScope); + if (!ar.clientId.length) + ar.clientId = oauthDefaultClientId; + if (!ar.scopeName.length) + ar.scopeName = oauthDefaultScope; + return ar; +} + +/** Loopback redirect URIs for native apps (RFC 8252 §7.3). */ +bool isLoopbackRedirectURI(string uri) +@safe { + if (!uri.length || uri.length > 512) + return false; + if (uri.canFind('\r') || uri.canFind('\n') || uri.canFind('\\')) + return false; + + URL url; + try url = URL(uri); + catch (Exception) + return false; + + if (url.schema != "http") + return false; + if (url.username.length || url.password.length) + return false; + + auto host = url.host; + if (host.length >= 2 && host[0] == '[' && host[$ - 1] == ']') + host = host[1 .. $ - 1]; + if (host != "127.0.0.1" && host != "localhost" && host != "::1") + return false; + + return true; +} + +bool isValidClientId(string id) +@safe { + import std.ascii : isAlphaNum; + if (!id.length || id.length > 64) + return false; + foreach (dchar ch; id) { + if (!ch.isAlphaNum && ch != '.' && ch != '_' && ch != '-') + return false; + } + return true; +} + +bool isValidPKCEVerifier(string verifier) +@safe { + import std.ascii : isAlphaNum; + if (verifier.length < 43 || verifier.length > 128) + return false; + foreach (dchar ch; verifier) { + if (!ch.isAlphaNum && ch != '-' && ch != '.' && ch != '_' && ch != '~') + return false; + } + return true; +} + +bool isValidPKCEChallenge(string challenge) +@safe { + import std.ascii : isAlphaNum; + if (challenge.length < 43 || challenge.length > 128) + return false; + foreach (dchar ch; challenge) { + if (!ch.isAlphaNum && ch != '-' && ch != '_') + return false; + } + return true; +} + +string pkceChallengeS256(string verifier) +@safe { + auto digest = sha256Of(verifier); + return Base64URLNoPadding.encode(digest[]).idup; +} + +string sha256Hex(string data) +@safe { + auto hex = toHexString(sha256Of(data)); + return hex[].idup; +} + +bool isSafeLocalRedirect(string url) +@safe { + if (!url.length) + return false; + if (url[0] != '/') + return false; + if (url.startsWith("//") || url.startsWith("/\\")) + return false; + if (url.canFind('\\') || url.canFind('\r') || url.canFind('\n')) + return false; + return true; +} + +string validateAuthorizeRequest(const ref AuthorizeRequest ar) +@safe { + if (ar.responseType != "code") + return "response_type must be \"code\""; + if (!isValidClientId(ar.clientId)) + return "invalid client_id"; + if (!isLoopbackRedirectURI(ar.redirectUri)) + return "redirect_uri must be an http loopback URI (127.0.0.1, localhost, or [::1])"; + if (ar.codeChallengeMethod != "S256") + return "code_challenge_method must be S256"; + if (!isValidPKCEChallenge(ar.codeChallenge)) + return "invalid code_challenge"; + if (ar.scopeName != oauthDefaultScope) + return "unsupported scope (only \"packages\" is allowed)"; + return null; +} + +string appendQuery(string uri, string[string] params) +@safe { + auto app = appender!string(); + app.put(uri); + bool first = uri.indexOf('?') < 0; + foreach (key, value; params) { + app.put(first ? '?' : '&'); + first = false; + app.put(urlEncode(key)); + app.put('='); + app.put(urlEncode(value)); + } + return app.data; +} + +private string requestTarget(HTTPServerRequest req) +@safe { + auto path = req.requestPath.toString(); + if (req.queryString.length) + return path ~ "?" ~ req.queryString; + return path; +} + +private final class DubRegistryOAuth { + private { + UserManController m_userman; + UserManAPI m_api; + OAuthStore m_store; + } + + this(UserManController userman, OAuthStore store) + { + m_userman = userman; + m_api = createLocalUserManAPI(userman); + m_store = store; + } + + void getAuthorize(HTTPServerRequest req, HTTPServerResponse res) + { + auto ar = parseAuthorizeParams(req); + if (auto err = validateAuthorizeRequest(ar)) { + auto error = err; + res.statusCode = HTTPStatus.badRequest; + res.render!("oauth.error.dt", req, error); + return; + } + + auto userN = currentUser(req); + if (userN.isNull) { + res.redirect("/login?redirect=" ~ urlEncode(requestTarget(req))); + return; + } + + User user = userN.get; + string error; + res.render!("oauth.authorize.dt", req, ar, error, user); + } + + void postAuthorize(HTTPServerRequest req, HTTPServerResponse res) + { + auto ar = parseAuthorizeParams(req); + if (auto err = validateAuthorizeRequest(ar)) { + auto error = err; + res.statusCode = HTTPStatus.badRequest; + res.render!("oauth.error.dt", req, error); + return; + } + + auto user = currentUser(req); + if (user.isNull) { + string[string] q; + q["response_type"] = ar.responseType; + q["client_id"] = ar.clientId; + q["redirect_uri"] = ar.redirectUri; + q["state"] = ar.state; + q["code_challenge"] = ar.codeChallenge; + q["code_challenge_method"] = ar.codeChallengeMethod; + q["scope"] = ar.scopeName; + res.redirect("/login?redirect=" ~ urlEncode(appendQuery("/oauth/authorize", q))); + return; + } + if (!user.get.active || user.get.banned) { + auto error = "This account cannot authorize applications."; + res.statusCode = HTTPStatus.forbidden; + res.render!("oauth.error.dt", req, error); + return; + } + + auto allow = req.form.get("allow", ""); + if (allow != "1") { + string[string] errq; + errq["error"] = "access_denied"; + errq["error_description"] = "The user denied the request"; + if (ar.state.length) + errq["state"] = ar.state; + res.redirect(appendQuery(ar.redirectUri, errq)); + return; + } + + auto code = generateRandomHash!32; + OAuthAuthCode rec; + rec.codeHash = sha256Hex(code); + rec.userId = user.get.id.toString(); + rec.clientId = ar.clientId; + rec.redirectUri = ar.redirectUri; + rec.codeChallenge = ar.codeChallenge; + rec.codeChallengeMethod = ar.codeChallengeMethod; + rec.scopeName = ar.scopeName; + rec.expiresAt = Clock.currTime(UTC()) + oauthAuthCodeTTL; + m_store.putCode(rec); + + string[string] q; + q["code"] = code; + if (ar.state.length) + q["state"] = ar.state; + res.redirect(appendQuery(ar.redirectUri, q)); + } + + void postToken(HTTPServerRequest req, HTTPServerResponse res) + { + try { + auto grant = oauthParam(req, "grant_type"); + enforceOAuth(grant == "authorization_code", "unsupported_grant_type", + "grant_type must be authorization_code", HTTPStatus.badRequest); + + auto code = oauthParam(req, "code"); + auto redirectUri = oauthParam(req, "redirect_uri"); + auto verifier = oauthParam(req, "code_verifier"); + auto clientId = oauthParam(req, "client_id", oauthDefaultClientId); + if (!clientId.length) + clientId = oauthDefaultClientId; + + enforceOAuth(code.length && redirectUri.length && verifier.length, + "invalid_request", "code, redirect_uri and code_verifier are required", + HTTPStatus.badRequest); + enforceOAuth(isValidClientId(clientId), "invalid_client", "invalid client_id", + HTTPStatus.badRequest); + enforceOAuth(isValidPKCEVerifier(verifier), "invalid_request", + "invalid code_verifier", HTTPStatus.badRequest); + + auto rec = m_store.takeCode(sha256Hex(code)); + enforceOAuth(!rec.isNull, "invalid_grant", "invalid or expired authorization code", + HTTPStatus.badRequest); + enforceOAuth(rec.get.redirectUri == redirectUri, "invalid_grant", + "redirect_uri does not match", HTTPStatus.badRequest); + enforceOAuth(rec.get.clientId == clientId, "invalid_grant", + "client_id does not match", HTTPStatus.badRequest); + enforceOAuth(pkceChallengeS256(verifier) == rec.get.codeChallenge, + "invalid_grant", "PKCE verification failed", HTTPStatus.badRequest); + + auto accessToken = generateRandomHash!32; + OAuthAccessToken tok; + tok.tokenHash = sha256Hex(accessToken); + tok.userId = rec.get.userId; + tok.clientId = rec.get.clientId; + tok.scopeName = rec.get.scopeName; + tok.createdAt = Clock.currTime(UTC()); + tok.expiresAt = tok.createdAt + oauthAccessTokenTTL; + m_store.putToken(tok); + + Json body = Json.emptyObject; + body["access_token"] = accessToken; + body["token_type"] = "Bearer"; + body["expires_in"] = oauthAccessTokenTTL.total!"seconds"; + body["scope"] = rec.get.scopeName; + res.writeJsonBody(body); + } catch (OAuthHTTPException e) { + writeOAuthError(res, e); + } catch (Exception e) { + logWarn("OAuth token endpoint failed: %s", e.msg); + writeOAuthError(res, new OAuthHTTPException("server_error", + "token request failed", HTTPStatus.internalServerError)); + } + } + + void postRevoke(HTTPServerRequest req, HTTPServerResponse res) + { + auto token = oauthParam(req, "token"); + if (token.length) + m_store.deleteToken(sha256Hex(token)); + res.statusCode = HTTPStatus.ok; + res.writeBody("", "text/plain"); + } + + void getGitHubLogin(HTTPServerRequest req, HTTPServerResponse res) + { + if (!githubOAuthEnabled) { + auto error = "GitHub login is not configured on this instance."; + res.statusCode = HTTPStatus.serviceUnavailable; + res.render!("oauth.error.dt", req, error); + return; + } + + auto redirectTo = req.query.get("redirect", ""); + if (redirectTo.length && !isSafeLocalRedirect(redirectTo)) + redirectTo = ""; + + if (!req.session) + req.session = res.startSession(); + auto state = generateRandomHash!16; + req.session.set("github_oauth_state", state); + req.session.set("github_oauth_redirect", redirectTo); + + string[string] q; + q["client_id"] = g_githubClientId; + q["redirect_uri"] = githubCallbackURL(); + q["state"] = state; + q["scope"] = "read:user user:email"; + res.redirect(appendQuery("https://github.com/login/oauth/authorize", q)); + } + + void getGitHubCallback(HTTPServerRequest req, HTTPServerResponse res) + { + if (!githubOAuthEnabled) { + auto error = "GitHub login is not configured on this instance."; + res.statusCode = HTTPStatus.serviceUnavailable; + res.render!("oauth.error.dt", req, error); + return; + } + + auto errCode = req.query.get("error", ""); + if (errCode.length) { + auto error = "GitHub login was cancelled or failed (" ~ errCode ~ ")."; + res.statusCode = HTTPStatus.badRequest; + res.render!("oauth.error.dt", req, error); + return; + } + + auto state = req.query.get("state", ""); + auto code = req.query.get("code", ""); + auto sessState = req.session ? req.session.get!string("github_oauth_state", "") : ""; + if (!code.length || !state.length || !sessState.length || state != sessState) { + auto error = "Invalid GitHub login callback. Please try again."; + res.statusCode = HTTPStatus.badRequest; + res.render!("oauth.error.dt", req, error); + return; + } + + auto redirectTo = req.session.get!string("github_oauth_redirect", ""); + req.session.remove("github_oauth_state"); + req.session.remove("github_oauth_redirect"); + + try { + auto user = loginOrRegisterFromGitHub(code); + startUserSession(req, res, user); + if (!isSafeLocalRedirect(redirectTo)) + redirectTo = "/"; + res.redirect(redirectTo); + } catch (Exception e) { + logWarn("GitHub OAuth login failed: %s", e.msg); + auto error = "GitHub login failed: " ~ e.msg; + res.statusCode = HTTPStatus.badGateway; + res.render!("oauth.error.dt", req, error); + } + } + + private Nullable!User currentUser(HTTPServerRequest req) + @safe { + Nullable!User none; + if (!req.session) + return none; + auto name = req.session.get!string("userName", ""); + if (!name.length) + return none; + try { + none = m_api.users.getByName(name); + return none; + } catch (Exception) + return Nullable!User.init; + } + + private void startUserSession(HTTPServerRequest req, HTTPServerResponse res, User user) + @safe { + if (!req.session) + req.session = res.startSession(); + req.session.set("userEmail", user.email); + req.session.set("userName", user.name); + req.session.set("userFullName", user.fullName); + req.session.set("userID", user.id.toString()); + } + + private User loginOrRegisterFromGitHub(string code) + { + auto ghToken = exchangeGitHubCode(code); + auto ghUser = githubAPI("https://api.github.com/user", ghToken); + auto emails = githubAPI("https://api.github.com/user/emails", ghToken); + + string ghId; + if (ghUser["id"].type == Json.Type.string) + ghId = ghUser["id"].get!string; + else + ghId = ghUser["id"].to!string; + auto login = ghUser["login"].opt!string; + auto fullName = ghUser["name"].opt!string; + if (!fullName.length) + fullName = login; + auto email = pickGitHubEmail(ghUser, emails); + enforce(email.length, "GitHub account has no verified email address."); + + User user; + bool found; + try { + user = m_api.users.getByEmail(email); + found = true; + } catch (Exception) {} + + if (!found) { + auto username = sanitizeUserName(login, ghId); + auto password = generateRandomHash!16; + try { + auto id = m_api.users.register(email, username, fullName, password); + user = m_api.users.get(id); + } catch (Exception e) { + username = "gh-" ~ ghId; + auto id = m_api.users.register(email, username, fullName, password); + user = m_api.users.get(id); + } + } + + enforce(user.active, "This account is not yet activated."); + enforce(!user.banned, "This account is banned."); + m_userman.setProperty(user.id, "github_id", Json(ghId)); + return user; + } +} + +private string sanitizeUserName(string login, string ghId) +@safe { + import std.ascii : isAlphaNum; + auto app = appender!string(); + foreach (dchar ch; login.toLower) { + if (ch.isAlphaNum || ch == '_') + app.put(ch); + } + auto name = app.data; + if (name.length < 3) + return "gh-" ~ ghId; + if (name.length > 32) + name = name[0 .. 32]; + return name; +} + +private string pickGitHubEmail(Json user, Json emails) +@safe { + if (emails.type == Json.Type.array) { + foreach (e; emails) { + if (e["primary"].opt!bool && e["verified"].opt!bool) + return e["email"].opt!string; + } + foreach (e; emails) { + if (e["verified"].opt!bool) + return e["email"].opt!string; + } + } + if (user["email"].type == Json.Type.string) + return user["email"].get!string; + return null; +} + +private string githubCallbackURL() +@safe { + auto base = g_serviceURL; + if (!base.length) + base = "https://code.dlang.org/"; + if (base[$ - 1] != '/') + base ~= "/"; + return base ~ "login/github/callback"; +} + +private string exchangeGitHubCode(string code) +{ + Json body; + requestHTTP("https://github.com/login/oauth/access_token", + (scope req) { + req.method = HTTPMethod.POST; + req.headers["Accept"] = "application/json"; + req.headers["User-Agent"] = "dub-registry"; + Json payload = Json.emptyObject; + payload["client_id"] = g_githubClientId; + payload["client_secret"] = g_githubClientSecret; + payload["code"] = code; + payload["redirect_uri"] = githubCallbackURL(); + req.writeJsonBody(payload); + }, + (scope res) { + enforce(res.statusCode < 400, "GitHub token exchange failed"); + body = parseJsonString(res.bodyReader.readAllUTF8()); + }); + auto token = body["access_token"].opt!string; + enforce(token.length, "GitHub did not return an access token"); + return token; +} + +private Json githubAPI(string url, string token) +{ + Json body; + requestHTTP(url, + (scope req) { + req.headers["Accept"] = "application/vnd.github+json"; + req.headers["Authorization"] = "Bearer " ~ token; + req.headers["User-Agent"] = "dub-registry"; + }, + (scope res) { + enforce(res.statusCode < 400, "GitHub API request failed"); + body = parseJsonString(res.bodyReader.readAllUTF8()); + }); + return body; +} + +private string oauthParam(HTTPServerRequest req, string key, string def = "") +@safe { + auto ct = req.contentType; + if (ct.startsWith("application/json")) { + if (req.json.type == Json.Type.object) { + auto v = req.json[key].opt!string; + if (v.length) + return v; + } + return def; + } + auto fv = req.form.get(key, ""); + return fv.length ? fv : def; +} + +private class OAuthHTTPException : Exception { + string error; + int status; + this(string error, string description, int status) + { + super(description); + this.error = error; + this.status = status; + } +} + +private void enforceOAuth(bool cond, string error, string description, int status) +{ + if (!cond) + throw new OAuthHTTPException(error, description, status); +} + +private void writeOAuthError(HTTPServerResponse res, OAuthHTTPException e) +{ + res.statusCode = e.status; + Json body = Json.emptyObject; + body["error"] = e.error; + body["error_description"] = e.msg; + res.writeJsonBody(body); +} + +@safe unittest +{ + assert(isLoopbackRedirectURI("http://127.0.0.1:43781/callback")); + assert(isLoopbackRedirectURI("http://127.0.0.1/oauth/cb")); + assert(isLoopbackRedirectURI("http://localhost:8080/")); + assert(isLoopbackRedirectURI("http://[::1]:9/cb")); + assert(!isLoopbackRedirectURI("https://127.0.0.1/callback")); + assert(!isLoopbackRedirectURI("http://example.com/callback")); + assert(!isLoopbackRedirectURI("http://127.0.0.1.evil.test/callback")); + assert(!isLoopbackRedirectURI("http://evil.com#@127.0.0.1/")); + assert(!isLoopbackRedirectURI("http://127.0.0.1:80/cb\r\nLocation: http://evil")); + assert(!isLoopbackRedirectURI("")); +} + +@safe unittest +{ + assert(isValidClientId("native")); + assert(isValidClientId("dub-publish")); + assert(isValidClientId("dubx")); + assert(!isValidClientId("")); + assert(!isValidClientId("has space")); + assert(!isValidClientId("slash/nope")); +} + +@safe unittest +{ + // RFC 7636 appendix B + enum verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert(pkceChallengeS256(verifier) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + assert(isValidPKCEVerifier(verifier)); + assert(isValidPKCEChallenge("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")); +} + +@safe unittest +{ + assert(isSafeLocalRedirect("/oauth/authorize?response_type=code")); + assert(isSafeLocalRedirect("/my_packages")); + assert(!isSafeLocalRedirect("https://evil.test/")); + assert(!isSafeLocalRedirect("//evil.test/")); + assert(!isSafeLocalRedirect("/\\evil.test")); + assert(!isSafeLocalRedirect("")); +} + +@safe unittest +{ + AuthorizeRequest ar; + ar.responseType = "code"; + ar.clientId = "native"; + ar.redirectUri = "http://127.0.0.1:1234/callback"; + ar.codeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + ar.codeChallengeMethod = "S256"; + ar.scopeName = "packages"; + assert(validateAuthorizeRequest(ar) is null); + + auto bad = ar; + bad.redirectUri = "https://code.dlang.org/callback"; + assert(validateAuthorizeRequest(bad) !is null); + + bad = ar; + bad.codeChallengeMethod = "plain"; + assert(validateAuthorizeRequest(bad) !is null); +} + +@safe unittest +{ + assert(extractBearerToken("Bearer abc") == "abc"); + assert(extractBearerToken("bearer xyz") == "xyz"); + assert(!extractBearerToken("Basic abc").length); + assert(!extractBearerToken("").length); +} diff --git a/source/dubregistry/oauthflags.d b/source/dubregistry/oauthflags.d new file mode 100644 index 00000000..cc6da722 --- /dev/null +++ b/source/dubregistry/oauthflags.d @@ -0,0 +1,8 @@ +/** + Shared flags for OAuth UI. Kept separate so Diet templates can read them + without importing the full OAuth module (avoids a userman.web cycle). +*/ +module dubregistry.oauthflags; + +/// True when a GitHub OAuth App is configured for website login. +__gshared bool githubOAuthEnabled; diff --git a/source/dubregistry/oauthstore.d b/source/dubregistry/oauthstore.d new file mode 100644 index 00000000..b57c5146 --- /dev/null +++ b/source/dubregistry/oauthstore.d @@ -0,0 +1,109 @@ +/** + MongoDB persistence for OAuth authorization codes and access tokens. + + Copyright: © 2013-2026 rejectedsoftware e.K. + License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. +*/ +module dubregistry.oauthstore; + +import dubregistry.mongodb : getMongoClient; + +import std.datetime.systime; +import std.datetime.timezone; +import std.typecons : Nullable; + +import vibe.data.bson; +import vibe.data.serialization; +import vibe.db.mongo.collection; + + +struct OAuthAuthCode { + BsonObjectID _id; + string codeHash; + string userId; + string clientId; + string redirectUri; + string codeChallenge; + string codeChallengeMethod; + @name("scope") string scopeName; + SysTime expiresAt; +} + +struct OAuthAccessToken { + BsonObjectID _id; + string tokenHash; + string userId; + string clientId; + @name("scope") string scopeName; + SysTime createdAt; + SysTime expiresAt; +} + +final class OAuthStore { +@safe: + private { + MongoCollection m_codes; + MongoCollection m_tokens; + } + + this(string dbname) + { + auto db = getMongoClient.getDatabase(dbname); + m_codes = db["oauth_codes"]; + m_tokens = db["oauth_tokens"]; + + IndexOptions unique; + unique.unique = true; + m_codes.createIndexes([ + IndexModel().add("codeHash", 1).withOptions(unique) + ]); + m_tokens.createIndexes([ + IndexModel().add("tokenHash", 1).withOptions(unique), + IndexModel().add("userId", 1) + ]); + } + + void putCode(ref OAuthAuthCode rec) + { + if (rec._id == BsonObjectID.init) + rec._id = BsonObjectID.generate(); + m_codes.insertOne(rec); + } + + Nullable!OAuthAuthCode takeCode(string codeHash) + { + Nullable!OAuthAuthCode none; + auto rec = m_codes.findOne!OAuthAuthCode(["codeHash": codeHash]); + if (rec.isNull) + return none; + m_codes.deleteOne(["_id": rec.get._id]); + if (rec.get.expiresAt < Clock.currTime(UTC())) + return none; + return rec; + } + + void putToken(ref OAuthAccessToken rec) + { + if (rec._id == BsonObjectID.init) + rec._id = BsonObjectID.generate(); + m_tokens.insertOne(rec); + } + + Nullable!OAuthAccessToken findTokenHash(string tokenHash) + { + Nullable!OAuthAccessToken none; + auto rec = m_tokens.findOne!OAuthAccessToken(["tokenHash": tokenHash]); + if (rec.isNull) + return none; + if (rec.get.expiresAt < Clock.currTime(UTC())) { + m_tokens.deleteOne(["_id": rec.get._id]); + return none; + } + return rec; + } + + void deleteToken(string tokenHash) + { + m_tokens.deleteOne(["tokenHash": tokenHash]); + } +} diff --git a/source/dubregistry/web.d b/source/dubregistry/web.d index 48ef8a43..45f42e65 100644 --- a/source/dubregistry/web.d +++ b/source/dubregistry/web.d @@ -7,6 +7,8 @@ module dubregistry.web; import dubregistry.dbcontroller; import dubregistry.internal.utils; +import dubregistry.oauth; +import dubregistry.oauthstore; import dubregistry.registry; import dubregistry.repositories.bitbucket; import dubregistry.repositories.github; @@ -18,6 +20,7 @@ import std.array; import std.file; import std.path; import std.string; +import std.typecons : Nullable; import userman.db.controller : UserManController; import userman.web; import vibe.d; @@ -26,11 +29,11 @@ static import userman.api; static import userman.db.controller; -DubRegistryWebFrontend registerDubRegistryWebFrontend(URLRouter router, DubRegistry registry, UserManController userman) +DubRegistryWebFrontend registerDubRegistryWebFrontend(URLRouter router, DubRegistry registry, UserManController userman, OAuthStore oauthStore = null) { DubRegistryWebFrontend webfrontend; if (userman) { - auto ff = new DubRegistryFullWebFrontend(registry, userman); + auto ff = new DubRegistryFullWebFrontend(registry, userman, oauthStore); webfrontend = ff; router.registerWebInterface(ff); router.registerUserManWebInterface(userman); @@ -485,12 +488,14 @@ class DubRegistryWebFrontend { class DubRegistryFullWebFrontend : DubRegistryWebFrontend { private { UserManWebAuthenticator m_usermanauth; + OAuthStore m_oauthStore; } - this(DubRegistry registry, UserManController userman) + this(DubRegistry registry, UserManController userman, OAuthStore oauthStore = null) { super(registry, userman); m_usermanauth = new UserManWebAuthenticator(createLocalUserManAPI(userman)); + m_oauthStore = oauthStore; } void querySearch(string q = "") @@ -954,6 +959,11 @@ class DubRegistryFullWebFrontend : DubRegistryWebFrontend { private User performAuth(HTTPServerRequest req, HTTPServerResponse res) { + auto bearer = tryBearerAuth(req, m_userman, m_oauthStore); + if (!bearer.isNull) + return bearer.get; + if (extractBearerToken(req).length) + throw new HTTPStatusException(HTTPStatus.unauthorized, "Invalid access token"); return m_usermanauth.performAuth(req, res); } } diff --git a/views/oauth.authorize.dt b/views/oauth.authorize.dt new file mode 100644 index 00000000..ab3c39a1 --- /dev/null +++ b/views/oauth.authorize.dt @@ -0,0 +1,35 @@ +extends layout + +block title + - title = "Authorize application"; + +block body + - if(error.length) + p.redAlert= error + + .inputForm + h1 Authorize application + p.light A local application wants to manage packages on your DUB account (signed in as #[strong #{user.name}]). + + p + strong Client: + | #{ar.clientId} + p + strong Redirect: + | #[code #{ar.redirectUri}] + p + strong Access: + | manage your packages (register, update, settings) + + form(method="POST", action="#{req.rootDir}oauth/authorize") + input(type="hidden", name="response_type", value=ar.responseType) + input(type="hidden", name="client_id", value=ar.clientId) + input(type="hidden", name="redirect_uri", value=ar.redirectUri) + input(type="hidden", name="state", value=ar.state) + input(type="hidden", name="code_challenge", value=ar.codeChallenge) + input(type="hidden", name="code_challenge_method", value=ar.codeChallengeMethod) + input(type="hidden", name="scope", value=ar.scopeName) + p + button(type="submit", name="allow", value="1") Authorize + | + button.danger(type="submit", name="allow", value="0") Deny diff --git a/views/oauth.error.dt b/views/oauth.error.dt new file mode 100644 index 00000000..ccd4c78f --- /dev/null +++ b/views/oauth.error.dt @@ -0,0 +1,11 @@ +extends layout + +block title + - title = "Authorization error"; + +block body + .inputForm + h1 Authorization error + p.redAlert= error + p + a.blind(href="#{req.rootDir}") Back to the registry diff --git a/views/userman.login.dt b/views/userman.login.dt index a2e7c5f7..d5869600 100644 --- a/views/userman.login.dt +++ b/views/userman.login.dt @@ -29,6 +29,15 @@ block body p button(type="submit") Login + - import dubregistry.oauthflags : githubOAuthEnabled; + - import vibe.textfilter.urlencode : urlEncode; + - if (githubOAuthEnabled) + - string ghLogin = "login/github"; + - if (redirect.length) + - ghLogin ~= "?redirect=" ~ urlEncode(redirect); + p + a.blind(href=ghLogin) Log in with GitHub + p h2 Cannot log in? p