| 1 | //! https://oauth.net/2/ |
| 2 | |
| 3 | const std = @import("std"); |
| 4 | const string = []const u8; |
| 5 | const files = @import("./files.zig"); |
| 6 | const pek = @import("pek"); |
| 7 | const extras = @import("extras"); |
| 8 | const url = @import("url"); |
| 9 | const http = @import("http"); |
| 10 | const nio = @import("nio"); |
| 11 | const json = @import("json"); |
| 12 | const builtin = @import("builtin"); |
| 13 | const root = @import("root"); |
| 14 | const Base = @This(); |
| 15 | |
| 16 | pub const Provider = struct { |
| 17 | id: string, |
| 18 | authorize_url: string, |
| 19 | token_url: string, |
| 20 | me_url: string, |
| 21 | scope: string = "", |
| 22 | name_prop: string, |
| 23 | name_prefix: string = "", |
| 24 | id_prop: string = "id", |
| 25 | logo: string, |
| 26 | color: string, |
| 27 | |
| 28 | pub fn real_id(self: Provider) string { |
| 29 | if (std.mem.indexOfScalar(u8, self.id, ',')) |_| { |
| 30 | var iter = std.mem.splitScalar(u8, self.id, ','); |
| 31 | return iter.next().?; |
| 32 | } |
| 33 | return self.id; |
| 34 | } |
| 35 | |
| 36 | pub fn domain(self: Provider) string { |
| 37 | if (std.mem.indexOfScalar(u8, self.id, ',')) |_| { |
| 38 | var iter = std.mem.splitScalar(u8, self.id, ','); |
| 39 | _ = iter.next(); |
| 40 | return iter.next().?; |
| 41 | } |
| 42 | return self.id; |
| 43 | } |
| 44 | }; |
| 45 | |
| 46 | pub const Client = struct { |
| 47 | provider: Provider, |
| 48 | id: string, |
| 49 | secret: string, |
| 50 | }; |
| 51 | |
| 52 | fn icon_url(comptime name: string) string { |
| 53 | return "https://unpkg.com/simple-icons@" ++ "5.13.0" ++ "/icons/" ++ name ++ ".svg"; |
| 54 | } |
| 55 | |
| 56 | pub const providers = struct { |
| 57 | pub var amazon = Provider{ |
| 58 | .id = "amazon", |
| 59 | .authorize_url = "https://www.amazon.com/ap/oa", |
| 60 | .token_url = "https://api.amazon.com/auth/o2/token", |
| 61 | .me_url = "https://api.amazon.com/user/profile", |
| 62 | .scope = "profile", |
| 63 | .name_prop = "name", |
| 64 | .id_prop = "user_id", |
| 65 | .logo = icon_url("amazon"), |
| 66 | .color = "#FF9900", |
| 67 | }; |
| 68 | pub var battle_net = Provider{ |
| 69 | .id = "battle.net", |
| 70 | .authorize_url = "https://us.battle.net/oauth/authorize", |
| 71 | .token_url = "https://us.battle.net/oauth/token", |
| 72 | .me_url = "https://us.battle.net/oauth/userinfo", |
| 73 | .scope = "openid", |
| 74 | .name_prop = "battletag", |
| 75 | .logo = icon_url("battle-dot-net"), |
| 76 | .color = "#00AEFF", |
| 77 | }; |
| 78 | pub var discord = Provider{ |
| 79 | .id = "discord", |
| 80 | .authorize_url = "https://discordapp.com/api/oauth2/authorize", |
| 81 | .token_url = "https://discordapp.com/api/oauth2/token", |
| 82 | .me_url = "https://discordapp.com/api/users/@me", |
| 83 | .scope = "identify", |
| 84 | .name_prop = "username", |
| 85 | .name_prefix = "@", |
| 86 | .logo = icon_url("discord"), |
| 87 | .color = "#7289DA", |
| 88 | }; |
| 89 | pub var facebook = Provider{ |
| 90 | .id = "facebook", |
| 91 | .authorize_url = "https://graph.facebook.com/oauth/authorize", |
| 92 | .token_url = "https://graph.facebook.com/oauth/access_token", |
| 93 | .me_url = "https://graph.facebook.com/me", |
| 94 | .name_prop = "name", |
| 95 | .logo = icon_url("facebook"), |
| 96 | .color = "#1877F2", |
| 97 | }; |
| 98 | pub var github = Provider{ |
| 99 | .id = "github", |
| 100 | .authorize_url = "https://github.com/login/oauth/authorize", |
| 101 | .token_url = "https://github.com/login/oauth/access_token", |
| 102 | .me_url = "https://api.github.com/user", |
| 103 | .scope = "read:user", |
| 104 | .name_prop = "login", |
| 105 | .name_prefix = "@", |
| 106 | .logo = icon_url("github"), |
| 107 | .color = "#181717", |
| 108 | }; |
| 109 | pub var google = Provider{ |
| 110 | .id = "google", |
| 111 | .authorize_url = "https://accounts.google.com/o/oauth2/v2/auth", |
| 112 | .token_url = "https://www.googleapis.com/oauth2/v4/token", |
| 113 | .me_url = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", |
| 114 | .scope = "profile", |
| 115 | .name_prop = "name", |
| 116 | .logo = icon_url("google"), |
| 117 | .color = "#4285F4", |
| 118 | }; |
| 119 | pub var microsoft = Provider{ |
| 120 | .id = "microsoft", |
| 121 | .authorize_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", |
| 122 | .token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token", |
| 123 | .me_url = "https://graph.microsoft.com/v1.0/me/", |
| 124 | .scope = "https://graph.microsoft.com/user.read", |
| 125 | .name_prop = "displayName", |
| 126 | .logo = icon_url("microsoft"), |
| 127 | .color = "#666666", |
| 128 | }; |
| 129 | pub var reddit = Provider{ |
| 130 | .id = "reddit", |
| 131 | .authorize_url = "https://old.reddit.com/api/v1/authorize", |
| 132 | .token_url = "https://old.reddit.com/api/v1/access_token", |
| 133 | .me_url = "https://oauth.reddit.com/api/v1/me", |
| 134 | .scope = "identity", |
| 135 | .name_prop = "name", |
| 136 | .name_prefix = "u/", |
| 137 | .logo = icon_url("reddit"), |
| 138 | .color = "#FF4500", |
| 139 | }; |
| 140 | pub var railway = Provider{ |
| 141 | .id = "railway", |
| 142 | .authorize_url = "https://backboard.railway.com/oauth/auth", |
| 143 | .token_url = "https://backboard.railway.com/oauth/token", |
| 144 | .me_url = "https://backboard.railway.com/oauth/me", |
| 145 | .scope = "openid+profile", |
| 146 | .name_prop = "name", |
| 147 | .name_prefix = "", |
| 148 | .logo = icon_url("railway"), |
| 149 | .color = "#0B0D0E", |
| 150 | }; |
| 151 | }; |
| 152 | |
| 153 | pub const dynamic_providers = struct { |
| 154 | pub const _gitea = Provider{ |
| 155 | .id = "gitea", |
| 156 | .authorize_url = "https://{[domain]s}/login/oauth/authorize", |
| 157 | .token_url = "https://{[domain]s}/login/oauth/access_token", |
| 158 | .me_url = "https://{[domain]s}/api/v1/user", |
| 159 | .name_prop = "username", |
| 160 | .name_prefix = "@", |
| 161 | .logo = icon_url("gitea"), |
| 162 | .color = "#609926", |
| 163 | }; |
| 164 | pub const _forgejo = Provider{ |
| 165 | .id = "forgejo", |
| 166 | .authorize_url = "https://{[domain]s}/login/oauth/authorize", |
| 167 | .token_url = "https://{[domain]s}/login/oauth/access_token", |
| 168 | .me_url = "https://{[domain]s}/api/v1/user", |
| 169 | .name_prop = "username", |
| 170 | .name_prefix = "@", |
| 171 | .logo = icon_url("forgejo"), |
| 172 | .color = "#FB923C", |
| 173 | }; |
| 174 | pub const _gitlab = Provider{ |
| 175 | .id = "gitlab", |
| 176 | .authorize_url = "https://{[domain]s}/oauth/authorize", |
| 177 | .token_url = "https://{[domain]s}/oauth/token", |
| 178 | .me_url = "https://{[domain]s}/api/v4/user", |
| 179 | .scope = "read_user", |
| 180 | .name_prop = "username", |
| 181 | .name_prefix = "@", |
| 182 | .logo = icon_url("gitlab"), |
| 183 | .color = "#FCA121", |
| 184 | }; |
| 185 | pub const _mastodon = Provider{ |
| 186 | .id = "mastodon", |
| 187 | .authorize_url = "https://{[domain]s}/oauth/authorize", |
| 188 | .token_url = "https://{[domain]s}/oauth/token", |
| 189 | .me_url = "https://{[domain]s}/api/v1/accounts/verify_credentials", |
| 190 | .scope = "read:accounts", |
| 191 | .name_prop = "username", |
| 192 | .name_prefix = "@", |
| 193 | .logo = icon_url("mastodon"), |
| 194 | .color = "#3088D4", |
| 195 | }; |
| 196 | pub const _pleroma = Provider{ |
| 197 | .id = "pleroma", |
| 198 | .authorize_url = "https://{[domain]s}/oauth/authorize", |
| 199 | .token_url = "https://{[domain]s}/oauth/token", |
| 200 | .me_url = "https://{[domain]s}/api/v1/accounts/verify_credentials", |
| 201 | .scope = "read:accounts", |
| 202 | .name_prop = "username", |
| 203 | .name_prefix = "@", |
| 204 | .logo = icon_url("pleroma"), |
| 205 | .color = "#FBA457", |
| 206 | }; |
| 207 | }; |
| 208 | |
| 209 | pub fn providerById(alloc: std.mem.Allocator, name: string) !?Provider { |
| 210 | inline for (comptime std.meta.declarations(providers)) |item| { |
| 211 | const p = @field(providers, item.name); |
| 212 | if (std.mem.eql(u8, p.id, name)) { |
| 213 | return p; |
| 214 | } |
| 215 | } |
| 216 | inline for (comptime extras.globalOption("oauth2_providers", []const Provider) orelse &.{}) |p| { |
| 217 | if (std.mem.eql(u8, p.id, name)) { |
| 218 | return p; |
| 219 | } |
| 220 | } |
| 221 | const c_ind = std.mem.indexOfScalar(u8, name, ',') orelse return null; |
| 222 | const p_id = name[0..c_ind]; |
| 223 | const domain = name[c_ind + 1 ..]; |
| 224 | const args = .{ .domain = domain }; |
| 225 | inline for (comptime std.meta.declarations(dynamic_providers)) |item| { |
| 226 | const didp = @field(dynamic_providers, item.name); |
| 227 | if (std.mem.eql(u8, didp.id, p_id)) { |
| 228 | return Provider{ |
| 229 | .id = name, |
| 230 | .authorize_url = try nio.fmt.allocPrint(alloc, didp.authorize_url, args), |
| 231 | .token_url = try nio.fmt.allocPrint(alloc, didp.token_url, args), |
| 232 | .me_url = try nio.fmt.allocPrint(alloc, didp.me_url, args), |
| 233 | .scope = didp.scope, |
| 234 | .name_prop = didp.name_prop, |
| 235 | .name_prefix = didp.name_prefix, |
| 236 | .id_prop = didp.id_prop, |
| 237 | .logo = didp.logo, |
| 238 | .color = didp.color, |
| 239 | }; |
| 240 | } |
| 241 | } |
| 242 | if (std.mem.eql(u8, p_id, "oidc")) { |
| 243 | const io = root.io; |
| 244 | var buf: [4096]u8 = @splat(0); |
| 245 | var http_client: std.http.Client = .{ .allocator = alloc, .io = io }; |
| 246 | defer http_client.deinit(); |
| 247 | |
| 248 | const url_s = try nio.fmt.allocPrint(alloc, "https://{s}/.well-known/openid-configuration", .{domain}); |
| 249 | defer alloc.free(url_s); |
| 250 | |
| 251 | var req = try http_client.request(.GET, try std.Uri.parse(url_s), .{ |
| 252 | .headers = .{ |
| 253 | .accept_encoding = .{ .override = "identity" }, |
| 254 | }, |
| 255 | .redirect_behavior = .not_allowed, |
| 256 | }); |
| 257 | defer req.deinit(); |
| 258 | try req.sendBodiless(); |
| 259 | var resp = try req.receiveHead(&.{}); |
| 260 | const body_content = try resp.reader(&buf).allocRemaining(alloc, .limited(1024 * 1024 * 5)); |
| 261 | defer alloc.free(body_content); |
| 262 | if (resp.head.status != .ok) std.log.scoped(.oauth).err("GET '{s}': {d}", .{ url_s, resp.head.status }); |
| 263 | if (resp.head.status != .ok) std.log.scoped(.oauth).err("{s}", .{body_content}); |
| 264 | if (resp.head.status != .ok) return null; |
| 265 | const val = try json.parseFromSlice(alloc, "body.json", body_content, .{ .maximum_depth = 100, .support_trailing_commas = true }); |
| 266 | defer val.deinit(alloc); |
| 267 | val.acquire(); |
| 268 | defer val.release(); |
| 269 | const rootobj = val.root.object(); |
| 270 | const authorize_url = rootobj.getS("authorization_endpoint") orelse { |
| 271 | std.log.scoped(.oauth).err("openid-configuration did not have the expected 'authorization_endpoint' key", .{}); |
| 272 | return null; |
| 273 | }; |
| 274 | const token_url = rootobj.getS("token_endpoint") orelse { |
| 275 | std.log.scoped(.oauth).err("openid-configuration did not have the expected 'token_endpoint' key", .{}); |
| 276 | return null; |
| 277 | }; |
| 278 | const me_url = rootobj.getS("userinfo_endpoint") orelse { |
| 279 | std.log.scoped(.oauth).err("openid-configuration did not have the expected 'userinfo_endpoint' key", .{}); |
| 280 | return null; |
| 281 | }; |
| 282 | return Provider{ |
| 283 | .id = name, |
| 284 | .authorize_url = try alloc.dupe(u8, authorize_url), |
| 285 | .token_url = try alloc.dupe(u8, token_url), |
| 286 | .me_url = try alloc.dupe(u8, me_url), |
| 287 | .scope = "openid", |
| 288 | .name_prop = "preferred_username", |
| 289 | .name_prefix = "", |
| 290 | .id_prop = "sub", |
| 291 | .logo = icon_url("openid"), |
| 292 | .color = "#F78C40", |
| 293 | }; |
| 294 | } |
| 295 | inline for (comptime extras.globalOption("oauth2_dynamic_providers", []const Provider) orelse &.{}) |didp| { |
| 296 | if (std.mem.eql(u8, didp.id, p_id)) { |
| 297 | return Provider{ |
| 298 | .id = name, |
| 299 | .authorize_url = try nio.fmt.allocPrint(alloc, didp.authorize_url, args), |
| 300 | .token_url = try nio.fmt.allocPrint(alloc, didp.token_url, args), |
| 301 | .me_url = try nio.fmt.allocPrint(alloc, didp.me_url, args), |
| 302 | .scope = didp.scope, |
| 303 | .name_prop = didp.name_prop, |
| 304 | .name_prefix = didp.name_prefix, |
| 305 | .id_prop = didp.id_prop, |
| 306 | .logo = didp.logo, |
| 307 | .color = didp.color, |
| 308 | }; |
| 309 | } |
| 310 | } |
| 311 | return null; |
| 312 | } |
| 313 | |
| 314 | pub fn clientByProviderId(clients: []const Client, name: string) ?Client { |
| 315 | for (clients) |item| { |
| 316 | if (std.mem.eql(u8, name, item.provider.id)) { |
| 317 | return item; |
| 318 | } |
| 319 | } |
| 320 | return null; |
| 321 | } |
| 322 | |
| 323 | pub fn Handlers(comptime T: type) type { |
| 324 | return struct { |
| 325 | const Self = @This(); |
| 326 | pub var clients: []Client = &.{}; |
| 327 | |
| 328 | pub fn login(request: *http.ServerRequest, body_writer: anytype, alloc: std.mem.Allocator, query: url.SearchParams, request_headers: *const http.HeadersMap, response_status: *http.Status, response_headers: *http.HeadersMap) !void { |
| 329 | if (query.get("with")) |with| { |
| 330 | const client = clientByProviderId(Self.clients, with) orelse return try fail(response_status, body_writer, "Client with that ID not found!\n", .{}); |
| 331 | return try loginOne(request, alloc, T, client, T.callbackPath, request_headers, response_status, response_headers); |
| 332 | } |
| 333 | if (Self.clients.len == 1) { |
| 334 | return try loginOne(request, alloc, T, clients[0], T.callbackPath, request_headers, response_status, response_headers); |
| 335 | } |
| 336 | |
| 337 | try response_headers.append("content-type", "text/html"); |
| 338 | const page = files.@"/selector.pek"; |
| 339 | const tmpl = comptime pek.parse(page); |
| 340 | try pek.compile(Base, alloc, body_writer, tmpl, .{ |
| 341 | .clients = Self.clients, |
| 342 | }); |
| 343 | } |
| 344 | |
| 345 | pub fn callback(request: *http.ServerRequest, body_writer: anytype, alloc: std.mem.Allocator, query: url.SearchParams, request_headers: *const http.HeadersMap, response_status: *http.Status, response_headers: *http.HeadersMap) !void { |
| 346 | _ = request; |
| 347 | const state = query.get("state") orelse return try fail(response_status, body_writer, "", .{}); |
| 348 | const client = clientByProviderId(Self.clients, state) orelse return try fail(response_status, body_writer, "error: No handler found for provider: {s}\n", .{state}); |
| 349 | const code = query.get("code") orelse return try fail(response_status, body_writer, "", .{}); |
| 350 | |
| 351 | const io = if (!builtin.is_test) root.io else std.Options.debug_io; |
| 352 | var buf: [4096]u8 = @splat(0); |
| 353 | var http_client: std.http.Client = .{ .allocator = alloc, .io = io }; |
| 354 | defer http_client.deinit(); |
| 355 | |
| 356 | var params = url.SearchParams.init(alloc); |
| 357 | try params.append("client_id", client.id); |
| 358 | try params.append("client_secret", client.secret); |
| 359 | try params.append("grant_type", "authorization_code"); |
| 360 | try params.append("code", code); |
| 361 | try params.append("redirect_uri", try redirectUri(request_headers, alloc, T.callbackPath)); |
| 362 | try params.append("state", "none"); |
| 363 | const req_body = try params.encode(); |
| 364 | |
| 365 | var req = try http_client.request(.POST, try std.Uri.parse(client.provider.token_url), .{ |
| 366 | .headers = .{ |
| 367 | .accept_encoding = .{ .override = "identity" }, |
| 368 | .authorization = .{ .override = try nio.fmt.allocPrint(alloc, "Basic {s}", .{try extras.base64EncodeAlloc(alloc, try std.mem.join(alloc, ":", &.{ client.id, client.secret }))}) }, |
| 369 | .content_type = .{ .override = "application/x-www-form-urlencoded" }, |
| 370 | }, |
| 371 | .extra_headers = &.{ |
| 372 | .{ .name = "Accept", .value = "application/json" }, |
| 373 | }, |
| 374 | .redirect_behavior = .not_allowed, |
| 375 | }); |
| 376 | defer req.deinit(); |
| 377 | try req.sendBodyComplete(req_body); |
| 378 | var resp = try req.receiveHead(&.{}); |
| 379 | const body_content = try resp.reader(&buf).allocRemaining(alloc, .limited(1024 * 1024 * 5)); |
| 380 | if (resp.head.status != .ok) std.log.scoped(.oauth).debug("{s}: {s}", .{ @tagName(resp.head.status), body_content }); |
| 381 | if (resp.head.status != .ok) return error.OauthBadToken; |
| 382 | const val = try json.parseFromSlice(alloc, "body.json", body_content, .{ .maximum_depth = 100, .support_trailing_commas = true }); |
| 383 | val.acquire(); |
| 384 | const tt = val.root.object().getS("token_type").?; |
| 385 | if (!std.ascii.eqlIgnoreCase(tt, "bearer")) return fail(response_status, body_writer, "oauth2: invalid token type: expected 'bearer', got '{s}'", .{tt}); |
| 386 | const at = val.root.object().getS("access_token") orelse return try fail(response_status, body_writer, "Identity Provider Login Error!\n{s}", .{body_content}); |
| 387 | val.release(); |
| 388 | |
| 389 | var req2 = try http_client.request(.GET, try std.Uri.parse(client.provider.me_url), .{ |
| 390 | .headers = .{ |
| 391 | .accept_encoding = .{ .override = "identity" }, |
| 392 | .authorization = .{ .override = try nio.fmt.allocPrint(alloc, "Bearer {s}", .{at}) }, |
| 393 | }, |
| 394 | .extra_headers = &.{ |
| 395 | .{ .name = "Accept", .value = "application/json" }, |
| 396 | }, |
| 397 | .redirect_behavior = .not_allowed, |
| 398 | }); |
| 399 | defer req2.deinit(); |
| 400 | try req2.sendBodiless(); |
| 401 | var resp2 = try req2.receiveHead(&.{}); |
| 402 | const body_content2 = try resp2.reader(&buf).allocRemaining(alloc, .limited(1024 * 1024 * 5)); |
| 403 | if (resp2.head.status != .ok) std.log.scoped(.oauth).debug("{s}: {s}", .{ @tagName(resp2.head.status), body_content2 }); |
| 404 | if (resp2.head.status != .ok) return error.OauthBadUserinfo; |
| 405 | const val2 = try json.parseFromSlice(alloc, "body2.json", body_content2, .{ .maximum_depth = 100, .support_trailing_commas = true }); |
| 406 | val2.acquire(); |
| 407 | const id = try fixId(val2.root.object().getAny(client.provider.id_prop).?); |
| 408 | const name = val2.root.object().getS(client.provider.name_prop).?; |
| 409 | val2.release(); |
| 410 | try T.saveInfo(response_headers, alloc, client.provider, id, name, val, val2); |
| 411 | |
| 412 | try response_headers.append("location", T.doneUrl); |
| 413 | response_status.* = .found; |
| 414 | } |
| 415 | }; |
| 416 | } |
| 417 | |
| 418 | fn loginOne(request: *http.ServerRequest, alloc: std.mem.Allocator, comptime T: type, client: Client, callbackPath: string, request_headers: *const http.HeadersMap, response_status: *http.Status, response_headers: *http.HeadersMap) !void { |
| 419 | if (try T.isLoggedIn(request, alloc)) { |
| 420 | try response_headers.append("location", T.doneUrl); |
| 421 | } else { |
| 422 | const idp = client.provider; |
| 423 | var params = url.SearchParams.init(alloc); |
| 424 | try params.append("client_id", client.id); |
| 425 | try params.append("redirect_uri", try redirectUri(request_headers, alloc, callbackPath)); |
| 426 | try params.append("response_type", "code"); |
| 427 | try params.append("scope", idp.scope); |
| 428 | try params.append("duration", "temporary"); |
| 429 | try params.append("state", idp.id); |
| 430 | const authurl = try std.mem.join(alloc, "?", &.{ idp.authorize_url, try params.encode() }); |
| 431 | try response_headers.append("location", authurl); |
| 432 | } |
| 433 | response_status.* = .found; |
| 434 | } |
| 435 | |
| 436 | fn fail(response_status: *http.Status, body_writer: anytype, comptime err: string, args: anytype) !void { |
| 437 | response_status.* = .bad_request; |
| 438 | try body_writer.print(err, args); |
| 439 | } |
| 440 | |
| 441 | fn redirectUri(request_headers: *const http.HeadersMap, alloc: std.mem.Allocator, callbackPath: string) !string { |
| 442 | const xproto = request_headers.find("x-forwarded-proto") orelse ""; |
| 443 | const maybe_tls = std.mem.eql(u8, xproto, "https"); |
| 444 | const proto: string = if (maybe_tls) "https" else "http"; |
| 445 | const host = request_headers.find("host").?; |
| 446 | return try nio.fmt.allocPrint(alloc, "{s}://{s}{s}", .{ proto, host, callbackPath }); |
| 447 | } |
| 448 | |
| 449 | fn fixId(id: json.ValueIndex) !string { |
| 450 | return switch (id.v()) { |
| 451 | .string => |v| v.to(), |
| 452 | .number => |v| v.to(), |
| 453 | else => unreachable, |
| 454 | }; |
| 455 | } |
| 456 | |
| 457 | pub fn pek_domain(alloc: std.mem.Allocator, writer: pek.Writer, p: Provider) !void { |
| 458 | _ = alloc; |
| 459 | try writer.writeAll(p.domain()); |
| 460 | } |