diff --git a/docs/pages/providers/soundcloud.md b/docs/pages/providers/soundcloud.md new file mode 100644 index 00000000..0c95f7eb --- /dev/null +++ b/docs/pages/providers/soundcloud.md @@ -0,0 +1,78 @@ +--- +title: "SoundCloud" +--- + +# SoundCloud + +OAuth 2.0 provider for SoundCloud. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +```ts +import { SoundCloud } from "arctic"; + +const soundcloud = new SoundCloud(clientId, clientSecret, redirectURI); +``` + +## Create authorization URL + +```ts +import { generateState, createS256CodeChallenge } from "arctic"; + +const state = generateState(); +const s256Challenge = createS256CodeChallenge(); + +const url = soundcloud.createAuthorizationURL(state, s256Challenge); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). SoundCloud returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await soundcloud.validateAuthorizationCode(code, s256Challenge); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. SoundCloud returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await soundcloud.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/src/index.ts b/src/index.ts index cfdde2c3..844e0851 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { FortyTwo } from "./providers/42.js"; export { AmazonCognito } from "./providers/amazon-cognito.js"; export { AniList } from "./providers/anilist.js"; export { Apple } from "./providers/apple.js"; @@ -12,10 +13,10 @@ export { Dribbble } from "./providers/dribbble.js"; export { Dropbox } from "./providers/dropbox.js"; export { Facebook } from "./providers/facebook.js"; export { Figma } from "./providers/figma.js"; -export { Intuit } from "./providers/intuit.js"; export { GitHub } from "./providers/github.js"; export { GitLab } from "./providers/gitlab.js"; export { Google } from "./providers/google.js"; +export { Intuit } from "./providers/intuit.js"; export { Kakao } from "./providers/kakao.js"; export { KeyCloak } from "./providers/keycloak.js"; export { Lichess } from "./providers/lichess.js"; @@ -33,6 +34,7 @@ export { Roblox } from "./providers/roblox.js"; export { Salesforce } from "./providers/salesforce.js"; export { Shikimori } from "./providers/shikimori.js"; export { Slack } from "./providers/slack.js"; +export { SoundCloud } from "./providers/soundcloud.js"; export { Spotify } from "./providers/spotify.js"; export { Strava } from "./providers/strava.js"; export { Tiltify } from "./providers/tiltify.js"; @@ -44,8 +46,8 @@ export { WorkOS } from "./providers/workos.js"; export { Yahoo } from "./providers/yahoo.js"; export { Yandex } from "./providers/yandex.js"; export { Zoom } from "./providers/zoom.js"; -export { FortyTwo } from "./providers/42.js"; export { OAuth2Tokens, generateCodeVerifier, generateState } from "./oauth2.js"; -export { OAuth2RequestError, ArcticFetchError } from "./request.js"; export { decodeIdToken } from "./oidc.js"; +export { ArcticFetchError, OAuth2RequestError } from "./request.js"; + diff --git a/src/providers/soundcloud.ts b/src/providers/soundcloud.ts new file mode 100644 index 00000000..92463f9b --- /dev/null +++ b/src/providers/soundcloud.ts @@ -0,0 +1,57 @@ +import { createOAuth2Request, sendTokenRequest } from "../request.js"; + +import { type OAuth2Tokens } from "../oauth2.js"; + +const authorizationEndpoint = "https://secure.soundcloud.com/authorize"; +const tokenEndpoint = "https://secure.soundcloud.com/oauth/token"; + +export class SoundCloud { + private clientId: string; + private clientSecret: string; + private redirectURI: string; + + public constructor(clientId: string, clientSecret: string, redirectURI: string) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectURI = redirectURI; + } + + public createAuthorizationURL(state: string, challenge: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("redirect_uri", this.redirectURI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + return url; + } + + public async validateAuthorizationCode(code: string, challenge: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + body.set("redirect_uri", this.redirectURI); + body.set("code_verifier", challenge); + body.set("code", code); + const request = createOAuth2Request(tokenEndpoint, body); + // const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + // request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); + return tokens; + } + + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + // const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + // request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); + return tokens; + } +}