This commit is contained in:
Conrad Kramer 2024-03-30 16:47:59 -07:00
parent cb1bc1c8aa
commit 86594fb663
10 changed files with 507 additions and 81 deletions

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:burrow.rs?mode=developer</string>
<string>webcredentials:burrow.rs?mode=developer</string>
</array>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:burrow.rs?mode=developer</string>
<string>webcredentials:burrow.rs?mode=developer</string>
</array>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>

View file

@ -7,6 +7,12 @@ struct BurrowApp: App {
var body: some Scene {
WindowGroup {
BurrowView()
.onOpenURL { url in
print(url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
print(userActivity.webpageURL)
}
}
}
}

View file

@ -1,9 +1,29 @@
import AuthenticationServices
import SwiftUI
#if !os(macOS)
struct BurrowView: View {
@Environment(\.webAuthenticationSession)
private var webAuthenticationSession
var body: some View {
NavigationStack {
VStack {
HStack {
Text("Networks")
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
Menu {
Button("Hack Club", action: addHackClubNetwork)
Button("WireGuard", action: addWireGuardNetwork)
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
.accessibilityLabel("Add")
}
}
.padding(.top)
NetworkCarouselView()
Spacer()
TunnelStatusView()
@ -11,9 +31,31 @@ struct BurrowView: View {
.padding(.bottom)
}
.padding()
.navigationTitle("Networks")
}
}
private func addHackClubNetwork() {
guard
let issuerURL = URL(string: "https://slack.com"),
let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return }
Task {
do {
let session = try await OpenID.Session(
issuerURL: issuerURL,
redirectURI: redirectURI,
scopes: ["openid", "profile"],
clientID: "2210535565.6884042183125"
)
let response = try await session.authorize(webAuthenticationSession)
} catch {
print(error)
}
}
}
private func addWireGuardNetwork() {
}
}
#if DEBUG
@ -24,3 +66,4 @@ struct NetworkView_Previews: PreviewProvider {
}
}
#endif
#endif

View file

@ -0,0 +1,39 @@
import SwiftUI
struct NetworkCarouselView: View {
var networks: [any Network] = [
HackClub(id: "1"),
HackClub(id: "2"),
WireGuard(id: "4"),
HackClub(id: "5"),
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks, id: \.id) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
.scaleEffect(1.0 - abs(phase.value) * 0.1)
}
}
}
}
.scrollTargetLayout()
.scrollClipDisabled()
.scrollIndicators(.hidden)
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
}
}
#if DEBUG
struct NetworkCarouselView_Previews: PreviewProvider {
static var previews: some View {
NetworkCarouselView()
}
}
#endif

View file

@ -30,59 +30,9 @@ struct NetworkView<Content: View>: View {
}
}
struct AddNetworkView: View {
var body: some View {
Text("Add Network")
.frame(maxWidth: .infinity, minHeight: 175, maxHeight: 175)
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(style: .init(lineWidth: 2, dash: [6]))
)
}
}
extension NetworkView where Content == AnyView {
init(network: any Network) {
color = network.backgroundColor
content = { AnyView(network.label) }
}
}
struct NetworkCarouselView: View {
var networks: [any Network] = [
HackClub(id: "1"),
HackClub(id: "2"),
WireGuard(id: "4"),
HackClub(id: "5"),
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks, id: \.id) { network in
NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
.scaleEffect(1.0 - abs(phase.value) * 0.1)
}
}
AddNetworkView()
}
.scrollTargetLayout()
}
.scrollClipDisabled()
.scrollIndicators(.hidden)
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
}
}
#if DEBUG
struct NetworkCarouselView_Previews: PreviewProvider {
static var previews: some View {
NetworkCarouselView()
}
}
#endif

364
Apple/App/OpenID.swift Normal file
View file

@ -0,0 +1,364 @@
import AuthenticationServices
import SwiftUI
import Foundation
enum OAuth2 {
enum Error: Swift.Error {
case unknown
case invalidAuthorizationURL
case invalidCallbackURL
case invalidRedirectURI
case invalidScopes(Set<String>)
}
enum TokenType: Codable, LosslessStringConvertible {
case bearer
case unknown(String)
init?(_ description: String) {
self = switch description {
case "bearer": .bearer
default: .unknown(description)
}
}
var description: String {
switch self {
case .bearer: "bearer"
case .unknown(let type): type
}
}
}
enum GrantType: Codable, LosslessStringConvertible {
case authorizationCode
case unknown(String)
init?(_ description: String) {
self = switch description {
case "authorization_code": .authorizationCode
default: .unknown(description)
}
}
var description: String {
switch self {
case .authorizationCode: "authorization_code"
case .unknown(let type): type
}
}
}
enum ResponseType: Codable, LosslessStringConvertible {
case code
case idToken
case unknown(String)
init?(_ description: String) {
self = switch description {
case "code": .code
case "id_token": .idToken
default: .unknown(description)
}
}
var description: String {
switch self {
case .code: "code"
case .idToken: "id_token"
case .unknown(let type): type
}
}
}
fileprivate struct AccessTokenRequest: Codable {
var clientID: String
var grantType: GrantType
var code: String?
var redirectURI: URL?
}
struct AccessTokenResponse: Codable {
var accessToken: String
var tokenType: TokenType
var expiresIn: Double?
var refreshToken: String?
}
fileprivate struct CodeResponse: Codable {
var code: String
var state: String?
}
fileprivate static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
fileprivate static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}
}
enum OpenID {
struct Configuration: Codable {
var issuer: URL
var authorizationEndpoint: URL
var tokenEndpoint: URL
var userinfoEndpoint: URL
var scopesSupported: Set<String>
static func load(from issuerURL: URL) async throws -> Self {
let configurationURL = issuerURL
.appending(component: ".well-known")
.appending(component: "openid-configuration")
let (data, _) = try await URLSession.shared.data(from: configurationURL)
return try OAuth2.decoder.decode(Self.self, from: data)
}
}
struct Session {
var authorizationEndpoint: URL
var tokenEndpoint: URL
var redirectURI: URL
var responseType = OAuth2.ResponseType.code
var scopes: Set<String>
var clientID: String
init(issuerURL: URL, redirectURI: URL, scopes: Set<String>, clientID: String) async throws {
async let configuration = Configuration.load(from: issuerURL)
try await self.init(
configuration: configuration,
redirectURI: redirectURI,
scopes: scopes,
clientID: clientID
)
}
init(configuration: Configuration, redirectURI: URL, scopes: Set<String>, clientID: String) throws {
guard scopes.isSubset(of: configuration.scopesSupported) else {
throw OAuth2.Error.invalidScopes(scopes.subtracting(configuration.scopesSupported))
}
self.authorizationEndpoint = configuration.authorizationEndpoint
self.tokenEndpoint = configuration.tokenEndpoint
self.redirectURI = redirectURI
self.scopes = scopes
self.clientID = clientID
}
private var authorizationURL: URL {
get throws {
var queryItems: [URLQueryItem] = [
.init(name: "client_id", value: clientID),
.init(name: "response_type", value: responseType.description),
.init(name: "redirect_uri", value: redirectURI.absoluteString),
]
if !scopes.isEmpty {
queryItems.append(.init(name: "scope", value: scopes.joined(separator: ",")))
}
guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else {
throw OAuth2.Error.invalidAuthorizationURL
}
components.queryItems = queryItems
guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL }
return authorizationURL
}
}
private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse {
switch responseType {
case .code:
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
throw OAuth2.Error.invalidCallbackURL
}
return try await handle(response: try components.decode(OAuth2.CodeResponse.self))
default:
throw OAuth2.Error.invalidCallbackURL
}
}
private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse {
let body = OAuth2.AccessTokenRequest(clientID: clientID, grantType: .authorizationCode, code: response.code)
var request = URLRequest(url: tokenEndpoint)
request.httpMethod = "POST"
request.httpBody = try OAuth2.encoder.encode(body)
let session = URLSession(configuration: .ephemeral)
let (data, _) = try await session.data(for: request)
let response = try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data)
return response
}
func authorize(
configure: (ASWebAuthenticationSession) -> Void
) async throws -> OAuth2.AccessTokenResponse {
let authorizationURL = try authorizationURL
let callbackURL = try await ASWebAuthenticationSession.start(
url: authorizationURL,
redirectURI: redirectURI,
configure: configure
)
return try await handle(callbackURL: callbackURL)
}
func authorize(_ session: WebAuthenticationSession) async throws -> OAuth2.AccessTokenResponse {
let authorizationURL = try authorizationURL
let callbackURL = try await session.start(
url: authorizationURL,
redirectURI: redirectURI
)
return try await handle(callbackURL: callbackURL)
}
}
}
extension WebAuthenticationSession {
func start(url: URL, redirectURI: URL) async throws -> URL {
if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) {
return try await authenticate(
using: url,
callback: try ASWebAuthenticationSession.callback(for: redirectURI),
additionalHeaderFields: [:]
)
} else {
let callbackURLScheme = try ASWebAuthenticationSession.callbackURLScheme(for: redirectURI) ?? ""
return try await authenticate(using: url, callbackURLScheme: callbackURLScheme)
}
}
}
extension ASWebAuthenticationSession {
static func start(url: URL, redirectURI: URL, configure: (ASWebAuthenticationSession) -> Void) async throws -> URL {
try await withUnsafeThrowingContinuation { continuation in
do {
let session: ASWebAuthenticationSession
if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) {
session = ASWebAuthenticationSession(
url: url,
callback: try callback(for: redirectURI),
completionHandler: completionHandler(for: continuation)
)
} else {
session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: try callbackURLScheme(for: redirectURI),
completionHandler: completionHandler(for: continuation)
)
}
configure(session)
session.start()
} catch {
continuation.resume(throwing: error)
}
}
}
private static func completionHandler(for continuation: UnsafeContinuation<URL, Error>) -> CompletionHandler {
return { url, error in
if let url {
continuation.resume(returning: url)
} else {
continuation.resume(throwing: error ?? OAuth2.Error.unknown)
}
}
}
}
extension ASWebAuthenticationSession {
@available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *)
fileprivate static func callback(for redirectURI: URL) throws -> Callback {
switch redirectURI.scheme {
case "https":
guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI }
return .https(host: host, path: redirectURI.path)
case "http":
throw OAuth2.Error.invalidRedirectURI
case .some(let scheme):
return .customScheme(scheme)
case .none:
throw OAuth2.Error.invalidRedirectURI
}
}
fileprivate static func callbackURLScheme(for redirectURI: URL) throws -> String? {
switch redirectURI.scheme {
case "http", .none:
return nil
case "https":
#if os(macOS)
if
let host = url.host,
let associatedDomains = try? Task.current.associatedDomains,
!associatedDomains.contains(host) {
throw OAuth2.Error.invalidCallbackURL
}
#endif
return "https"
case .some(let scheme):
return scheme
}
}
}
extension URLComponents {
fileprivate func decode<T: Decodable>(_ type: T.Type) throws -> T {
guard let queryItems else {
throw DecodingError.valueNotFound(
T.self,
.init(codingPath: [], debugDescription: "Missing query items")
)
}
let data = try JSONEncoder().encode(try queryItems.values)
return try JSONDecoder().decode(T.self, from: data)
}
}
extension Sequence where Element == URLQueryItem {
fileprivate var values: [String: String?] {
get throws {
try Dictionary(map { ($0.name, $0.value) }) { _, _ in
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items"))
}
}
}
}
#if os(macOS)
import Security
private struct Task {
enum Error: Swift.Error {
case unknown
}
static var current: Self {
get throws {
guard let task = SecTaskCreateFromSelf(nil) else { throw Error.unknown }
return Self(task: task)
}
}
var task: SecTask
var associatedDomains: [String] {
get throws {
var error: Unmanaged<CFError>?
let value = SecTaskCopyValueForEntitlement(
task,
"com.apple.developer.associated-domains" as CFString,
&error
)
if let error = error?.takeRetainedValue() {
throw error
}
return value as! [String] // swiftlint:disable:this force_cast
}
}
}
#endif

View file

@ -4,16 +4,19 @@ struct TunnelButton: View {
@Environment(\.tunnel)
var tunnel: any Tunnel
private var action: Action? { tunnel.action }
var body: some View {
if let action = tunnel.action {
Button {
Button {
if let action {
tunnel.perform(action)
} label: {
Text(action.description)
}
.padding(.horizontal)
.buttonStyle(.floating)
} label: {
Text(action.description)
}
.disabled(action.isDisabled)
.padding(.horizontal)
.buttonStyle(.floating)
}
}
@ -40,12 +43,21 @@ extension TunnelButton {
}
}
extension TunnelButton.Action {
extension TunnelButton.Action? {
var description: LocalizedStringKey {
switch self {
case .enable: "Enable"
case .start: "Start"
case .stop: "Stop"
case .none: "Start"
}
}
var isDisabled: Bool {
if case .none = self {
true
} else {
false
}
}
}